236 lines
13 KiB
Markdown
236 lines
13 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_label` — which doubles as the on/off
|
||
switch — `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; "Contact us" appears only as the inert preview's placeholder when
|
||
nothing is configured). `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 (no site label configured) the tag renders nothing
|
||
at all.
|
||
|
||
## Site settings and localization
|
||
|
||
The site editor's messaging row holds the feature's configuration
|
||
(`/_api/settings`): `contact_label` — the {contact:} buttons' label and
|
||
the visitor dialog's title, doubling as the on/off switch (empty = 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: the recipient is addressed `Brand <email>`, the link
|
||
text the bare address, the subject prefilled) and `contact_subject`
|
||
(defaults to the label). Saves re-render the page in place, so button
|
||
and dialog strings update without a reload.
|
||
|
||
The two strings localize like titles: translations live in the
|
||
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
|
||
render time) 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 row: the flag selector (the article editor's LangSelect)
|
||
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`, absent
|
||
entirely when disabled — pagerite.js reads that as "visitors get no
|
||
icon"); `contact-main.js` re-reads them on every open and on page swaps,
|
||
so a language switch or a settings save is reflected immediately. The
|
||
email address itself is never in the config: it is revealed over the
|
||
messaging socket only after the proof of work is solved (and only when
|
||
configured).
|
||
|
||
## 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, pow}` — a solved proof-of-work, submitted as soon as the
|
||
background solve finishes (below); grants the message/upload quota,
|
||
reveals the site's contact email and marks the visit's analytics trail.
|
||
- `{token, msg, fr, name?, email?}` — append a message; `msg` is the
|
||
Markdown-subset source, `fr` the page path it was sent from (stored
|
||
for context). Spends one quota; with none left it is answered with a
|
||
fresh `required` challenge instead, which the client solves, re-proves
|
||
and retries the message once. 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 an empty quota and should be re-sent once re-proven); `{type: "pow",
|
||
quota}` — a submitted proof was accepted, this many messages/uploads it
|
||
grants; `{type: "contact-email", email}` — the site's contact email,
|
||
revealed only after a solve; or `{type: "contact-error", detail}`.
|
||
|
||
### Proof of work (anti-spam)
|
||
|
||
Sends and uploads draw from a quota granted by easypow (PBKDF2
|
||
challenges, ~5 s solving cost, 10 actions per solve). The server hands a
|
||
fresh single-use challenge on registration/resync and whenever the quota
|
||
runs out; 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) and submits the solution as
|
||
soon as it is ready — typically long before the user finishes writing,
|
||
so sends just work. Solutions are single-use and challenges are a
|
||
per-connection local in the `/_ws` handler; the granted quota lives in
|
||
RAM keyed by the token hash (single worker), shared with the HTTP upload
|
||
route and kept across reconnects. A validated proof also reveals the
|
||
site's contact email (the address never appears in pages — scrapers
|
||
cannot lift it without solving) and records the visit's contact trail
|
||
marker in analytics (a panel opened without a solve leaves no trace).
|
||
|
||
## 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 spend the same PoW
|
||
quota as messages (a solved challenge covers both; the gates are a
|
||
session-shaped `x-contact-token` header, a 20 MB size cap, and the
|
||
quota itself) — an upload answered 429 "pow" makes the client re-prove
|
||
and retry once. Every upload also stays *pending* per session (10
|
||
un-sent files cap, answered 429 — not 403, which could trip auth
|
||
flows): 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).
|