Files
pagerite/docs/messages.md
T
LeoVasanko 106712a359 contact messages: document the settings, localization and toggle
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.
2026-09-28 06:35:55 +00:00

12 KiB
Raw Blame History

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 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 the panels' own UI strings (composer hints, "you", inbox labels) are English-only — the configurable site strings (label, subject) do localize (above).