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:
1 parent
2843469e04
commit
719546a0ee
3 files changed
+46
-28
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 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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user