From ec35bc71e81d6136dc2c6bdaeb185c857cde4c64 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 27 Sep 2026 06:13:53 +0000 Subject: [PATCH] contact messages: easypow proof-of-work for sends, pending-upload cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New dependency: easypow (PBKDF2-SHA512 challenges, bundled JS solver). Sends are PoW-gated: registration/resync and every accepted message come with a fresh single-use challenge (~5 s solve), held in per-socket RAM state only — nothing in the database. A message burns one solution; a missing, stale or spent solution is dropped and answered with a fresh 'required' challenge. The bundled browser solver is served at /_contact/easypow.js straight from the installed package, so client and validator versions always match. Uploads are deliberately not PoW-gated (the transfer rate is the real cost). Instead, uploads are tracked as pending per session and a quota caps pending files at 10 — answered 429, not 403, which could trip auth flows. A sent message strikes the files it references off the pending set; when the session's last socket closes, the remaining pending files are deleted from the store. --- AGENTS.md | 4 +-- docs/messages.md | 51 ++++++++++++++++++--------- pagerite/messages.py | 82 ++++++++++++++++++++++++++++++++++++++++---- pyproject.toml | 1 + 4 files changed, 114 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9faf70..e517e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `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, and the public `/_contact/files` upload route. + - `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). - `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. @@ -49,7 +49,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed). ## Toolchain -- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). +- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `easypow`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). - Key libraries: - **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML). - To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header diff --git a/docs/messages.md b/docs/messages.md index 388b77f..199ff7a 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -49,12 +49,28 @@ no extra connection). Frames **to** the server: `pagerite.js` re-sends this on every socket (re)connect, which is the resync mechanism (the socket idle-closes after 5 min and reconnects on activity). -- `{token, msg, fr}` — append a message; `msg` is the Markdown-subset - source, `fr` the page path it was sent from (stored for context). +- `{token, msg, pow, fr}` — append a message; `msg` is the Markdown-subset + source, `pow` the solved proof-of-work (below), `fr` the page path it + was sent from (stored for context). Frames **from** the server: `{type: "thread", id, created, unread, messages: [{id, t, admin, html, page}]}` — a full thread snapshot -(replace/upsert by `id`), or `{type: "contact-error", detail}`. +(replace/upsert by `id`); `{type: "pow", challenge, required}` — a fresh +single-use PoW challenge (`required` means the last message was rejected +for a missing/stale solution and should be re-sent once solved); or +`{type: "contact-error", detail}`. + +### Proof of work (anti-spam) + +Sends are gated by easypow (PBKDF2 challenges, ~5 s solving cost). The +server hands a fresh single-use challenge with every registration/resync +and after every accepted message; the panel solves it in the background +with the bundled solver module (served from the installed package at +`/_contact/easypow.js`, so client and validator versions always match), +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. ## Admin side @@ -110,11 +126,16 @@ filename needed — the URL's last segment names the download) and rendered or sniffed into something executable in the site's origin; subresource fetches (``, `