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.
This commit is contained in:
LeoVasanko committed 2026-09-27 16:35:14 +00:00
1 parent 0adcd0bf90
commit e3080d70c0
4 files changed
+43 -33

No files matched your search

+2 -2
View File
@@ -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 `<link rel="alternate" type="text/markdown">`, 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 `<head>` and the sitemap, recorded in analytics.
- `data.py` — msgspec Structs for the kanta database.
+2 -1
View File
@@ -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
+13 -30
View File
@@ -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)
+26
View File
@@ -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)