Files
replypier/backend/app/utils/domain_check.py
T
WangShen007 5e0615f36d feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
2026-09-23 22:05:00 +08:00

110 lines
4.1 KiB
Python

"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Whether a signup domain resolves in DNS - for a soft warning, never a block:
some people sign up before their site is live.
"""
import asyncio
import socket
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
LOOKUP_TIMEOUT_SECONDS = 2.0
# getaddrinfo blocks, and a thread can't be cancelled: a dead nameserver holds
# one until the system resolver gives up, long after our timeout. So lookups
# get a small pool of their own (never the event loop's shared one) and never
# queue: when every slot is busy the answer is "can't tell".
MAX_LOOKUPS = 4
_executor = ThreadPoolExecutor(max_workers=MAX_LOOKUPS, thread_name_prefix="domain-check")
_slots = threading.BoundedSemaphore(MAX_LOOKUPS)
# Only these mean "no such name"; anything else (EAI_AGAIN, a failing
# resolver) is a temporary failure and says nothing about the domain.
_NOT_FOUND = {socket.EAI_NONAME, getattr(socket, "EAI_NODATA", socket.EAI_NONAME)}
# The field re-checks on every pause in typing: remember definite answers
# for a while so that doesn't cost a lookup each time.
CACHE_SECONDS = 600
CACHE_LIMIT = 1000
_cache: Dict[str, Tuple[float, bool]] = {}
def bare_host(domain: str) -> str:
"""'https://Example.com/path' -> 'example.com'."""
value = domain.strip()
return (urlparse(value if "//" in value else f"//{value}").hostname or "").lower()
def site_host(domain: str) -> str:
"""An org's site as hostnames compare: 'https://WWW.Acme.com/' -> 'acme.com'.
CORS admits this host and its www., however the domain was typed."""
return bare_host(domain).removeprefix("www.")
def _lookup(host: str) -> Optional[bool]:
try:
socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
return True
except socket.gaierror as e:
return False if e.errno in _NOT_FOUND else None
async def _resolves(host: str) -> Optional[bool]:
if not _slots.acquire(blocking=False):
return None
try:
future = _executor.submit(_lookup, host)
except RuntimeError: # pool shut down
_slots.release()
return None
# Released when the lookup ends - or is cancelled before it ever ran,
# which a release inside _lookup would miss.
future.add_done_callback(lambda _: _slots.release())
return await asyncio.wrap_future(future)
def _combine(answers) -> Optional[bool]:
"""Any address is enough; "doesn't exist" only when every name said so."""
if any(answer is True for answer in answers):
return True
return False if answers and all(answer is False for answer in answers) else None
async def domain_resolves(domain: str) -> Optional[bool]:
"""True/False when DNS answered, None when it couldn't say in time (or the
input isn't a hostname) - callers warn only on a definite False. Asks for
the bare domain and www. together (some sites only publish www), within
one overall time budget."""
host = bare_host(domain)
if not host or "." not in host:
return None
cached = _cache.get(host)
if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
return cached[1]
candidates = {host, f"www.{host.removeprefix('www.')}"}
try:
result = _combine(await asyncio.wait_for(
asyncio.gather(*(_resolves(candidate) for candidate in candidates)), LOOKUP_TIMEOUT_SECONDS
))
except asyncio.TimeoutError:
result = None
if result is not None:
if len(_cache) >= CACHE_LIMIT:
_cache.clear()
_cache[host] = (time.monotonic(), result)
return result