feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
1 parent
e24d3e2a65
commit
5d0a48505a
57 files changed
+32086
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user