The inbox moves out of the analytics tab to its own page at /_m (own pens-row icon, own entry and socket lifecycle; the admin-gated /_api/ws/analytics socket keeps serving both, with typed messaging frames ignored by AnalyticsView). Admin replies are labeled with the currently configured site name in the wire payload (the SSO name stays stored for audit), and the thread view is capped to roughly the visitor panel's width.
167 lines
9.1 KiB
Markdown
167 lines
9.1 KiB
Markdown
# 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 `/_m`.
|
||
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, the `/_m` inbox
|
||
page route 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`), the
|
||
`/_m` page render and the on-demand bundles' 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/messages-main.js` + `MessagesView.vue` — the admin inbox
|
||
page at `/_m` (own pens-row icon, own socket lifecycle).
|
||
|
||
## 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, 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
|
||
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.
|
||
|
||
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}`.
|
||
|
||
### 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).
|
||
|
||
## Admin side
|
||
|
||
Admins watch the inbox on its own page, **`/_m`** (✉️ icon in the pens
|
||
row), 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 each client
|
||
picks out its own frames (the analytics viewer ignores them). 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 store the SSO `remote-user` name for audit but are labeled with
|
||
the currently configured **site name** in the wire payload (substituted at
|
||
read time) 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. Images (raster and
|
||
SVG) go through the usual AVIF/WebP/JPEG derivative pipeline and inline
|
||
in the message — SVGs included: the extension-less link negotiates
|
||
raster derivatives, and even an as-is-served `.svg` is script-inert
|
||
inside an `<img>`. Everything else (videos, archives, html, exe…) is
|
||
stored as an inert `{hash}.file` blob — the original extension is
|
||
dropped on disk, so the store can never be double-click-executed — and
|
||
linked as `/_f/{hash}/{original-name}`; the `.file` suffix exists only
|
||
on the filesystem and is rejected in URLs. Serving is strictly
|
||
separated: `/_f/{name}` serves media only (hash with or without
|
||
extension), `.orig.` originals stay blocked, and the filename-carrying
|
||
route serves only `{name}.file` blobs (never media, never by hash).
|
||
Every `/_f/` response carries `content-disposition: attachment` (no
|
||
filename needed — the URL's last segment names the download) and
|
||
`x-content-type-options: nosniff`, so a store file can never be
|
||
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
|
||
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
|
||
server restart (the pending set is RAM-only) still linger — no 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`, the visitor's self-entered
|
||
`name`/`email` (name required, email optional; captured with the first
|
||
message, fixed afterwards) 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 — the wire
|
||
payload shows the current site name instead) and `page`.
|
||
Appends are single transactions (`contact` / `contact:reply` /
|
||
`contact:read`).
|
||
|
||
## Limits and deliberate gaps (MVP)
|
||
|
||
- Message source ≤ 8000 chars; uploads ≤ 20 MB and ≤ 10 pending per
|
||
session; payloads cap at 100 threads × last 200 messages.
|
||
- No thread moderation (delete/block), no orphan-upload GC for files
|
||
orphaned by a server restart, and panel strings are English-only.
|