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,704 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, Header, Query, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.customer import Customer
|
||||
from app.models.widget import Widget
|
||||
from app.models.user import User
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.repositories.widget import WidgetRepository
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.core.security import create_conversation_token, verify_conversation_token
|
||||
from app.services import conversation_token
|
||||
from app.utils.client_ip import forwarded_client_ip
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from app.models.schemas.widget import WidgetCreate, WidgetResponse
|
||||
from app.core.logger import get_logger
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository, session_in_scope
|
||||
from app.services.contact_capture import CONTACT_EMAIL_META_KEY
|
||||
from app.services.widget_identity import may_stand_for
|
||||
from app.models.session_to_agent import SessionStatus
|
||||
from app.core.config import settings
|
||||
from app.core.s3 import get_s3_signed_url
|
||||
from app.utils.business_hours import is_within_business_hours
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The enterprise module can record where each widget is live; without it,
|
||||
# nothing is recorded.
|
||||
try:
|
||||
from app.enterprise.services import widget_install
|
||||
except ImportError:
|
||||
widget_install = None
|
||||
|
||||
|
||||
def _identity_expired() -> HTTPException:
|
||||
"""401 telling the widget its visitor's identity lapsed, not that it never had one.
|
||||
|
||||
The embed loader answers this by asking the host page for a fresh token
|
||||
(`tokenProvider`) and retrying, so an identified visitor is never silently
|
||||
re-created as an anonymous customer.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"code": conversation_token.IDENTITY_EXPIRED_CODE,
|
||||
"message": "Conversation token expired. Issue a new one with /generate-token.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_ASSETS_DIR = Path(settings.WIDGET_ASSETS_DIR)
|
||||
|
||||
|
||||
def _asset_version() -> str:
|
||||
"""Cache-buster for the widget bundle URLs.
|
||||
|
||||
/assets/widget.js is served without a Cache-Control header and its URL never
|
||||
changes, so browsers fall back to heuristic caching and can keep serving an old
|
||||
bundle for hours after a release — the widget silently runs last week's code.
|
||||
Stamping the build's mtime+size onto the URL makes a new build a new URL.
|
||||
Recomputed per request (a stat is microseconds) so a rebuild takes effect
|
||||
without restarting the backend.
|
||||
"""
|
||||
try:
|
||||
stat = (_ASSETS_DIR / "widget.js").stat()
|
||||
return f"{int(stat.st_mtime)}-{stat.st_size}"
|
||||
except OSError:
|
||||
# Assets served from somewhere else (CDN, dev server) — nothing to stamp.
|
||||
return ""
|
||||
|
||||
|
||||
def _widget_html_response(html: str) -> HTMLResponse:
|
||||
"""The widget HTML embeds a conversation token and the current customization,
|
||||
and points at a versioned bundle URL. Caching it would hand a visitor someone
|
||||
else's token from a shared cache, and would keep pinning them to a stale widget
|
||||
build after a release."""
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
# BACKEND_URL defaults to http://localhost:8000 and neither compose file sets
|
||||
# it, so a self-hosted install that never overrode it used to serve every visitor
|
||||
# a widget whose socket dialled the visitor's own machine (#315). Loopback can
|
||||
# never be right for a browser on someone else's computer, so we fall back to the
|
||||
# origin the widget HTML was actually fetched over.
|
||||
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"})
|
||||
|
||||
_warned_about_loopback_backend_url = False
|
||||
|
||||
|
||||
def _is_loopback(url: str) -> bool:
|
||||
host = urlparse(url).hostname
|
||||
return host is not None and host.lower() in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def _public_origin(request: Optional[Request]) -> Optional[str]:
|
||||
"""Scheme and host this request reached us on, as the browser sees them.
|
||||
|
||||
Reads the forwarding headers our nginx sets rather than ``request.url``, which
|
||||
only reflects them when the ASGI server is started with proxy headers enabled.
|
||||
"""
|
||||
if request is None:
|
||||
return None
|
||||
|
||||
# Several proxies in a chain append to these, and the browser's own value is
|
||||
# the first entry.
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
host = (forwarded_host or request.headers.get("host") or "").split(",")[0].strip()
|
||||
if not host:
|
||||
return None
|
||||
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
if forwarded_proto:
|
||||
scheme = forwarded_proto.split(",")[0].strip()
|
||||
elif forwarded_host:
|
||||
# A proxy that rewrites the host but not the protocol (Traefik and some
|
||||
# Caddy setups, unlike our nginx) leaves request.url.scheme reading "http"
|
||||
# for the proxy's own hop. Trusting that would hand an https page a ws://
|
||||
# socket the browser refuses as mixed content, so assume the hop was TLS.
|
||||
scheme = "https"
|
||||
else:
|
||||
scheme = request.url.scheme or "https"
|
||||
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def _widget_runtime_config(request: Optional[Request] = None) -> dict:
|
||||
"""Runtime API/WS URLs to hand the widget iframe as ``window.APP_CONFIG``.
|
||||
|
||||
Derived from ``BACKEND_URL`` so a self-hosted widget talks to the configured
|
||||
backend instead of the vendor cloud. On the hosted deployment BACKEND_URL is
|
||||
``https://api.chattermate.chat``, which yields exactly the widget's baked-in
|
||||
defaults, so injecting this is a no-op there.
|
||||
"""
|
||||
global _warned_about_loopback_backend_url
|
||||
|
||||
api_base = settings.BACKEND_URL.rstrip("/")
|
||||
if _is_loopback(api_base):
|
||||
derived = _public_origin(request)
|
||||
if derived and not _is_loopback(derived):
|
||||
if not _warned_about_loopback_backend_url:
|
||||
_warned_about_loopback_backend_url = True
|
||||
logger.warning(
|
||||
"BACKEND_URL is %s, which no visitor's browser can reach. "
|
||||
"Serving widgets against %s instead, taken from the request. "
|
||||
"Set BACKEND_URL to this install's public API URL.",
|
||||
api_base, derived,
|
||||
)
|
||||
api_base = derived
|
||||
|
||||
if api_base.startswith("https://"):
|
||||
ws_url = "wss://" + api_base[len("https://"):]
|
||||
elif api_base.startswith("http://"):
|
||||
ws_url = "ws://" + api_base[len("http://"):]
|
||||
else:
|
||||
ws_url = api_base
|
||||
return {"API_URL": f"{api_base}/api/v1", "WS_URL": ws_url}
|
||||
|
||||
@router.post("", response_model=WidgetResponse)
|
||||
def create_new_widget(
|
||||
widget: WidgetCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""Create a new widget for the organization"""
|
||||
widget_repo = WidgetRepository(db)
|
||||
return widget_repo.create_widget(widget, current_user.organization_id)
|
||||
|
||||
|
||||
def _note_install(db: Session, widget: Widget, request: Request, background_tasks: BackgroundTasks) -> None:
|
||||
"""Note where the widget is live (or blocked), after the response is
|
||||
sent. Never lets a tracking error break the widget itself."""
|
||||
if widget_install is None:
|
||||
return
|
||||
try:
|
||||
sighting = widget_install.claim_write(db, widget, request.headers.get("origin"), forwarded_client_ip(request))
|
||||
if sighting:
|
||||
background_tasks.add_task(widget_install.record, widget.id, sighting)
|
||||
except Exception:
|
||||
logger.exception(f"Install tracking failed for widget {widget.id}")
|
||||
|
||||
|
||||
@router.get("/{widget_id}/data", response_class=HTMLResponse)
|
||||
async def get_widget_ui(
|
||||
widget_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
authorization: Optional[str] = Header(None),
|
||||
source: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get widget UI and handle customer authentication"""
|
||||
|
||||
widget = db.query(Widget).filter(Widget.id == widget_id).first()
|
||||
if not widget:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
|
||||
_note_install(db, widget, request, background_tasks)
|
||||
|
||||
# Get agent data
|
||||
agent_repo = AgentRepository(db)
|
||||
agent = agent_repo.get_by_id(widget.agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
# What the header may honestly claim. Only a human-handled agent needs the
|
||||
# business-hours check: while the AI answers, it answers at 3am too.
|
||||
presence = {
|
||||
'mode': 'ai' if agent.ai_replies_enabled else 'human',
|
||||
'available': True if agent.ai_replies_enabled
|
||||
else is_within_business_hours(agent.organization),
|
||||
}
|
||||
|
||||
require_token_auth = getattr(agent, 'require_token_auth', False)
|
||||
customer_id = None
|
||||
token = None
|
||||
token_payload = None
|
||||
|
||||
# Try to validate existing token if provided
|
||||
if authorization and authorization.startswith('Bearer '):
|
||||
token = authorization.split(' ')[1]
|
||||
state = conversation_token.inspect(token, widget_id)
|
||||
if state.is_live:
|
||||
token_payload = state.payload
|
||||
customer_id = state.payload.get("sub")
|
||||
elif state.identity_lost:
|
||||
# A lapsed token that named a customer means a signed-in visitor whose
|
||||
# identity just ran out. Say so instead of rendering the frame as a
|
||||
# brand-new anonymous visitor - the loader re-identifies and retries.
|
||||
raise _identity_expired()
|
||||
else:
|
||||
token = None # Invalid token
|
||||
|
||||
# SECURITY: If token auth is required, valid token MUST be provided
|
||||
if require_token_auth:
|
||||
if not token or customer_id is None:
|
||||
logger.warning(f"Widget UI request denied: require_token_auth=true but no valid token for widget_id={widget_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required. Token must be obtained from /api/v1/generate-token endpoint with valid API key."
|
||||
)
|
||||
# Has valid token - return widget with existing token
|
||||
return _widget_html_response(await get_widget_html(
|
||||
widget_id=widget_id,
|
||||
agent_name=agent.display_name or agent.name,
|
||||
agent_customization=agent.customization,
|
||||
customer_id=customer_id,
|
||||
initial_token=token,
|
||||
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
||||
allow_attachments=agent.allow_attachments,
|
||||
ai_replies_enabled=agent.ai_replies_enabled,
|
||||
presence=presence,
|
||||
request=request
|
||||
))
|
||||
|
||||
# Token auth NOT required - allow anonymous access
|
||||
# If we have a valid token, use it; otherwise create a new one
|
||||
if token and customer_id:
|
||||
return _widget_html_response(await get_widget_html(
|
||||
widget_id=widget_id,
|
||||
agent_name=agent.display_name or agent.name,
|
||||
agent_customization=agent.customization,
|
||||
customer_id=customer_id,
|
||||
initial_token=token,
|
||||
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
||||
allow_attachments=agent.allow_attachments,
|
||||
ai_replies_enabled=agent.ai_replies_enabled,
|
||||
presence=presence,
|
||||
request=request
|
||||
))
|
||||
|
||||
# No valid token - create new one for anonymous access. A live token that
|
||||
# names no customer yet keeps its conversation key.
|
||||
token = _mint_widget_token(widget_id, _conversation_key(token_payload), source=source)
|
||||
|
||||
return _widget_html_response(await get_widget_html(
|
||||
widget_id=widget_id,
|
||||
agent_name=agent.display_name or agent.name,
|
||||
agent_customization=agent.customization,
|
||||
customer_id=customer_id,
|
||||
initial_token=token,
|
||||
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
||||
allow_attachments=agent.allow_attachments,
|
||||
ai_replies_enabled=agent.ai_replies_enabled,
|
||||
presence=presence,
|
||||
request=request
|
||||
))
|
||||
|
||||
async def get_widget_html(widget_id: str, agent_name: str, agent_customization: dict, customer_id: Optional[str] = None, initial_token: Optional[str] = None, agent_workflow: bool = False, allow_attachments: bool = False, ai_replies_enabled: bool = True,
|
||||
presence: Optional[dict] = None, request: Optional[Request] = None) -> str:
|
||||
"""Generate widget HTML with embedded data"""
|
||||
import html
|
||||
widget_url = settings.VITE_WIDGET_URL
|
||||
version = _asset_version()
|
||||
asset_query = f"?v={version}" if version else ""
|
||||
|
||||
# Convert AgentCustomization to dict if it's a model instance
|
||||
customization_dict = {}
|
||||
if agent_customization:
|
||||
# Get signed URL for photo if using S3
|
||||
photo_url = agent_customization.photo_url
|
||||
if settings.S3_FILE_STORAGE and photo_url:
|
||||
photo_url = await get_s3_signed_url(photo_url)
|
||||
|
||||
customization_dict = {
|
||||
"chat_background_color": agent_customization.chat_background_color,
|
||||
"chat_text_color": agent_customization.chat_text_color,
|
||||
"chat_bubble_color": agent_customization.chat_bubble_color,
|
||||
"accent_color": agent_customization.accent_color,
|
||||
"font_family": agent_customization.font_family,
|
||||
"photo_url": photo_url,
|
||||
"chat_style": agent_customization.chat_style.value if agent_customization.chat_style else "CHATBOT",
|
||||
"widget_position": agent_customization.widget_position.value if agent_customization.widget_position else "FLOATING",
|
||||
"welcome_title": agent_customization.welcome_title,
|
||||
"welcome_subtitle": agent_customization.welcome_subtitle,
|
||||
"welcome_message": agent_customization.welcome_message,
|
||||
"chat_initiation_messages": agent_customization.chat_initiation_messages or [],
|
||||
"quick_actions": agent_customization.quick_actions or [],
|
||||
"show_citations": agent_customization.show_citations,
|
||||
"collect_email": agent_customization.collect_email,
|
||||
# A human-only agent never produces an AI reply, so disclosing one
|
||||
# would be untrue — same reason the widget hides it after takeover.
|
||||
"show_ai_disclaimer": agent_customization.show_ai_disclaimer and ai_replies_enabled,
|
||||
"allow_new_chat": agent_customization.allow_new_chat,
|
||||
"customization_metadata": agent_customization.customization_metadata or {}
|
||||
}
|
||||
|
||||
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Widget</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
// Runtime backend config so the widget connects to THIS install's
|
||||
// backend instead of falling back to the baked cloud default.
|
||||
// Declared before the widget module loads.
|
||||
window.APP_CONFIG = {json.dumps(_widget_runtime_config(request))};
|
||||
</script>
|
||||
<script type="module" crossorigin src="{widget_url}/assets/widget.js{asset_query}"></script>
|
||||
<link rel="stylesheet" crossorigin href="{widget_url}/assets/widget.css{asset_query}">
|
||||
<script>
|
||||
window.__INITIAL_DATA__ = {{
|
||||
widgetId: "{html.escape(widget_id)}",
|
||||
agentName: "{html.escape(agent_name)}",
|
||||
customization: {json.dumps(customization_dict)},
|
||||
customerId: "{html.escape(customer_id or '')}",
|
||||
initialToken: "{html.escape(initial_token or '')}",
|
||||
customer: {{}},
|
||||
workflow: {str(agent_workflow).lower()},
|
||||
allowAttachments: {str(allow_attachments).lower()},
|
||||
presence: {json.dumps(presence or {'mode': 'ai', 'available': True})}
|
||||
}};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
async def get_human_agent_session_info(db: Session, customer_id: str, scope: str) -> dict:
|
||||
"""Human agent assigned to the customer's open conversation this token may reach"""
|
||||
human_agent_info = {}
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
sessions = session_repo.get_customer_sessions(customer_id, SessionStatus.OPEN, scope=scope)
|
||||
|
||||
if sessions and len(sessions) > 0:
|
||||
session_model,user_full_name, user_profile_pic = sessions[0]
|
||||
|
||||
if user_full_name: # If there's a human agent assigned
|
||||
# HumanAgentResponse signs human_agent_profile_pic on serialization.
|
||||
|
||||
# Get human agent info from session
|
||||
human_agent_info = {
|
||||
"human_agent_name": user_full_name,
|
||||
"human_agent_profile_pic": user_profile_pic,
|
||||
}
|
||||
|
||||
return human_agent_info
|
||||
|
||||
|
||||
def _conversation_key(token_payload: Optional[dict]) -> str:
|
||||
"""Keep the browser's conversation key across re-mints, or start one.
|
||||
|
||||
Never an account key: only our backend mints those, for one signed-in
|
||||
dashboard account, and every token the widget issues is unverified.
|
||||
"""
|
||||
key = (token_payload or {}).get(conversation_token.CONVERSATION_KEY_CLAIM)
|
||||
if key and not conversation_token.is_account_key(key):
|
||||
return key
|
||||
return conversation_token.new_conversation_key()
|
||||
|
||||
|
||||
def _mint_widget_token(widget_id: str, conversation_key: str, customer_id: Optional[str] = None,
|
||||
source: Optional[str] = None, token_payload: Optional[dict] = None) -> str:
|
||||
"""A conversation token the widget issues itself - unverified, so always scoped.
|
||||
|
||||
Prefers a fresh `source` (first-visit Explore flow) over one the visitor's
|
||||
earlier token carried.
|
||||
"""
|
||||
extra_data = {conversation_token.CONVERSATION_KEY_CLAIM: conversation_key}
|
||||
source = source or (token_payload or {}).get("source")
|
||||
if widget_id == settings.EXPLORE_WIDGET_ID and source:
|
||||
extra_data["source"] = source
|
||||
return create_conversation_token(widget_id=widget_id, customer_id=customer_id, **extra_data)
|
||||
|
||||
|
||||
def _anonymous_customer(customer_repo: CustomerRepository, widget: Widget, conversation_key: str,
|
||||
claimed_email: Optional[str] = None) -> Customer:
|
||||
"""A customer of the visitor's own. An address they gave that belongs to
|
||||
someone they can't stand for is kept where the inbox shows it."""
|
||||
meta_data = {CONTACT_EMAIL_META_KEY: claimed_email} if claimed_email else None
|
||||
return customer_repo.create_customer(
|
||||
CustomerRepository.anonymous_email(), widget.organization_id,
|
||||
meta_data=meta_data, conversation_key=conversation_key)
|
||||
|
||||
|
||||
def _widget_data_response(widget: Widget, agent: Agent, customer_id: Optional[str], human_agent_info: dict,
|
||||
token: Optional[str] = None) -> dict:
|
||||
"""GET /widgets/{id}'s answer; `token` only when the visitor needs a new one."""
|
||||
# photo_url is signed by AgentCustomizationResponse on serialization.
|
||||
data = {
|
||||
"id": widget.id,
|
||||
"organization_id": widget.organization_id,
|
||||
"customer_id": customer_id,
|
||||
"human_agent": human_agent_info,
|
||||
"agent": {
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"display_name": agent.display_name,
|
||||
"customization": agent.customization,
|
||||
"workflow": bool(agent.use_workflow and agent.active_workflow_id),
|
||||
"allow_attachments": agent.allow_attachments
|
||||
}
|
||||
}
|
||||
if token:
|
||||
data["token"] = token
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{widget_id}", response_model=WidgetResponse)
|
||||
async def get_widget_data(
|
||||
widget_id: str,
|
||||
response: Response,
|
||||
email: Optional[str] = None,
|
||||
source: Optional[str] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get widget data including agent customization"""
|
||||
logger.info(f"Getting widget data for widget_id {widget_id}, email {email}, has_token {bool(authorization)}")
|
||||
# Binds this visitor to a customer: a stored copy would skip that and leave
|
||||
# the widget holding the page's anonymous token.
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
widget = db.query(Widget).filter(Widget.id == widget_id).first()
|
||||
if not widget:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
|
||||
agent_repo = AgentRepository(db)
|
||||
agent = agent_repo.get_by_id(widget.agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
require_token_auth = getattr(agent, 'require_token_auth', False)
|
||||
customer_id = None
|
||||
token = None
|
||||
token_payload = None
|
||||
scope = None
|
||||
|
||||
# Try to validate existing token if provided
|
||||
if authorization and authorization.startswith('Bearer '):
|
||||
token = authorization.split(' ')[1]
|
||||
state = conversation_token.inspect(token, widget_id)
|
||||
if state.is_live:
|
||||
try:
|
||||
scope = conversation_token.conversation_scope(state.payload)
|
||||
token_payload = state.payload
|
||||
customer_id = state.payload.get("sub")
|
||||
except ValueError:
|
||||
token = None
|
||||
elif state.identity_lost:
|
||||
# Identity ran out rather than never existing: minting a fresh anonymous
|
||||
# customer here is what used to turn a signed-in visitor into "Anonymous"
|
||||
# in the inbox the moment they started a new chat.
|
||||
raise _identity_expired()
|
||||
else:
|
||||
token = None
|
||||
|
||||
# SECURITY: If token auth required, must have valid token with customer_id
|
||||
if require_token_auth:
|
||||
if not token or customer_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Token required")
|
||||
|
||||
# Check if agent has workflow enabled
|
||||
agent_has_workflow = bool(agent.use_workflow and agent.active_workflow_id)
|
||||
|
||||
# Check if agent has ASK_ANYTHING chat style
|
||||
is_ask_anything_style = (agent.customization and
|
||||
agent.customization.chat_style and
|
||||
agent.customization.chat_style.value == "ASK_ANYTHING")
|
||||
|
||||
# Email is optional unless the agent explicitly opts into collecting it before chat.
|
||||
# When not collecting email we bypass the gate (anonymous customer + token), like
|
||||
# ASK_ANYTHING / workflow agents already do. NOTE: this runs AFTER the require_token_auth
|
||||
# 401 check above, so token-protected widgets are unaffected.
|
||||
email_bypass = not bool(agent.customization and getattr(agent.customization, 'collect_email', False))
|
||||
|
||||
customer_repo = CustomerRepository(db)
|
||||
conversation_key = _conversation_key(token_payload)
|
||||
# Judged on the customer the socket will act as - the merge survivor - or
|
||||
# the widget would be told yes here and refused on every connect.
|
||||
customer = customer_repo.resolve_merged(customer_id) if customer_id not in (None, "None") else None
|
||||
|
||||
if customer is not None:
|
||||
# Once the embedding app signs this person in, a token the widget issued
|
||||
# (from an email someone typed) may no longer name them - whoever claimed
|
||||
# the address first would otherwise keep a token for the signed-in user.
|
||||
# Hand that browser a customer of its own and keep the address it gave.
|
||||
if not may_stand_for(customer, scope):
|
||||
logger.info(f"Widget {widget_id}: unverified token names a signed-in customer; starting an anonymous one")
|
||||
anonymous = _anonymous_customer(customer_repo, widget, conversation_key, claimed_email=customer.email)
|
||||
new_token = _mint_widget_token(widget_id, conversation_key, customer_id=str(anonymous.id),
|
||||
source=source, token_payload=token_payload)
|
||||
return _widget_data_response(widget, agent, str(anonymous.id), {}, new_token)
|
||||
|
||||
logger.debug(f"Using existing token for customer_id: {customer_id}")
|
||||
human_agent_info = await get_human_agent_session_info(db, str(customer.id), scope)
|
||||
return _widget_data_response(widget, agent, customer_id, human_agent_info)
|
||||
|
||||
# A token-protected widget only ever runs on tokens its site issued: one whose
|
||||
# customer is gone is no reason to issue a widget token of our own.
|
||||
if require_token_auth:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Token required")
|
||||
|
||||
# No customer yet - or the token's one no longer exists, which would otherwise
|
||||
# refuse every connect while this answer kept the token. For workflow agents,
|
||||
# ASK_ANYTHING style, or email-optional agents, create one with a blank email.
|
||||
# Only email-collecting regular agents require email up front.
|
||||
if not (email or agent_has_workflow or is_ask_anything_style or email_bypass):
|
||||
# Regular agent without email/customer_id - return 401
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# The email is only what the visitor typed. It may still name the existing
|
||||
# customer - one person in the inbox - because the token minted below is
|
||||
# scoped to this browser's conversations, not to theirs. Never a person the
|
||||
# embedding app signed in, though: only its own token may stand for them.
|
||||
existing = customer_repo.get_customer_by_email(email, widget.organization_id) if email else None
|
||||
if existing is not None and not existing.is_authenticated:
|
||||
customer = existing
|
||||
elif existing is not None:
|
||||
customer = _anonymous_customer(customer_repo, widget, conversation_key, claimed_email=email)
|
||||
elif email:
|
||||
customer = customer_repo.create_customer(email, widget.organization_id, conversation_key=conversation_key)
|
||||
else:
|
||||
customer = _anonymous_customer(customer_repo, widget, conversation_key)
|
||||
|
||||
human_agent_info = await get_human_agent_session_info(db, str(customer.id), conversation_key)
|
||||
new_token = _mint_widget_token(widget_id, conversation_key, customer_id=str(customer.id),
|
||||
source=source, token_payload=token_payload)
|
||||
return _widget_data_response(widget, agent, str(customer.id), human_agent_info, new_token)
|
||||
|
||||
|
||||
@router.post("/{widget_id}/end-chat")
|
||||
async def end_chat_acknowledgment(
|
||||
widget_id: str,
|
||||
session_id: str,
|
||||
reason: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Acknowledge end_chat event from widget and close session on backend"""
|
||||
try:
|
||||
logger.info(f"End chat request received: widget_id={widget_id}, session_id={session_id}, reason={reason}, has_token={bool(authorization)}")
|
||||
|
||||
# A conversation token is REQUIRED. This used to validate the token only
|
||||
# when one was sent, so a request with no Authorization header fell
|
||||
# straight through and closed any session by id, unauthenticated.
|
||||
if not authorization or not authorization.startswith('Bearer '):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
token_data = verify_conversation_token(authorization.split(' ')[1])
|
||||
except Exception:
|
||||
token_data = None
|
||||
if not token_data:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
customer_id = token_data.get("sub")
|
||||
if token_data.get("widget_id") != widget_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Widget mismatch")
|
||||
try:
|
||||
scope = conversation_token.conversation_scope(token_data)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
# The token proves who the caller is, not that this session is theirs —
|
||||
# without this any visitor could close another customer's conversation,
|
||||
# or another browser's conversation of a customer it only claimed to be.
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
session = session_repo.get_session(session_id)
|
||||
if not session or str(session.customer_id) != str(customer_id) or not session_in_scope(session, scope):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
success = session_repo.close_session(
|
||||
session_id=session_id,
|
||||
reason=reason,
|
||||
description=description
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"End chat acknowledged: widget_id={widget_id}, session_id={session_id}, reason={reason}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Chat session closed",
|
||||
"session_id": session_id,
|
||||
"closed_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Failed to close session: widget_id={widget_id}, session_id={session_id}")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in end_chat_acknowledgment: {str(e)}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to process end chat")
|
||||
|
||||
|
||||
@router.get("", response_model=List[WidgetResponse])
|
||||
def list_widgets(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""List all widgets for the organization"""
|
||||
widget_repo = WidgetRepository(db)
|
||||
return widget_repo.get_widgets(current_user.organization_id)
|
||||
|
||||
|
||||
@router.get("/agent/{agent_id}", response_model=List[WidgetResponse])
|
||||
def get_widgets_by_agent(
|
||||
agent_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""Get all widgets for a specific agent"""
|
||||
widget_repo = WidgetRepository(db)
|
||||
widgets = widget_repo.get_widgets_by_agent(agent_id)
|
||||
|
||||
# Verify the agent belongs to the user's organization
|
||||
agent_repo = AgentRepository(db)
|
||||
agent = agent_repo.get_by_id(agent_id)
|
||||
if not agent or str(agent.organization_id) != str(current_user.organization_id):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
return widgets
|
||||
|
||||
|
||||
@router.delete("/{widget_id}")
|
||||
def remove_widget(
|
||||
widget_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""Delete a widget"""
|
||||
widget_repo = WidgetRepository(db)
|
||||
widget = widget_repo.get_widget(widget_id)
|
||||
if not widget or widget.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
widget_repo.delete_widget(widget_id)
|
||||
return {"message": "Widget deleted"}
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import require_permissions
|
||||
from app.repositories.widget_app import WidgetAppRepository
|
||||
from app.models.schemas.widget_app import (
|
||||
WidgetAppCreate,
|
||||
WidgetAppUpdate,
|
||||
WidgetAppResponse,
|
||||
WidgetAppWithKeyResponse,
|
||||
WidgetAppListResponse
|
||||
)
|
||||
from uuid import UUID
|
||||
from app.core.logger import get_logger
|
||||
|
||||
# Enterprise feature check
|
||||
try:
|
||||
from app.enterprise.repositories.plan import PlanRepository
|
||||
from app.enterprise.services.feature_access import require_accessible_subscription
|
||||
HAS_ENTERPRISE = True
|
||||
except ImportError:
|
||||
HAS_ENTERPRISE = False
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def check_widget_app_feature_access(current_user: User, db: Session):
|
||||
"""Check if user has access to widget apps feature (requires pro plan)"""
|
||||
if not HAS_ENTERPRISE:
|
||||
return # Allow access in non-enterprise mode
|
||||
|
||||
# Accessible subscription = active/trial/past-due-in-period OR cancelled-but-
|
||||
# still-in-paid-period. Raises 403 when the org has no accessible plan.
|
||||
subscription = require_accessible_subscription(db, current_user.organization_id)
|
||||
plan_repo = PlanRepository(db)
|
||||
|
||||
# Check if api_access feature is available in the plan (required for widget apps)
|
||||
if not plan_repo.check_feature_availability(str(subscription.plan_id), 'api_access'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Widget Apps feature is not available in your current plan. Please upgrade to Pro to access this feature."
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=WidgetAppWithKeyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_widget_app(
|
||||
app_data: WidgetAppCreate,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new widget app with generated API key.
|
||||
|
||||
**IMPORTANT:** The API key is shown only once in this response.
|
||||
Save it securely - it cannot be retrieved later.
|
||||
|
||||
Requires: manage_organization permission (admin only)
|
||||
Requires: Pro plan with widget_apps feature
|
||||
"""
|
||||
try:
|
||||
# Check widget apps feature access
|
||||
check_widget_app_feature_access(current_user, db)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
|
||||
# Create app (returns app and plain key)
|
||||
app, plain_key = repo.create_app(
|
||||
organization_id=current_user.organization_id,
|
||||
created_by=current_user.id,
|
||||
name=app_data.name,
|
||||
description=app_data.description
|
||||
)
|
||||
|
||||
# Return app with key (shown only once)
|
||||
return WidgetAppWithKeyResponse(
|
||||
id=app.id,
|
||||
name=app.name,
|
||||
description=app.description,
|
||||
organization_id=app.organization_id,
|
||||
created_by=app.created_by,
|
||||
is_active=app.is_active,
|
||||
created_at=app.created_at,
|
||||
updated_at=app.updated_at,
|
||||
api_key=plain_key # ONLY time key is exposed
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error creating widget app: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create widget app"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=WidgetAppListResponse)
|
||||
async def list_widget_apps(
|
||||
include_inactive: bool = False,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all widget apps for current organization.
|
||||
|
||||
Requires: manage_organization permission (admin only)
|
||||
Requires: Pro plan with widget_apps feature
|
||||
"""
|
||||
try:
|
||||
# Check widget apps feature access
|
||||
check_widget_app_feature_access(current_user, db)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
apps = repo.get_apps_by_organization(
|
||||
organization_id=current_user.organization_id,
|
||||
include_inactive=include_inactive
|
||||
)
|
||||
|
||||
return WidgetAppListResponse(
|
||||
total=len(apps),
|
||||
apps=[WidgetAppResponse.from_orm(app) for app in apps]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error listing widget apps: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to list widget apps"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{app_id}", response_model=WidgetAppResponse)
|
||||
async def get_widget_app(
|
||||
app_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get widget app details by ID.
|
||||
|
||||
Requires: manage_organization permission (admin only)
|
||||
Requires: Pro plan with widget_apps feature
|
||||
"""
|
||||
try:
|
||||
# Check widget apps feature access
|
||||
check_widget_app_feature_access(current_user, db)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
app = repo.get_app_by_id(
|
||||
app_id=app_id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Widget app {app_id} not found"
|
||||
)
|
||||
|
||||
return WidgetAppResponse.from_orm(app)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting widget app: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get widget app"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{app_id}", response_model=WidgetAppResponse)
|
||||
async def update_widget_app(
|
||||
app_id: UUID,
|
||||
update_data: WidgetAppUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update widget app (name, description, active status).
|
||||
|
||||
Requires: manage_organization permission (admin only)
|
||||
Requires: Pro plan with widget_apps feature
|
||||
"""
|
||||
try:
|
||||
# Check widget apps feature access
|
||||
check_widget_app_feature_access(current_user, db)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
app = repo.update_app(
|
||||
app_id=app_id,
|
||||
organization_id=current_user.organization_id,
|
||||
name=update_data.name,
|
||||
description=update_data.description,
|
||||
is_active=update_data.is_active
|
||||
)
|
||||
|
||||
if not app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Widget app {app_id} not found"
|
||||
)
|
||||
|
||||
return WidgetAppResponse.from_orm(app)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error updating widget app: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update widget app"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{app_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_widget_app(
|
||||
app_id: UUID,
|
||||
hard_delete: bool = False,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete widget app (soft delete by default, hard delete if hard_delete=true).
|
||||
|
||||
Soft delete (default): Sets is_active=False, keeps record for audit trail.
|
||||
Hard delete: Permanently removes record (use with caution).
|
||||
|
||||
Requires: manage_organization permission (admin only)
|
||||
Requires: Pro plan with widget_apps feature
|
||||
"""
|
||||
try:
|
||||
# Check widget apps feature access
|
||||
check_widget_app_feature_access(current_user, db)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
|
||||
if hard_delete:
|
||||
success = repo.delete_app(app_id, current_user.organization_id)
|
||||
else:
|
||||
success = repo.deactivate_app(app_id, current_user.organization_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Widget app {app_id} not found"
|
||||
)
|
||||
|
||||
# Invalidate cache if we have the key prefix (can't do this without key)
|
||||
# Cache will naturally expire after TTL
|
||||
|
||||
return None # 204 No Content
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting widget app: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete widget app"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{app_id}/regenerate-key", response_model=WidgetAppWithKeyResponse)
|
||||
async def regenerate_api_key(
|
||||
app_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Regenerate API key for widget app.
|
||||
|
||||
**IMPORTANT:** The new API key is shown only once in this response.
|
||||
Save it securely - it cannot be retrieved later.
|
||||
The old key will be immediately invalidated.
|
||||
|
||||
Requires: manage_organization permission (admin only)
|
||||
Requires: Pro plan with widget_apps feature
|
||||
"""
|
||||
try:
|
||||
# Check widget apps feature access
|
||||
check_widget_app_feature_access(current_user, db)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
|
||||
result = repo.regenerate_api_key(
|
||||
app_id=app_id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Widget app {app_id} not found"
|
||||
)
|
||||
|
||||
app, new_plain_key = result
|
||||
|
||||
# Invalidate cache (old key is now invalid)
|
||||
# Note: We don't have the old key here, so cache will expire naturally
|
||||
|
||||
return WidgetAppWithKeyResponse(
|
||||
id=app.id,
|
||||
name=app.name,
|
||||
description=app.description,
|
||||
organization_id=app.organization_id,
|
||||
created_by=app.created_by,
|
||||
is_active=app.is_active,
|
||||
created_at=app.created_at,
|
||||
updated_at=app.updated_at,
|
||||
api_key=new_plain_key # New key shown only once
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error regenerating API key: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to regenerate API key"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Keep crawlers out of the API origin.
|
||||
|
||||
The API host had no robots.txt at all, so search engines were free to crawl
|
||||
(and then report as 404s) endpoints that were never pages: /api/v1/... routes
|
||||
and the YOUR_WIDGET_ID placeholder our docs use in examples.
|
||||
|
||||
Two halves, because they do different jobs. robots.txt stops the fetch;
|
||||
X-Robots-Tag stops a URL discovered elsewhere from being indexed anyway.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
NOINDEX_HEADER = (b"x-robots-tag", b"noindex")
|
||||
|
||||
|
||||
def api_robots_txt() -> str:
|
||||
"""The API origin's robots.txt body.
|
||||
|
||||
Disallows everything — except, under path dispatch, the help center. That
|
||||
mode (the self-host default) serves public help-center pages from
|
||||
{BACKEND_URL}/help/{slug} on this very origin, and those are meant to be
|
||||
indexed; a bare "Disallow: /" would quietly de-index every self-hosted
|
||||
help center. Cloud runs subdomain mode, where nothing on this origin is a
|
||||
page and the carve-out is absent.
|
||||
|
||||
The Allow goes FIRST. RFC 9309 crawlers take the most specific match and
|
||||
would get this right either way, but ones that take the first match read
|
||||
a leading "Disallow: /" and stop — skipping the very pages the carve-out
|
||||
exists to keep crawlable.
|
||||
"""
|
||||
lines = ["User-agent: *"]
|
||||
if settings.HELP_CENTER_PUBLIC_MODE == "path":
|
||||
lines.append("Allow: /help/")
|
||||
lines.append("Disallow: /")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class NoIndexHeaderMiddleware:
|
||||
"""Stamp ``X-Robots-Tag: noindex`` on every response under ``prefix``.
|
||||
|
||||
Pure ASGI rather than BaseHTTPMiddleware: this fronts every API request,
|
||||
and BaseHTTPMiddleware would pull streaming responses through a queue for
|
||||
the sake of one header.
|
||||
"""
|
||||
|
||||
def __init__(self, app, prefix: str):
|
||||
self.app = app
|
||||
self.prefix = prefix
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http" or not scope.get("path", "").startswith(self.prefix):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_noindex(message):
|
||||
if message["type"] == "http.response.start":
|
||||
message["headers"] = [*(message.get("headers") or []), NOINDEX_HEADER]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_noindex)
|
||||
|
||||
|
||||
def install_crawler_policy(app: FastAPI) -> None:
|
||||
"""Register the robots.txt route and the noindex header middleware."""
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False)
|
||||
async def robots_txt():
|
||||
return PlainTextResponse(api_robots_txt())
|
||||
|
||||
app.add_middleware(NoIndexHeaderMiddleware, prefix=settings.API_V1_STR)
|
||||
@@ -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