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:
1 parent
bbb11bf17e
commit
975ebfe5e7
3 files changed
+130
-3
No files matched your search
@@ -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/`
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user