Files
pagerite/docs/messages.md
T
LeoVasanko d3c89e8c66 Visitor contact messaging: in site communication system with email readiness (#3)
- `{contact}` or `{contact: Label}` to add a button on article, additional access on top right corner letter emoji
- Article button styling (with custom shape for Nitro theme)
- Visitor contact panel: live thread over the activity socket, rich composer (formatting,/file uploads), PoW anti-spam gating, contact email reveal after proof
- Admin inbox in the same overlay: thread list, replies, read marking, unread badge; background watch keeps badge and notifications live
- Messaging site settings (button label, email subject, our email) with per-language overrides and auto-translation support
- Browser notifications for new messages on both sides, with click-through to the new content
- Close conversation: archives the thread for admins, starts visitors fresh

Reviewed-on: https://git.zi.fi/LeoVasanko/pagerite/pulls/3
2026-09-29 03:01:00 +00:00

265 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, `contact_mailserver`).
- `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). `contact_mailserver` is a mailbridge URL
(`smtp://user:pass@host:port`, a bare `host[:port]`, or a backend URL
like `sendgrid:API-KEY`) reserved for email notifications — the actual
sending is not implemented yet. Saves re-render the page in place, so
button and dialog strings update without a reload.
Both sides also get browser notifications (the Notification API,
`frontend/src/notify.js`): permission is asked at one entry point only —
right after the first message of a fresh thread (a denied permission is
permanent in the browser and never re-asked; a dismissed prompt comes up
again on the next fresh thread). With permission granted the visitor is
notified of new admin replies while not viewing the thread and the admin
of new visitor messages; clicking a notification focuses the tab and
opens the thread/inbox at what was new.
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). A live conversation is also flagged
(`pagerite-contact-session`): while set, page loads register the session
right away (no panel click needed), so a granted notification permission
reports new replies immediately.
**Closing a conversation**: the panel's ⟲ button sends `{token, close}` —
the server marks the thread `closed` (archived in the admin inbox,
excluded from the unread count, replies rejected, the token's pushes
stopped) — then wipes the local session keys (token, seen, lastadmin,
session flag) and rotates the token, so the next message starts an
all-new thread.
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.
- `{token, close}` — end the conversation (archived for admins; above).
Frames **from** the server: `{type: "thread", id, created, unread,
closed, 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 while the overlay is open — and a background
watch in `contact-main.js` (`watchAdmin`) connects at every page load
while messaging is enabled, keeping the icon badge live and notifying
new visitor messages without waiting for the inbox (MessagesView owns
notifications while 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, archived
last); 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, archived ones excluded) is seeded by the `/_api/settings`
probe's `contact_unread` on every page load and kept live by the watch
and the inbox.
## 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).