contact messages: docs
docs/messages.md covers the trigger, both socket protocols, the storage model, the XSS stance (grammar restriction via a strict renderer, never HTML sanitization), uploads, and the MVP gaps (PoW seams, moderation, orphan GC, i18n). AGENTS.md and frontend-runtime.md learn the new module, bundles and asset keys.
This commit is contained in:
1 parent
bbb11bf17e
commit
975ebfe5e7
3 files changed
+130
-3
No files matched your search
@@ -10,11 +10,12 @@ Please instead ask the user to see from dev tools what you need, e.g. to look up
|
||||
Pagerite is a CMS. See `docs` for the full design and implementation details. Key files for code changes:
|
||||
|
||||
- `pagerite/` — Python backend package (hatchling build target).
|
||||
- `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/files routers, then `frontend.route(app, "/")`, then the pages catch-all last).
|
||||
- `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/messages/files routers, then `frontend.route(app, "/")`, then the pages catch-all last).
|
||||
- `state.py` — shared core, no routes: env-derived site constants, `data`/`kanta`, `analytics_store`, the fastapi-vue `frontend`, the render cache (`_html_response`, `_invalidate_pages`), the translator `dispatcher`, slug helpers, `@kanta.bootstrap` hooks.
|
||||
- `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, favicon settings).
|
||||
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
|
||||
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md).
|
||||
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames, the analytics socket the admin inbox.
|
||||
- `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols, and the public `/_contact/files` upload route.
|
||||
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via `<link rel="alternate" type="text/markdown">`, and llms.txt links point at the `.md` URLs.
|
||||
- `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `<head>` and the sitemap, recorded in analytics.
|
||||
- `data.py` — msgspec Structs for the kanta database.
|
||||
@@ -31,6 +32,9 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
||||
- `main.js` — Vue editor app entry.
|
||||
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
||||
- `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown).
|
||||
- `contact-main.js` + `ContactPanel.vue` — public contact panel (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button is clicked; rides the `/_ws` activity socket through pagerite.js's bridge (register/send, shared queue and reconnect pacing) — no extra connection.
|
||||
- `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly.
|
||||
- `MessagesView.vue` — the admin contact inbox (messages tab of `AnalyticsView.vue` on `/_a`): thread list, replies and read marking over `/_api/ws/analytics`.
|
||||
- `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state.
|
||||
- `pagerite.js` — public page entry.
|
||||
- `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`).
|
||||
|
||||
@@ -14,7 +14,7 @@ It first probes `GET /auth/api/settings` to detect whether Paskia SSO is availab
|
||||
|
||||
The editor/analytics components (everything except pagerite.js and main.js) make their `/_api` calls through paskia-js's `apiFetch`/`apiJson` instead of plain `fetch`: when a call gets a 401/403 carrying an `auth.iframe` hint (expired session), the login dialog opens in place and the request retries transparently after authentication. pagerite.js uses `apiJson` only for the task-checkbox toggle (ticking a box is an explicit edit attempt, so a login dialog is welcome there; any failure — including a cancelled login — reverts the checkbox) and `fetchJson` for its auth probes (plain fetch with JSON handling and errors on non-OK, never a dialog); page-cache and navigation fetches stay on plain `fetch`, so anonymous visitors never get a login popup uninvited.
|
||||
|
||||
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
|
||||
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src`/`pagerite:contact-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, page transition, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened; the langselect and contact bundles load on demand the same way, on multi-language pages and `{contact:}` trigger clicks. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
|
||||
|
||||
## `assets/`
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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: raster images through
|
||||
the usual AVIF/WebP/JPEG derivative pipeline, a small allowlist of document
|
||||
types (pdf/txt/md/csv/zip) as-is. **SVG and HTML are excluded** — script
|
||||
carriers. Gates today: a session-shaped `x-contact-token` header, a 20 MB
|
||||
size cap and the type allowlist. 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.
|
||||
Reference in new issue
Block a user