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:
1 parent
0adcd0bf90
commit
e3080d70c0
4 files changed
+43
-33
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user