docs/messages.md gains the site-settings/localization section (enabled toggle, mailto link, chunk-keyed string translations and their manual override UI); docs/localization.md notes the site strings riding the title/nav pipeline; AGENTS.md's i18n/translate bullets match.
217 lines
12 KiB
Markdown
217 lines
12 KiB
Markdown
# Contact messages
|
||
|
||
Visitor→admin messaging built into the site: a `{contact: Label}` directive
|
||
in article Markdown renders a button that opens the messaging overlay, and
|
||
the corner ✉️ icon (top right, for everyone) opens it too — the visitor's
|
||
own thread for visitors, the admin inbox for editors. Messages are stored
|
||
in the kanta database. 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 admin unread
|
||
count and the `/_contact/files` upload route.
|
||
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`)
|
||
and the site settings (`contact_enabled`, `contact_label`,
|
||
`contact_email`, `contact_subject`).
|
||
- `pagerite/tracking.py` — the sockets the protocol rides (see below).
|
||
- `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin
|
||
icon badge seed — the probe runs on every page load, no polling) and the
|
||
contact configuration (with `?lang=` overrides for the label/subject).
|
||
- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`), the
|
||
localized contact strings (`_contact_config`) and the on-demand bundle's
|
||
asset config.
|
||
- `frontend/src/contact-main.js` + `ContactOverlay.vue` — the overlay entry
|
||
and shell (paskia's shared blur backdrop, centered dialog, full-screen on
|
||
mobile), imported on demand by `pagerite.js` on icon/trigger clicks.
|
||
- `frontend/src/ContactPanel.vue` — the visitor thread content.
|
||
- `frontend/src/MessagesView.vue` — the admin inbox content (thread list,
|
||
replies, read marking); connects its admin-gated socket only while open.
|
||
- `frontend/src/Composer.vue` + `richtext.js` — the shared composer and its
|
||
DOM→Markdown serializer.
|
||
|
||
## Trigger
|
||
|
||
A lone `{contact: Label}` line in article Markdown (the block-directive
|
||
machinery of `markdown.py`) renders
|
||
`<button class="contact-trigger">Label</button>`. An empty label falls
|
||
back to the configured site label (`Data.contact_label`, localized —
|
||
below), ultimately the built-in "Contact us". `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. A per-button 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). When
|
||
messaging is disabled the tag renders nothing at all.
|
||
|
||
## Site settings and localization
|
||
|
||
The site editor's messaging block holds the feature's configuration
|
||
(`/_api/settings`): `contact_enabled` (off = no trigger buttons, no
|
||
visitor corner icon, sends rejected; admins keep the inbox for existing
|
||
threads), `contact_email` (a mailto link in the visitor dialog, its
|
||
subject prefilled) and the `contact_label`/`contact_subject` strings
|
||
(the label doubles as the dialog's title and the buttons' default; the
|
||
subject defaults to the label).
|
||
|
||
The two strings localize like titles: translations live in the
|
||
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
|
||
render time — the *effective* label, so the built-in default localizes
|
||
too) and the translator service picks them up automatically
|
||
(`pending_items` emits them as title-kind fragments; nav-mode jobs carry
|
||
them as extra top-level list items). Manual overrides are edited in the
|
||
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.
|
||
|
||
## 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`) and the newest admin reply's timestamp
|
||
(`pagerite-contact-lastadmin`) drive the unread marker (a periodic
|
||
scale-and-shake nudge animation, `has-unread` in pagerite.css) on trigger
|
||
buttons and the corner ✉️ icon when an admin reply arrives while the panel
|
||
is closed — including on a fresh page load before any socket activity
|
||
(pagerite.js reads the pair directly; the bundle keeps it live once
|
||
loaded).
|
||
|
||
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
|
||
|
||
Editors open the inbox from the same ✉️ corner icon (the trigger buttons
|
||
always open the visitor side, so both can be tested side by side): the
|
||
same overlay, wider to fit the thread list, served by the existing
|
||
`/_api/ws/analytics` socket (admin-gated like all of `/_api`) which
|
||
`MessagesView` connects only while the overlay is open. 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. The icon's
|
||
unread marker (any unanswered thread) is seeded by the `/_api/settings`
|
||
probe's `contact_unread` on every page load and kept live by the inbox
|
||
while it is open.
|
||
|
||
## 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 the panels' own UI strings (composer
|
||
hints, "you", inbox labels) are English-only — the *configurable* site
|
||
strings (label, subject) do localize (above).
|