Files
pagerite/pagerite/messages.py
T
LeoVasanko afa6b967e6 contact messages: unified overlay, corner ✉️ icon for everyone
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.
2026-09-28 01:37:37 +00:00

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"},
)