feat(widget): Widget 配置、loader 与右下角悬浮入口

生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
WangShen007 committed 2026-09-25 20:50:00 +08:00
1 parent e24d3e2a65
commit 5d0a48505a
57 files changed
+32086

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
"""
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.
Custom-domain verification for the public help center.
The customer proves control of the domain with a TXT record
(_chattermate.<domain> = cm-verify=<token>) and routes traffic with a CNAME
to HELP_CENTER_CNAME_TARGET (A/AAAA records matching HELP_CENTER_TARGET_IPS
are accepted for providers that flatten CNAMEs). Both must pass before the
host dispatcher will ever serve the domain. SSL turns active once an HTTPS
probe against /healthz succeeds with a valid certificate.
"""
import os
import secrets
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.models.help_center import HelpCenterSettings, SSLStatus
from app.repositories.help_center import HelpCenterRepository
logger = get_logger(__name__)
DNS_LIFETIME_SECONDS = 5.0
SSL_PROBE_TIMEOUT_SECONDS = 10.0
TXT_RECORD_PREFIX = "_chattermate"
# Public resolvers for verification lookups. The container's own resolver is
# Docker's embedded DNS (127.0.0.11) for service discovery — it negatively
# caches names that didn't exist when first queried (so a TXT added *after* the
# customer set up the domain keeps returning NXDOMAIN). Query public DNS
# directly, and let self-hosters override if their network blocks these.
_PUBLIC_DNS_RESOLVERS = [
ip.strip()
for ip in os.getenv("DOMAIN_VERIFY_DNS_RESOLVERS", "1.1.1.1,8.8.8.8,9.9.9.9").split(",")
if ip.strip()
]
def generate_verification_token() -> str:
return secrets.token_hex(16)
def txt_record_host(domain: str) -> str:
return f"{TXT_RECORD_PREFIX}.{domain}"
def expected_txt_value(row: HelpCenterSettings) -> str:
return f"cm-verify={row.domain_verification_token}"
def _resolver():
import dns.resolver
resolver = dns.resolver.Resolver()
if _PUBLIC_DNS_RESOLVERS:
resolver.nameservers = _PUBLIC_DNS_RESOLVERS
resolver.lifetime = DNS_LIFETIME_SECONDS
return resolver
def check_txt_record(row: HelpCenterSettings) -> bool:
"""True when the verification TXT record is present."""
import dns.resolver
expected = expected_txt_value(row)
try:
answers = _resolver().resolve(txt_record_host(row.custom_domain), "TXT")
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers,
dns.resolver.LifetimeTimeout):
return False
for record in answers:
value = b"".join(record.strings).decode("utf-8", errors="replace")
if value.strip() == expected:
return True
return False
def check_cname_record(domain: str) -> bool:
"""True when the domain points at us: CNAME to the target, or (for
CNAME-flattening providers) A/AAAA records matching the target IPs."""
import dns.resolver
target = settings.HELP_CENTER_CNAME_TARGET.rstrip(".").lower()
try:
answers = _resolver().resolve(domain, "CNAME")
return any(str(record.target).rstrip(".").lower() == target for record in answers)
except (dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout):
return False
except dns.resolver.NoAnswer:
pass # no CNAME — fall through to flattened A/AAAA comparison
if not settings.HELP_CENTER_TARGET_IPS:
return False
resolved = set()
for rtype in ("A", "AAAA"):
try:
resolved.update(str(r) for r in _resolver().resolve(domain, rtype))
except Exception:
continue
return bool(resolved) and resolved <= settings.HELP_CENTER_TARGET_IPS
def probe_ssl(domain: str) -> bool:
"""HTTPS reachability + certificate validation, resolving via PUBLIC DNS.
The container's own resolver (Docker embedded DNS) negatively caches a
custom domain that didn't exist before its cert was provisioned — so httpx
(which uses the system resolver) would keep failing. Resolve the A record
ourselves and connect to that IP with SNI = the domain, which also verifies
the served cert is actually issued for this domain."""
import socket
import ssl
try:
ip = str(_resolver().resolve(domain, "A")[0])
except Exception as e:
logger.info(f"SSL probe DNS for {domain} not ready: {e}")
return False
try:
ctx = ssl.create_default_context()
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
with socket.create_connection((ip, 443), timeout=SSL_PROBE_TIMEOUT_SECONDS) as sock:
with ctx.wrap_socket(sock, server_hostname=domain) as tls:
tls.sendall(
f"GET /healthz HTTP/1.1\r\nHost: {domain}\r\nConnection: close\r\n\r\n".encode()
)
status_line = tls.recv(128).split(b"\r\n", 1)[0]
return b" 200 " in status_line
except (OSError, ssl.SSLError) as e:
logger.info(f"SSL probe for {domain} not ready: {e}")
return False
def set_custom_domain(db: Session, row: HelpCenterSettings, domain: str) -> HelpCenterSettings:
"""Claim a (normalized) domain: fresh token, verification state reset."""
base_suffix = f".{settings.HELP_CENTER_BASE_DOMAIN}"
if domain == settings.HELP_CENTER_BASE_DOMAIN or domain.endswith(base_suffix):
raise ValueError(f"{settings.HELP_CENTER_BASE_DOMAIN} subdomains are assigned automatically and cannot be claimed.")
row.custom_domain = domain
row.domain_verification_token = generate_verification_token()
row.txt_record_verified = False
row.cname_record_verified = False
row.ssl_status = SSLStatus.NONE
row.domain_verified_at = None
return HelpCenterRepository(db).update(row)
def clear_custom_domain(db: Session, row: HelpCenterSettings) -> HelpCenterSettings:
row.custom_domain = None
row.domain_verification_token = None
row.txt_record_verified = False
row.cname_record_verified = False
row.ssl_status = SSLStatus.NONE
row.domain_verified_at = None
return HelpCenterRepository(db).update(row)
def verify_custom_domain(db: Session, row: HelpCenterSettings) -> HelpCenterSettings:
"""Run both DNS checks (and the SSL probe once verified) and persist the
outcome. Blocking (DNS/HTTP I/O) — callers run it off the event loop."""
if not row.custom_domain:
raise ValueError("No custom domain configured.")
row.txt_record_verified = check_txt_record(row)
row.cname_record_verified = check_cname_record(row.custom_domain)
if row.domain_verified:
if not row.domain_verified_at:
row.domain_verified_at = datetime.now(timezone.utc)
if row.ssl_status != SSLStatus.ACTIVE.value:
row.ssl_status = (
SSLStatus.ACTIVE if probe_ssl(row.custom_domain) else SSLStatus.PENDING
)
else:
row.domain_verified_at = None
row.ssl_status = SSLStatus.NONE
return HelpCenterRepository(db).update(row)
+95
View File
@@ -0,0 +1,95 @@
"""
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.
What a widget conversation token may do to the customer it names.
A widget visitor can be bound to a customer by an email they only typed, so the
token naming a customer is not proof of being them. Conversations are already
fenced by the token's scope (see conversation_token.conversation_scope); this
module fences the customer row itself - its contact details, merges, and whether
an unverified token may stand for a person the embedding app has signed in.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.customer import Customer
from app.models.session_to_agent import SessionToAgent
from app.repositories.customer import CustomerRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.conversation_token import (
LEGACY_SCOPE_PREFIX,
is_verified_scope,
scope_for_session_key,
)
def can_act_as(db: Session, customer: Optional[Customer], scope: Optional[str]) -> bool:
"""True when a token with `scope` may change `customer` as if it were them.
A verified token always may. An unverified one only for a customer its own
scope created - "no conversations elsewhere" is not ownership: a customer
made by a ticket or an import has none - and only while every conversation
of that customer is still its own.
"""
if customer is None or not scope:
return False
if is_verified_scope(scope):
return True
if customer.is_authenticated or not _created_by(customer, scope):
return False
return not SessionToAgentRepository(db).customer_has_sessions_outside_scope(customer.id, scope)
def _created_by(customer: Customer, scope: str) -> bool:
if customer.conversation_key:
return customer.conversation_key == scope
# Customers from before conversation keys: the anonymous row a legacy
# widget token created for itself is the only kind such a token can own.
return scope.startswith(LEGACY_SCOPE_PREFIX) and CustomerRepository.is_placeholder_email(customer.email)
def may_stand_for(customer: Optional[Customer], scope: Optional[str]) -> bool:
"""True when an unverified token may keep naming `customer` at all.
Once the embedding app has signed a person in, only a token our side vouched
for may name them - otherwise whoever claimed the address first would keep a
token for the signed-in user's row.
"""
if customer is None or not scope:
return False
return is_verified_scope(scope) or not customer.is_authenticated
def trusts_identity_in_conversation(
db: Session, customer: Optional[Customer], conversation: Optional[SessionToAgent]
) -> bool:
"""True when the visitor in `conversation` may be told, or change, what we
know of `customer`.
Reciting a stored name or email back is how a visitor who only typed someone
else's address would learn who they are. Unknown conversation: nothing.
"""
if conversation is None:
return False
return can_act_as(db, customer, scope_for_session_key(conversation.conversation_key))
def trusts_identity_in_session(db: Session, customer: Optional[Customer], session_id: UUID | str | None) -> bool:
"""trusts_identity_in_conversation, for a caller holding only the session id."""
conversation = SessionToAgentRepository(db).get_session(session_id) if session_id else None
return trusts_identity_in_conversation(db, customer, conversation)