The preview registered the {contact:} directive unconditionally, so with
no configured label it showed the button with the 'Contact us'
placeholder while the served page renders nothing. Mirror the page
logic in the preview render path.
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" is the last-resort fallback when neither is set).
|
||
`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, in the editor preview just as on the served page.
|
||
|
||
## 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).
|