Files
pagerite/docs/messages.md
T
LeoVasanko 6b34719397 contact messages: inert .file attachment blobs, universal forced download
Rework attachment storage and serving per the refined separation
scheme:

- Non-media uploads are stored as {hash}.file blobs (store_attachment)
  — the original extension never lands on the filesystem, so the store
  directory holds nothing double-click-executable. The original name
  lives only in the URL: /_f/{hash}/{original-name}. The .file suffix
  itself is rejected in URLs.
- Serving is strictly separated: /_f/{name} serves media only (hash
  with or without extension, .orig. still blocked) and 404s .file
  names; /_f/{name}/{filename} serves only {name}.file blobs, never
  media, never by hash alone.
- Every /_f/ response now carries bare 'content-disposition:
 attachment' (the URL's last segment already names the download) and
  x-content-type-options: nosniff — media included. Subresource
  fetches (<img>, <video>, favicons, the zoom viewer) ignore the
  disposition, so inline media is unaffected, while a direct
  navigation can never render or sniff a store file into something
  executable in the site's origin. The mime-based script-carrier
  forcing and the header filename are gone (subsumed).
- Message uploads treat SVG as media (rasterized derivatives; an
  as-is-served .svg is script-inert inside <img>), and undecodable
  fake images fall through to the .file attachment path.
2026-09-26 03:35:08 +00:00

7.3 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 a chat-style panel; messages are stored in the kanta database and answered from the admin inbox on /_a. 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 and the /_contact/files upload route.
  • pagerite/data.py — Data.contact (ContactThread/ContactMessage).
  • pagerite/tracking.py — the sockets the protocol rides (see below).
  • pagerite/views.py — the {contact:} directive (_contact_tag) and the on-demand bundle's asset config.
  • frontend/src/contact-main.js + ContactPanel.vue — the visitor panel, imported on demand by pagerite.js on trigger clicks.
  • frontend/src/Composer.vue + richtext.js — the shared composer and its DOM→Markdown serializer.
  • frontend/src/MessagesView.vue — the admin inbox (the messages tab of AnalyticsView.vue on /_a).

Trigger

A lone {contact: Label} line in article Markdown (the block-directive machinery of markdown.py) renders <button class="contact-trigger">Label</button> ("Contact us" when the label is empty). 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. The 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).

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) drives the unread dot on trigger buttons when an admin reply arrives while the panel is closed.

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, fr} — append a message; msg is the Markdown-subset source, fr the page path it was sent from (stored for context).

Frames from the server: {type: "thread", id, created, unread, messages: [{id, t, admin, html, page}]} — a full thread snapshot (replace/upsert by id), or {type: "contact-error", detail}.

Admin side

Admins watch the inbox as the messages tab on /_a, served by the existing /_api/ws/analytics socket (admin-gated like all of /_api). Analytics payloads keep their type-less snapshot shape; messaging frames always carry a type field, and that is how the client tells them apart. 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 are authored with the SSO remote-user name 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.

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. Gates today: a session-shaped x-contact-token header and a 20 MB size cap. Admin uploads from the same composer go to the admin-gated /_api/files instead. Files uploaded but never referenced by a sent message currently linger (no orphan 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, 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) and page. Appends are single transactions (contact / contact:reply / contact:read).

Limits and deliberate gaps (MVP)

  • Message source ≤ 8000 chars; uploads ≤ 20 MB; payloads cap at 100 threads × last 200 messages.
  • No proof-of-work yet: the send and upload paths are the two seams where the PoW stamp plugs in (challenge on token registration, verify on msg/upload; one stamp may cover several messages).
  • No thread moderation (delete/block), no orphan-upload GC, panel strings are English-only, and the admin inbox lists threads by token-hash prefix rather than enriched visitor identity.