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.
9.1 KiB
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/_minbox page route and the/_contact/filesupload 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/_mpage render and the on-demand bundles' asset config.frontend/src/contact-main.js+ContactPanel.vue— the visitor panel, imported on demand bypagerite.json 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.jsre-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;msgis the Markdown-subset source,powthe solved proof-of-work (below),frthe page path it was sent from (stored for context). The first message of a thread must carryname(and optionallyemail, 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 hrefs);
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.