contact messages: docs

docs/messages.md covers the trigger, both socket protocols, the storage
model, the XSS stance (grammar restriction via a strict renderer, never
HTML sanitization), uploads, and the MVP gaps (PoW seams, moderation,
orphan GC, i18n). AGENTS.md and frontend-runtime.md learn the new
module, bundles and asset keys.
This commit is contained in:
LeoVasanko committed 2026-09-26 01:17:30 +00:00
1 parent bbb11bf17e
commit 975ebfe5e7
3 files changed
+130 -3

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ It first probes `GET /auth/api/settings` to detect whether Paskia SSO is availab
The editor/analytics components (everything except pagerite.js and main.js) make their `/_api` calls through paskia-js's `apiFetch`/`apiJson` instead of plain `fetch`: when a call gets a 401/403 carrying an `auth.iframe` hint (expired session), the login dialog opens in place and the request retries transparently after authentication. pagerite.js uses `apiJson` only for the task-checkbox toggle (ticking a box is an explicit edit attempt, so a login dialog is welcome there; any failure — including a cancelled login — reverts the checkbox) and `fetchJson` for its auth probes (plain fetch with JSON handling and errors on non-OK, never a dialog); page-cache and navigation fetches stay on plain `fetch`, so anonymous visitors never get a login popup uninvited.
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src`/`pagerite:contact-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened; the langselect and contact bundles load on demand the same way, on multi-language pages and `{contact:}` trigger clicks. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
## `assets/`
+123
View File
@@ -0,0 +1,123 @@
# Contact messages
Visitor→admin messaging built into the site: a `{contact: Label}` directive
in article Markdown renders a button that opens a chat-style panel; messages
are stored in the kanta database and answered from the admin inbox on `/_a`.
Visitors are anonymous — a localStorage session token identifies their
thread — and everything realtime rides the two existing WebSockets; there is
no REST polling anywhere.
- `pagerite/messages.py` — the feature's core: the strict message renderer,
thread store ops, wire payloads, both socket protocols and the
`/_contact/files` upload route.
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`).
- `pagerite/tracking.py` — the sockets the protocol rides (see below).
- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`) and the
on-demand bundle's asset config.
- `frontend/src/contact-main.js` + `ContactPanel.vue` — the visitor panel,
imported on demand by `pagerite.js` on trigger clicks.
- `frontend/src/Composer.vue` + `richtext.js` — the shared composer and its
DOM→Markdown serializer.
- `frontend/src/MessagesView.vue` — the admin inbox (the messages tab of
`AnalyticsView.vue` on `/_a`).
## Trigger
A lone `{contact: Label}` line in article Markdown (the block-directive
machinery of `markdown.py`) renders
`<button class="contact-trigger">Label</button>` ("Contact us" when the
label is empty). `pagerite.js` click-delegates these buttons and lazy-loads
the contact bundle; the editor preview renders the same button inert, and
description/card mining resolves the tag to nothing. The label lives in the
chunk, so translated pages localize it like any other text. Feeds render the
tag as its literal source (no directive context).
## Visitor side
The token is `crypto.randomUUID()` in localStorage
(`pagerite-contact-token`), generated on first use; the server stores only
its blake3 hash as the thread key. Pages are shared/cached renders, so the
thread UI is entirely client-side. A per-browser "seen" timestamp
(`pagerite-contact-seen`) drives the unread dot on trigger buttons when an
admin reply arrives while the panel is closed.
The panel rides the public `/_ws` activity socket via a bridge in
`pagerite.js` (`register`/`send`, sharing its queue and reconnect pacing —
no extra connection). Frames **to** the server:
- `{token}` — register/resync; the current thread state is pushed back.
`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).
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}`.
## Admin side
Admins watch the inbox as the **messages tab on `/_a`**, served by the
existing `/_api/ws/analytics` socket (admin-gated like all of `/_api`).
Analytics payloads keep their type-less snapshot shape; messaging frames
always carry a `type` field, and that is how the client tells them apart.
On connect the server sends `{type: "threads", threads: [...]}` (100 most
recently active); new messages and replies push single-thread updates.
Admin→server frames: `{type: "reply", id, text}` and `{type: "read", id}`.
Replies are authored with the SSO `remote-user` name and mark the thread
read; `unread` on a thread means its last message is the visitor's and
newer than the admin's last read.
## Message format and the XSS model
Bodies are a **Markdown subset** — emphasis, links, lists, code, plus
`/_f/` image and file references — serialized client-side from a
constrained contenteditable composer (plain-text paste only; formatting
via toolbar; uploads insert image/attachment-chip nodes).
**No user HTML is ever rendered or sanitized.** Sanitizing arbitrary HTML
is unwinnable, so the design never attempts it: the composer serializes to
*text*, and the server re-renders that text with a strict `MarkdownIt`
instance (`messages.py`): `html=False` (the page renderer's trusted-author
raw passthrough does not apply here), no plugins. Every byte of the stored
`html` is markdown-it output from the constrained grammar. markdown-it
also validates link protocols (`javascript:` & co. never become `href`s);
the link rule adds `rel="nofollow ugc noopener"` + `target="_blank"` (and
`download` + a chip class for `/_f/` attachment links); images are
restricted to `/_f/` store names, anything else collapses to its alt text.
Both panels inject this server-rendered HTML verbatim (`v-html`) — the one
injection point, fed only by the strict renderer.
## Uploads
`PUT /_contact/files/{name}` (public, like the messaging socket) stores
panel uploads in the content-addressed file store: raster images through
the usual AVIF/WebP/JPEG derivative pipeline, a small allowlist of document
types (pdf/txt/md/csv/zip) as-is. **SVG and HTML are excluded** — script
carriers. Gates today: a session-shaped `x-contact-token` header, a 20 MB
size cap and the type allowlist. Admin uploads from the same composer go
to the admin-gated `/_api/files` instead. Files uploaded but never
referenced by a sent message currently linger (no orphan GC).
## Storage
`Data.contact: dict[bytes, ContactThread]` in the kanta database, keyed by
the 12-byte blake3 hash of the session token (never the token itself). A
thread is `created`, `admin_read`, and `messages: dict[str,
ContactMessage]` keyed by a sortable timestamp-random id (dict-keyed, so a
save's database diff touches only the appended message). Each message
stores `t`, the Markdown source `md`, its strict rendering `html`, `admin`
("" = visitor, else the SSO name) and `page`. Appends are single
transactions (`contact` / `contact:reply` / `contact:read`).
## Limits and deliberate gaps (MVP)
- Message source ≤ 8000 chars; uploads ≤ 20 MB; payloads cap at 100
threads × last 200 messages.
- **No proof-of-work yet**: the send and upload paths are the two seams
where the PoW stamp plugs in (challenge on token registration, verify on
`msg`/upload; one stamp may cover several messages).
- No thread moderation (delete/block), no orphan-upload GC, panel strings
are English-only, and the admin inbox lists threads by token-hash prefix
rather than enriched visitor identity.