diff --git a/AGENTS.md b/AGENTS.md index 0f41a68..a01be2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_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). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and gates sends with a per-connection single-use easypow challenge, 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 (PoW validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). + - `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 (PoW validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), the `/_m` admin inbox page route, and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). - `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 ``, 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 `` and the sitemap, recorded in analytics. - `data.py` — msgspec Structs for the kanta database. @@ -31,10 +31,10 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `frontend/src/` — Vue editor and public-page JS entries. - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). + - `messages-main.js` + `MessagesView.vue` — admin contact inbox page at `/_m` (docs/messages.md): thread list, replies and read marking over `/_api/ws/analytics`; owns its socket lifecycle like AnalyticsView. - `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`). diff --git a/docs/messages.md b/docs/messages.md index a029c15..2986397 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -2,24 +2,24 @@ 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`. +are stored in the kanta database and answered from the admin inbox on `/_m`. 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. + thread store ops, wire payloads, both socket protocols, the `/_m` inbox + page route 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. +- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`), the + `/_m` page render and the on-demand bundles' 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`). +- `frontend/src/messages-main.js` + `MessagesView.vue` — the admin inbox + page at `/_m` (own pens-row icon, own socket lifecycle). ## Trigger @@ -78,16 +78,18 @@ solves and retries once. Challenges are a per-connection local in the ## 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 +Admins watch the inbox on its own page, **`/_m`** (✉️ icon in the pens +row), 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 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 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. +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. ## Message format and the XSS model @@ -151,7 +153,8 @@ 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) and `page`. +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`). diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index fe2fa2f..820b1e9 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -2,9 +2,10 @@ // Analytics viewer rendered as a normal page inside #main. Receives live // analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and // renders totals, smoothed visit/views curves, a transition map, and recent -// visit/crawler tables. The same socket serves the contact inbox (the -// "messages" tab, MessagesView): visitor threads arrive with the snapshot -// and replies go back down it (docs/messages.md). +// visit/crawler tables. The same socket also carries the contact-messaging +// protocol (typed frames) for the admin inbox — that lives on its own /_m +// page now (MessagesView.vue, docs/messages.md), so typed frames are ignored +// here. // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { fetchJson } from 'paskia' @@ -33,7 +34,6 @@ import VisitorCharts from './VisitorCharts.vue' import { VIEW_W } from './analytics/chart.js' import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import ConnNote from './ConnNote.vue' -import MessagesView from './MessagesView.vue' // Same centering margin as the charts, so the totals row's left edge // aligns with the chart svg above the natural width. @@ -73,28 +73,6 @@ const hashRange = location.hash.slice(1) const range = ref(RANGES[hashRange] ? hashRange : 'week') let rangePinned = Boolean(RANGES[hashRange]) -// The contact inbox rides the same socket (docs/messages.md): frames with -// a `type` field are messaging payloads, the type-less snapshot is -// analytics. The header switches between the two views. -const tab = ref('analytics') -const threads = ref([]) -const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread ? 1 : 0), 0)) - -function lastT(t) { - return t.messages.length ? t.messages[t.messages.length - 1].t : t.created -} - -function upsertThread(thread) { - const rest = threads.value.filter((t) => t.id !== thread.id) - threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a))) -} - -function wsSend(obj) { - if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)) -} -function onReply({ id, text }) { wsSend({ type: 'reply', id, text }) } -function onRead(id) { wsSend({ type: 'read', id }) } - function connect() { if (ws || !mounted) return conn.value = 'connecting' @@ -110,20 +88,9 @@ function connect() { ws.onmessage = (event) => { try { const msg = JSON.parse(event.data) - // Contact-messaging frames carry a type field (docs/messages.md); - // the type-less snapshot is analytics data. - if (msg?.type === 'threads') { - threads.value = msg.threads - return - } - if (msg?.type === 'thread') { - upsertThread(msg) - return - } - if (msg?.type === 'contact-error') { - console.warn('contact:', msg.detail) - return - } + // Contact-messaging frames (docs/messages.md) carry a type field and + // are for the /_m inbox; the type-less snapshot is analytics data. + if (msg?.type) return data.value = msg if (!rangePinned) { rangePinned = true @@ -340,15 +307,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c

Analytics

- -
- -
+

⚠️ {{ error }}

loading…