contact messages: document the PoW quota, email reveal and trail marker

docs/messages.md matches the quota protocol (proof frames, quota acks,
contact-email, upload 429 re-auth); docs/analytics.md notes the
server-originated contact trail marker; AGENTS.md's tracking/messages
bullets match.
This commit is contained in:
LeoVasanko committed 2026-09-28 14:42:47 +00:00
1 parent 2843469e04
commit 719546a0ee
3 files changed
+46 -28

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 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`), the admin unread count for `/_api/settings`, 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 holds the per-connection easypow challenge (a validated proof grants the per-session message/upload quota held in messages.py, reveals the site contact email, and records the visit's contact trail marker), 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 challenges live in `tracking.activity_ws` as a per-connection local; the granted quota here as a per-session RAM budget shared with the upload route; browser solver served at `/_contact/easypow.js`), the admin unread count for `/_api/settings`, and the public `/_contact/files` upload route (PoW quota + 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.
+5 -1
View File
@@ -199,7 +199,11 @@ for misses.
re-visited in a different language becomes a distinct trail step instead
of merging into the existing item. The entry page's referer and
`utm_*` tags come from the GET that loaded it (within 10 s before the
first message).
first message). A server-originated contact marker (the visitor opened
the contact panel and solved its proof of work, docs/messages.md —
recorded by the `/_ws` handler on the validated solution, never by the
client) flags the trail step it happened on, the first one per visit,
rendered with a ✉️ marker in the viewer.
- **Crawler hits**: a document GET no activity message matched within
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
documents never register as visits. JS-running crawlers (Googlebot,
+39 -25
View File
@@ -64,10 +64,12 @@ same messaging block: its language selector switches the label/subject
fields to a translation, saved via `PUT /_api/settings?lang=` into the
same store (`i18n.set_string_translation`; empty or the original's text
drops the override). The localized strings reach the client through the
page's asset config (`pagerite:contact-label` / `-subject` / `-email`,
absent entirely when disabled — pagerite.js reads that as "visitors get
no icon"); `contact-main.js` re-reads them on every open, so a language
switch is reflected immediately.
page's asset config (`pagerite:contact-label` / `-subject`, absent
entirely when disabled — pagerite.js reads that as "visitors get no
icon"); `contact-main.js` re-reads them on every open, so a language
switch is reflected immediately. The email address itself is never in
the config: it is revealed over the messaging socket only after the
proof of work is solved.
## Visitor side
@@ -91,9 +93,14 @@ 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, pow, fr, name?, email?}` — 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). The **first** message
- `{token, pow}` — a solved proof-of-work, submitted as soon as the
background solve finishes (below); grants the message/upload quota,
reveals the site's contact email and marks the visit's analytics trail.
- `{token, msg, fr, name?, email?}` — append a message; `msg` is the
Markdown-subset source, `fr` the page path it was sent from (stored
for context). Spends one quota; with none left it is answered with a
fresh `required` challenge instead, which the client solves, re-proves
and retries the message once. The **first** message
of a thread must carry `name` (and optionally `email`, unvalidated)
introducing the visitor; the identity is stored on the thread and fixed
from then on.
@@ -102,21 +109,27 @@ Frames **from** the server: `{type: "thread", id, created, unread, name,
email, messages: [{id, t, admin, html, page}]}` — a full thread snapshot
(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}`.
for an empty quota and should be re-sent once re-proven); `{type: "pow",
quota}` — a submitted proof was accepted, this many messages/uploads it
grants; `{type: "contact-email", email}` — the site's contact email,
revealed only after a solve; 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 are a per-connection local in the
`/_ws` handler (RAM only).
Sends and uploads draw from a quota granted by easypow (PBKDF2
challenges, ~5 s solving cost, 10 actions per solve). The server hands a
fresh single-use challenge on registration/resync and whenever the quota
runs out; 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) and submits the solution as
soon as it is ready — typically long before the user finishes writing,
so sends just work. Solutions are single-use and challenges are a
per-connection local in the `/_ws` handler; the granted quota lives in
RAM keyed by the token hash (single worker), shared with the HTTP upload
route and kept across reconnects. A validated proof also reveals the
site's contact email (the address never appears in pages — scrapers
cannot lift it without solving) and records the visit's contact trail
marker in analytics (a panel opened without a solve leaves no trace).
## Admin side
@@ -180,12 +193,13 @@ filename needed — the URL's last segment names the download) and
rendered or sniffed into something executable in the site's origin;
subresource fetches (`<img>`, `<video>`, favicons) ignore the
disposition, so inline media is unaffected. The chip anchors
additionally carry a `download` attribute. Uploads are deliberately
**not** PoW-gated (the transfer rate is the real cost there); the gates
are a session-shaped `x-contact-token` header, a 20 MB size cap, and a
pending-upload quota — 10 un-sent files per session, answered 429 (not
403, which could trip auth flows). Every upload stays *pending* per
session: a sent message strikes the files it references off the pending
additionally carry a `download` attribute. Uploads spend the same PoW
quota as messages (a solved challenge covers both; the gates are a
session-shaped `x-contact-token` header, a 20 MB size cap, and the
quota itself) — an upload answered 429 "pow" makes the client re-prove
and retry once. Every upload also stays *pending* per session (10
un-sent files cap, answered 429 — not 403, which could trip auth
flows): a sent message strikes the files it references off the pending
set, and when the session's last socket closes the remaining pending
files are deleted from the store. Admin uploads from the same composer
go to the admin-gated `/_api/files` instead. Files left behind by a