diff --git a/AGENTS.md b/AGENTS.md index 12ab4dd..7826759 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``, 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. diff --git a/docs/analytics.md b/docs/analytics.md index 4e563e5..43a4b23 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -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, diff --git a/docs/messages.md b/docs/messages.md index f5ab9fb..dd50919 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -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 (`