From e3080d70c03379f3a814f9da12745fff77d5e0e8 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 27 Sep 2026 16:35:14 +0000 Subject: [PATCH] contact messages: hold the PoW challenge as a WS-handler local Move the PoW state from a module-level dict keyed by WebSocket in messages.py to a plain per-connection local in tracking.activity_ws, making PoW a WS-level concern instead of being buried in the message handler: - activity_ws validates and burns the solution (raw.pop('pow')) before forwarding a message frame, and issues a fresh challenge after every registration and every vetted message frame; a failed validation is answered with a 'required' challenge and the frame never reaches messages.py. - messages.py loses the _pow dict, _issue_pow and the disconnect cleanup for it; visitor_frame needs no return value since a vetted frame always triggers a fresh challenge (a too-long message simply comes with contact-error and required=false, so the client does not retry it). The wire protocol and the frontend are unchanged. --- AGENTS.md | 4 ++-- docs/messages.md | 3 ++- pagerite/messages.py | 43 +++++++++++++------------------------------ pagerite/tracking.py | 26 ++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e517e4b..0f41a68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `state.py` — shared core, no routes: env-derived site constants, `data`/`kanta`, `analytics_store`, the fastapi-vue `frontend`, the render cache (`_html_response`, `_invalidate_pages`), the translator `dispatcher`, slug helpers, `@kanta.bootstrap` hooks. - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings). - `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`. - - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames, the analytics socket the admin inbox. - - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols, easypow PoW gating (single-use challenges in per-socket RAM, browser solver served at `/_contact/easypow.js`), and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). + - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and gates sends with a per-connection single-use easypow challenge, the analytics socket the admin inbox. + - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ``, and llms.txt links point at the `.md` URLs. - `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `` and the sitemap, recorded in analytics. - `data.py` — msgspec Structs for the kanta database. diff --git a/docs/messages.md b/docs/messages.md index 199ff7a..bc196b8 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -70,7 +70,8 @@ with the bundled solver module (served from the installed package at typically finishing while the user is still writing. Each message burns one solution — a send with a missing, stale or already-spent solution is dropped and answered with a fresh `required` challenge, which the client -solves and retries once. Challenges live in per-socket RAM state only. +solves and retries once. Challenges are a per-connection local in the +`/_ws` handler (RAM only). ## Admin side diff --git a/pagerite/messages.py b/pagerite/messages.py index 26f0ccf..c4b8df3 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -10,10 +10,12 @@ Admins watch and reply over the analytics socket 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): registration/resync and every -accepted message come with a fresh single-use challenge, solved in the -background by the browser (the bundled JS solver is served at -/_contact/easypow.js); a message burns one solution. Uploads are not +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 @@ -62,10 +64,9 @@ MAX_MESSAGE = 8000 _visitor_ws: dict[bytes, set[WebSocket]] = {} _admin_ws: set[WebSocket] = set() -#: PoW state (RAM only): the outstanding single-use challenge per visitor -#: socket, and uploaded-but-unreferenced file digests per session — the -#: latter are deleted when the session's last socket closes. -_pow: dict[WebSocket, str] = {} +#: 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. @@ -203,19 +204,12 @@ def _append(key: bytes, msg: ContactMessage, action: str, user: str | None) -> N # --- Visitor protocol (/_ws frames carrying "token") ------------------------ -async def _issue_pow(ws: WebSocket, *, required: bool = False) -> None: - """Send a fresh single-use PoW challenge (~POW_SECONDS of solving), - replacing any outstanding one for the socket.""" - challenge = easypow.generate(POW_SECONDS) - _pow[ws] = challenge - await ws.send_json({"type": "pow", "challenge": challenge, "required": required}) - - async def visitor_frame(ws: WebSocket, raw: dict) -> None: """Handle one visitor frame: ``{token}`` registers/resyncs (the thread - state is pushed back, with a fresh PoW challenge), ``{token, msg, pow, - fr?}`` burns the single-use PoW solution, appends the message and - broadcasts the updated thread to both sides.""" + 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"}) @@ -226,19 +220,10 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: if not text: thread = data.contact.get(key) await ws.send_json(_payload(key, thread) if thread else _empty_payload(key)) - await _issue_pow(ws) return if len(text) > MAX_MESSAGE: await ws.send_json({"type": "contact-error", "detail": "message too long"}) return - challenge = _pow.pop(ws, None) # every send attempt consumes it - try: - if challenge is None: - raise ValueError("no outstanding challenge") - easypow.validate(challenge, str(raw.get("pow") or "")) - except ValueError: - await _issue_pow(ws, required=True) - return now = datetime.now(UTC) _append( key, @@ -256,14 +241,12 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: for digest in re.findall(r"/_f/([0-9a-f]{12})", text): pending.discard(digest) await _broadcast(key, data.contact[key]) - await _issue_pow(ws) # the next message needs a fresh solution 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.""" - _pow.pop(ws, None) empty = [key for key, sockets in _visitor_ws.items() if not sockets - {ws}] for sockets in _visitor_ws.values(): sockets.discard(ws) diff --git a/pagerite/tracking.py b/pagerite/tracking.py index 49eebc1..9d443c0 100644 --- a/pagerite/tracking.py +++ b/pagerite/tracking.py @@ -23,6 +23,7 @@ from functools import lru_cache from pathlib import Path from urllib.parse import urlparse +import easypow import httpx import msgspec from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect @@ -451,6 +452,10 @@ async def activity_ws(ws: WebSocket) -> None: Frames carrying a ``token`` field are visitor contact messages (docs/messages.md), routed to pagerite.messages — the same socket serves the contact panel, which also receives thread pushes on it. + Message sends are proof-of-work gated at this level: a single-use + easypow challenge (a per-connection local) is issued on registration + and after every vetted message frame, and validated/burned before a + message frame is forwarded. """ ip = _client_ip(ws) ua = ws.headers.get("user-agent", "") @@ -462,6 +467,7 @@ async def activity_ws(ws: WebSocket) -> None: part for part in (uaparse(ua).pretty, lang) if part ) await ws.accept() + challenge = "" # outstanding PoW challenge for contact sends (below) try: while True: text = await ws.receive_text() @@ -470,7 +476,27 @@ async def activity_ws(ws: WebSocket) -> None: except msgspec.DecodeError: continue if isinstance(raw, dict) and "token" in raw: + # Contact-messaging frames (docs/messages.md). Sends are + # gated by a single-use easypow challenge held in the + # connection local above: validated and burned here, a + # fresh one issued after every registration and every + # vetted message frame. + if raw.get("msg"): + try: + if not challenge: + raise ValueError("no outstanding challenge") + easypow.validate(challenge, str(raw.pop("pow", "") or "")) + except ValueError: + challenge = easypow.generate(messages.POW_SECONDS) + await ws.send_json( + {"type": "pow", "challenge": challenge, "required": True} + ) + continue await messages.visitor_frame(ws, raw) + challenge = easypow.generate(messages.POW_SECONDS) + await ws.send_json( + {"type": "pow", "challenge": challenge, "required": False} + ) continue try: msg = msgspec.convert(raw, type=analytics.Ping)