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.
140 lines
7.3 KiB
Markdown
140 lines
7.3 KiB
Markdown
# 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 `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. 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.
|