The admin inbox leaves the short-lived /_m page and joins the visitor panel as one overlay (ContactOverlay shell over paskia's shared blur backdrop, centered dialog, full-screen on mobile): the corner ✉️ icon opens the visitor thread for visitors and the wider sidebar inbox for admins, while {contact:} trigger buttons always open the visitor side so admins can test both. The icon highlights unread state without extra connections: visitors via localStorage seen/last-admin timestamps, admins via the contact_unread count added to the /_api/settings probe, both kept live by the bundle once loaded. MessagesView owns its admin socket only while the overlay is open.
446 lines
17 KiB
Python
446 lines
17 KiB
Python
"""Visitor contact messages: storage, strict rendering, socket protocol.
|
|
|
|
Anonymous visitors open a panel from a ``{contact: Label}`` button in an
|
|
article (views._contact_tag). The panel rides the public /_ws activity
|
|
socket: tracking.activity_ws routes frames carrying a ``token`` field to
|
|
``visitor_frame``. The token (a localStorage random, generated on first
|
|
use) identifies the visitor's thread; only its blake3 hash is stored.
|
|
Admins watch and reply from the same overlay panel (the corner ✉️ icon
|
|
opens the inbox for them, the visitor thread for visitors) over the
|
|
analytics socket (tracking.analytics_websocket), admin-gated like the
|
|
rest of /_api; their frames go to ``admin_frame``. Both sides get
|
|
pushed full thread payloads (threads are small); there is no REST
|
|
polling anywhere.
|
|
|
|
Sends are proof-of-work gated (easypow): the /_ws handler
|
|
(tracking.activity_ws) issues a fresh single-use challenge with every
|
|
registration/resync and after every accepted message, and validates and
|
|
burns the solution before forwarding a send here — challenges are a
|
|
per-connection local there. The browser solves in the background with
|
|
the bundled solver (served at /_contact/easypow.js). Uploads are not
|
|
PoW-gated — the transfer rate is the real cost there — but uploaded
|
|
files stay pending per session and are deleted when its last socket
|
|
closes without a sent message referencing them; a quota caps pending
|
|
files.
|
|
|
|
Message bodies are a Markdown subset (emphasis, links, lists, code plus
|
|
/_f/ image and file references) serialized client-side from a constrained
|
|
contenteditable composer. They are rendered here with a strict MarkdownIt
|
|
instance: html=False, so not a single byte of user markup can reach a
|
|
page — the stored html is always markdown-it output from the constrained
|
|
grammar, never sanitized user HTML (sanitization is unwinnable; grammar
|
|
restriction is not). Links get rel="nofollow ugc noopener"; images are
|
|
limited to /_f/ store names (the upload endpoint is the only way in).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import secrets
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
import blake3
|
|
import easypow
|
|
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
|
from fastapi.responses import Response
|
|
from markdown_it import MarkdownIt
|
|
from markdown_it.common.utils import escapeHtml
|
|
|
|
from pagerite.data import ContactMessage, ContactThread
|
|
from pagerite.files import _ext, file_store, store_attachment, store_image
|
|
from pagerite.state import data, kanta
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
#: Session-token shape: crypto-random from the browser (uuid or hex).
|
|
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
|
|
|
|
#: Message source length cap (the rendered html is bounded by it too).
|
|
MAX_MESSAGE = 8000
|
|
|
|
#: Live sockets: visitor token hash -> panels, and every connected admin.
|
|
_visitor_ws: dict[bytes, set[WebSocket]] = {}
|
|
_admin_ws: set[WebSocket] = set()
|
|
|
|
#: Uploaded-but-unreferenced file digests per session (RAM only) — deleted
|
|
#: when the session's last socket closes. The PoW challenge state is a
|
|
#: per-connection local in the WS handler (tracking.activity_ws).
|
|
_pending_uploads: dict[bytes, set[str]] = {}
|
|
|
|
#: easypow solving cost per message and the pending-upload quota.
|
|
POW_SECONDS = 5.0
|
|
MAX_PENDING_UPLOADS = 10
|
|
|
|
|
|
# --- Strict rendering -----------------------------------------------------
|
|
# A separate instance from markdown.py's page renderer: no raw HTML (the
|
|
# trusted-author passthrough would be an XSS hole here), no plugins, no
|
|
# typographer. markdown-it escapes all markup in the source and validates
|
|
# link protocols (javascript: etc. never become hrefs).
|
|
|
|
_md = MarkdownIt("default", {"html": False, "typographer": False, "breaks": True})
|
|
|
|
#: Images may only reference the content-addressed store (uploads land
|
|
#: there via /_contact/files); anything else collapses to its alt text.
|
|
_IMG_SRC = re.compile(r"/_f/[0-9a-f]{12}(?:\.[a-z0-9]+)?")
|
|
|
|
|
|
def _link_rule(self, tokens, idx, options, env):
|
|
"""Open external links in a new tab without window.opener; store-file
|
|
links (uploaded attachments) render as download chips."""
|
|
token = tokens[idx]
|
|
href = token.attrs.get("href", "")
|
|
if href.startswith("/_f/"):
|
|
token.attrSet("class", "attachment")
|
|
token.attrSet("download", "")
|
|
else:
|
|
token.attrSet("rel", "nofollow ugc noopener")
|
|
token.attrSet("target", "_blank")
|
|
return self.renderToken(tokens, idx, options, env)
|
|
|
|
|
|
def _image_rule(self, tokens, idx, options, env):
|
|
"""Drop images that do not point at a /_f/ store file (escaped alt)."""
|
|
token = tokens[idx]
|
|
if not _IMG_SRC.fullmatch(token.attrs.get("src", "")):
|
|
return escapeHtml(self.renderInlineAsText(token.children, options, env))
|
|
token.attrSet("loading", "lazy")
|
|
return self.renderToken(tokens, idx, options, env)
|
|
|
|
|
|
_md.add_render_rule("link_open", _link_rule)
|
|
_md.add_render_rule("image", _image_rule)
|
|
|
|
|
|
def render_message(text: str) -> str:
|
|
"""Render message-source Markdown to thread HTML (the only HTML a
|
|
visitor's message ever becomes)."""
|
|
return _md.render(text)
|
|
|
|
|
|
# --- Thread store ----------------------------------------------------------
|
|
|
|
|
|
def thread_key(token: str) -> bytes:
|
|
"""The Data.contact key for a session token (hash, never the token)."""
|
|
return blake3.blake3(token.encode()).digest(12)
|
|
|
|
|
|
def _new_id(now: datetime) -> str:
|
|
"""Sortable message id: timestamp plus a random tiebreak."""
|
|
return f"{now:%Y%m%d%H%M%S%f}-{secrets.token_hex(3)}"
|
|
|
|
|
|
def _unread(thread: ContactThread) -> bool:
|
|
"""Admin perspective: the last message is the visitor's and newer than
|
|
the admin's last read."""
|
|
for mid in reversed(thread.messages):
|
|
m = thread.messages[mid]
|
|
if m.admin:
|
|
return False
|
|
return thread.admin_read is None or m.t > thread.admin_read
|
|
return False
|
|
|
|
|
|
def _payload(key: bytes, thread: ContactThread) -> dict:
|
|
"""Full thread state for the wire (same shape for visitors and admins;
|
|
clients replace/upsert by id). Messages are capped to the last 200.
|
|
Admin messages are labeled with the site name as currently configured
|
|
(not the stored SSO name, which stays in the database for audit)."""
|
|
brand = data.brand or "admin"
|
|
return {
|
|
"type": "thread",
|
|
"id": key.hex(),
|
|
"created": thread.created.isoformat(),
|
|
"unread": _unread(thread),
|
|
"name": thread.name,
|
|
"email": thread.email,
|
|
"messages": [
|
|
{
|
|
"id": mid,
|
|
"t": m.t.isoformat(),
|
|
"admin": brand if m.admin else "",
|
|
"html": m.html,
|
|
"page": m.page,
|
|
}
|
|
for mid, m in list(thread.messages.items())[-200:]
|
|
],
|
|
}
|
|
|
|
|
|
def _empty_payload(key: bytes) -> dict:
|
|
return {"type": "thread", "id": key.hex(), "created": "", "unread": False,
|
|
"name": "", "email": "", "messages": []}
|
|
|
|
|
|
async def _push(sockets, payload: dict) -> None:
|
|
"""Send a payload to a socket set, dropping the ones that fail."""
|
|
closed = set()
|
|
for ws in sockets:
|
|
try:
|
|
await ws.send_json(payload)
|
|
except Exception:
|
|
logger.exception("Message push failed; dropping client")
|
|
closed.add(ws)
|
|
for ws in closed:
|
|
sockets.discard(ws)
|
|
|
|
|
|
async def _broadcast(key: bytes, thread: ContactThread) -> None:
|
|
"""Push a thread update to its visitor's panels and every admin."""
|
|
payload = _payload(key, thread)
|
|
if key in _visitor_ws:
|
|
await _push(_visitor_ws[key], payload)
|
|
await _push(_admin_ws, payload)
|
|
|
|
|
|
def _append(
|
|
key: bytes,
|
|
msg: ContactMessage,
|
|
action: str,
|
|
user: str | None,
|
|
name: str = "",
|
|
email: str = "",
|
|
) -> None:
|
|
"""Append a message to the thread (created on first use), in one
|
|
transaction so the database diff is just the new entry. A visitor's
|
|
name/email are recorded alongside their first message."""
|
|
with kanta.transaction(action, user=user, extra=key.hex()):
|
|
thread = data.contact.get(key)
|
|
if thread is None:
|
|
thread = data.contact[key] = ContactThread(created=msg.t)
|
|
if name and not thread.name:
|
|
thread.name = name
|
|
thread.email = email
|
|
thread.messages[_new_id(msg.t)] = msg
|
|
|
|
|
|
# --- Visitor protocol (/_ws frames carrying "token") ------------------------
|
|
|
|
|
|
async def visitor_frame(ws: WebSocket, raw: dict) -> None:
|
|
"""Handle one visitor frame: ``{token}`` registers/resyncs (the thread
|
|
state is pushed back), ``{token, msg, fr?}`` appends a message and
|
|
broadcasts the updated thread to both sides. PoW extraction and
|
|
validation happen in the WS handler (tracking.activity_ws) — message
|
|
frames arrive here already vetted."""
|
|
token = str(raw.get("token") or "")
|
|
if not TOKEN_RE.fullmatch(token):
|
|
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
|
|
return
|
|
key = thread_key(token)
|
|
_visitor_ws.setdefault(key, set()).add(ws)
|
|
text = str(raw.get("msg") or "").strip()
|
|
if not text:
|
|
thread = data.contact.get(key)
|
|
await ws.send_json(_payload(key, thread) if thread else _empty_payload(key))
|
|
return
|
|
if len(text) > MAX_MESSAGE:
|
|
await ws.send_json({"type": "contact-error", "detail": "message too long"})
|
|
return
|
|
# The first message must introduce the visitor (name; email optional,
|
|
# unvalidated). The stored identity is fixed from then on.
|
|
thread = data.contact.get(key)
|
|
name = email = ""
|
|
if thread is None or not thread.name:
|
|
name = str(raw.get("name") or "").strip()[:100]
|
|
email = str(raw.get("email") or "").strip()[:200]
|
|
if not name:
|
|
await ws.send_json({"type": "contact-error", "detail": "name required"})
|
|
return
|
|
now = datetime.now(UTC)
|
|
_append(
|
|
key,
|
|
ContactMessage(
|
|
t=now,
|
|
md=text,
|
|
html=render_message(text),
|
|
page=str(raw.get("fr") or "")[:200],
|
|
),
|
|
"contact",
|
|
None,
|
|
name,
|
|
email,
|
|
)
|
|
# Files the message references are no longer pending cleanup.
|
|
if pending := _pending_uploads.get(key):
|
|
for digest in re.findall(r"/_f/([0-9a-f]{12})", text):
|
|
pending.discard(digest)
|
|
await _broadcast(key, data.contact[key])
|
|
|
|
|
|
def visitor_disconnect(ws: WebSocket) -> None:
|
|
"""Drop a closed /_ws socket from every registry. When a session's
|
|
last socket closes, its uploaded files that no sent message ended up
|
|
referencing are deleted from the store."""
|
|
empty = [key for key, sockets in _visitor_ws.items() if not sockets - {ws}]
|
|
for sockets in _visitor_ws.values():
|
|
sockets.discard(ws)
|
|
for key in empty:
|
|
del _visitor_ws[key]
|
|
for digest in _pending_uploads.pop(key, ()):
|
|
file_store.delete(digest)
|
|
|
|
|
|
# --- Admin protocol (/_api/ws/analytics frames) -----------------------------
|
|
|
|
|
|
def unread_count() -> int:
|
|
"""Threads with an unanswered visitor message — the admin inbox icon's
|
|
badge, seeded by /_api/settings (no polling; live while the inbox's
|
|
socket is open)."""
|
|
return sum(1 for t in data.contact.values() if _unread(t))
|
|
|
|
|
|
def _key_from_hex(value) -> bytes | None:
|
|
try:
|
|
key = bytes.fromhex(str(value or ""))
|
|
except ValueError:
|
|
return None
|
|
return key if len(key) == 12 else None
|
|
|
|
|
|
def _last_t(thread: ContactThread) -> datetime:
|
|
"""The thread's last-activity time (its newest message, else creation)."""
|
|
if thread.messages:
|
|
return list(thread.messages.values())[-1].t
|
|
return thread.created
|
|
|
|
|
|
async def admin_connect(ws: WebSocket) -> None:
|
|
"""Register an admin socket and send the inbox: all threads, most
|
|
recently active first (capped at 100)."""
|
|
_admin_ws.add(ws)
|
|
threads = sorted(data.contact.items(), key=lambda kv: _last_t(kv[1]), reverse=True)[
|
|
:100
|
|
]
|
|
await ws.send_json(
|
|
{"type": "threads", "threads": [_payload(k, t) for k, t in threads]}
|
|
)
|
|
|
|
|
|
def admin_disconnect(ws: WebSocket) -> None:
|
|
_admin_ws.discard(ws)
|
|
|
|
|
|
async def admin_frame(ws: WebSocket, raw: dict) -> None:
|
|
"""Handle one admin frame: ``{type: reply, id, text}`` appends an admin
|
|
message, ``{type: read, id}`` marks a thread read."""
|
|
match raw.get("type"):
|
|
case "reply":
|
|
key = _key_from_hex(raw.get("id"))
|
|
text = str(raw.get("text") or "").strip()
|
|
thread = data.contact.get(key) if key else None
|
|
if thread is None:
|
|
await ws.send_json({"type": "contact-error", "detail": "no such thread"})
|
|
return
|
|
if not text or len(text) > MAX_MESSAGE:
|
|
await ws.send_json({"type": "contact-error", "detail": "bad message"})
|
|
return
|
|
now = datetime.now(UTC)
|
|
user = ws.headers.get("remote-user")
|
|
_append(
|
|
key,
|
|
ContactMessage(
|
|
t=now,
|
|
md=text,
|
|
html=render_message(text),
|
|
admin=user or "admin",
|
|
),
|
|
"contact:reply",
|
|
user,
|
|
)
|
|
with kanta.transaction("contact:read", user=user, extra=key.hex()):
|
|
thread.admin_read = now
|
|
await _broadcast(key, thread)
|
|
case "read":
|
|
key = _key_from_hex(raw.get("id"))
|
|
thread = data.contact.get(key) if key else None
|
|
if thread is None:
|
|
return
|
|
with kanta.transaction(
|
|
"contact:read", user=ws.headers.get("remote-user"), extra=key.hex()
|
|
):
|
|
thread.admin_read = datetime.now(UTC)
|
|
await _push(_admin_ws, _payload(key, thread))
|
|
|
|
|
|
# --- Uploads -----------------------------------------------------------------
|
|
|
|
#: Contact-panel uploads: images (raster and SVG) go through the usual
|
|
#: derivative pipeline and inline in the message — SVGs included: the
|
|
#: extension-less link negotiates raster derivatives, and even an
|
|
#: as-is-served .svg is inert inside an <img> (and downloads when
|
|
#: navigated to). Every other type is stored as an inert {hash}.file
|
|
#: blob (store_attachment — no original extension on disk) and links as
|
|
#: /_f/{hash}/{original-name}; the .file suffix never appears in URLs.
|
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".heic", ".heif"}
|
|
UPLOAD_MAX_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
@router.put("/_contact/files/{name}")
|
|
async def contact_upload(name: str, request: Request) -> dict:
|
|
"""Store a contact-panel upload in the content-addressed store.
|
|
|
|
Public like the messaging socket (only /_api is gated): the caller
|
|
must present a session-shaped token and size is capped. Uploads are
|
|
deliberately not PoW-gated (the transfer rate is the real cost);
|
|
instead the file stays pending per session and is deleted when the
|
|
session's last socket closes without a sent message referencing it,
|
|
and a quota (429, not 403 — no auth flows here) caps pending files.
|
|
Images (raster and SVG) get the usual derivative treatment and are
|
|
referenced extension-less; any other file is stored as an inert
|
|
{hash}.file blob and referenced as /_f/{hash}/{original-name}, which
|
|
is always served as a forced download. Returns the /_f/ path and
|
|
whether the file is an inline-able image.
|
|
"""
|
|
token = request.headers.get("x-contact-token", "")
|
|
if not TOKEN_RE.fullmatch(token):
|
|
raise HTTPException(403, "no session")
|
|
pending = _pending_uploads.setdefault(thread_key(token), set())
|
|
if len(pending) >= MAX_PENDING_UPLOADS:
|
|
raise HTTPException(429, "too many un-sent uploads — send your message first")
|
|
try:
|
|
length = int(request.headers.get("content-length") or 0)
|
|
except ValueError:
|
|
length = 0
|
|
if length > UPLOAD_MAX_BYTES:
|
|
raise HTTPException(413, "file too large")
|
|
body = await request.body()
|
|
if not body:
|
|
raise HTTPException(400, "empty file")
|
|
if len(body) > UPLOAD_MAX_BYTES:
|
|
raise HTTPException(413, "file too large")
|
|
orig = Path(name).name[:100] or "file"
|
|
ext = _ext(orig)
|
|
if ext in IMAGE_EXTS:
|
|
stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif")
|
|
# Decodable images come back extension-less (GIFs and as-is SVGs
|
|
# keep their extension); an undecodable body attaches like any file.
|
|
if "." not in stored or ext in (".gif", ".svg"):
|
|
pending.add(stored.partition(".")[0])
|
|
return {"path": f"/_f/{stored}", "image": True, "name": orig}
|
|
stored = await asyncio.to_thread(store_attachment, body)
|
|
pending.add(stored.removesuffix(".file"))
|
|
return {
|
|
"path": f"/_f/{stored.removesuffix('.file')}/{quote(orig, safe='')}",
|
|
"image": False,
|
|
"name": orig,
|
|
}
|
|
|
|
|
|
@router.get("/_contact/easypow.js")
|
|
async def pow_solver() -> Response:
|
|
"""The easypow browser solver as an ES module, served from the
|
|
installed package so the client solver and the server validator are
|
|
always the same version."""
|
|
return Response(
|
|
easypow.js,
|
|
media_type="text/javascript",
|
|
headers={"cache-control": "public, max-age=86400"},
|
|
)
|