From 204c6ab608efab5daf02e5b4a793cb0b2592d683 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 25 Sep 2026 03:23:53 +0000 Subject: [PATCH 01/58] Transition map: always show the site structure, omit content-less index pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node inclusion no longer depends on views in the window: every page from /_api/pages is shown (the front page used to vanish when quiet). Omission follows the navigation instead β€” index pages without content of their own (category labels and empty pages, the nodes nav links bypass to the first child) are omitted, children promoted, first visible child taking the slot. Deleted pages named by traffic are inserted as ghosts where they would have been, materializing missing ancestors as content-less category lanes. /_api/pages gains the "empty" flag (not node.chunks) for the nav notion; has_content (chunks is not None) stays for the structure editor's delete affordance. --- docs/analytics.md | 11 +- frontend/src/StructureTree.vue | 3 +- frontend/src/analytics/transitions.js | 166 ++++++++++++++------------ pagerite/api.py | 8 ++ 4 files changed, 109 insertions(+), 79 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 3e4284c..59ec41e 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -416,8 +416,15 @@ by a top lane labeled 🏠︎ beside the home pill (50% thicker than the branch lanes, its label font and guide offset scaled along), each item's subtree fanning out below it in menu order along a large-radius circular arc that leaves heading -straight down and gradually bends right, index pages without views omitted -and their children promoted in their place. The submenu structure is drawn +straight down and gradually bends right. Every page of the tree is shown +whether or not it had views in the window β€” only index pages without +content of their own (category labels and empty pages β€” the ones the +navigation itself bypasses in favor of the first child) are omitted, +their children +promoted in their place, the first visible child taking the slot. Paths +the traffic names but the tree no longer has (deleted pages) are +inserted as ghosts where they would have been, missing ancestors +materialized as category lanes. The submenu structure is drawn as wide branch lanes: one per path prefix with at least two visible nodes, running behind the branch's node pills as circle arcs concentric with the fan (parent levels one radius step outward, so all lanes of a diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue index 4d47e82..882f51f 100644 --- a/frontend/src/StructureTree.vue +++ b/frontend/src/StructureTree.vue @@ -1,7 +1,8 @@ diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 1a78ec6..83746f6 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -38,7 +38,7 @@ import { flagFor, langName, langSort } from './langs' import { editorLang, pagePrimary } from './editorLang' import LangSelect from './LangSelect.vue' import ConnNote from './ConnNote.vue' -import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect' +import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import { dropPageCache, loadPlain } from './swapdoc' const props = defineProps({ @@ -75,13 +75,14 @@ let sessionDoc = null // input until the page's document has arrived: typing before the accept // would be clobbered by it. While merely DISconnected the editor stays // editable β€” text stashes and pending saves flush on reconnect. -const conn = ref('connecting') // connecting | open | waiting +const conn = ref('connecting') // connecting | open | waiting | login const retryIn = ref(0) const docReady = ref(false) const editable = new Compartment() const connNote = computed(() => conn.value === 'connecting' ? 'connecting to the server…' : conn.value === 'waiting' ? `connection lost β€” reconnecting in ~${retryIn.value} s…` + : conn.value === 'login' ? 'session expired β€” log in to continue editing…' : docReady.value ? '' : 'loading the page…', ) @@ -96,6 +97,9 @@ let connectWatchdog = null // retries trip the browser's WebSocket throttling (sockets stuck "pending" // for minutes), which is what kept hollowing out the editor. const reconnects = reconnectPolicy() +// Bumped by every connect()/unmount: the auth probe in onclose is async, so +// a stale tail checks it did not get superseded before acting. +let connectEpoch = 0 const dirty = ref(false) // unsaved text exists (drives the πŸ’Ύ button) let syncingScroll = false @@ -990,6 +994,7 @@ function consumePendingLine() { } function connect() { + connectEpoch++ clearTimeout(reconnectTimer) conn.value = 'connecting' if (ws) { @@ -1021,19 +1026,40 @@ function connect() { openPath(normPath(props.pagePath)) } } - ws.onclose = (ev) => { + ws.onclose = async (ev) => { // 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or // the connecting watchdog fired); worth seeing since a dead socket // before the first doc bricks the editor until a retry lands one. - // The wait is the policy's: doubling backoff with jitter (./reconnect). console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '') + const gen = connectEpoch const wait = reconnects.closed() - retryIn.value = Math.max(1, Math.round(wait / 1000)) - conn.value = 'waiting' - reconnectTimer = setTimeout(connect, wait) + // An expired session fails the handshake like a dead network, but blind + // retries loop forever β€” the WS API cannot open the login dialog. Probe + // /_api (./reconnect's authReconnect): a lost session runs the paskia + // login flow and reconnects the moment it completes; a declined login + // holds at the 'login' note (a later login anywhere in the page, or a + // save attempt, reconnects); only transient failures take the backoff. + const outcome = await authReconnect(() => { conn.value = 'login' }) + if (gen !== connectEpoch) return + if (outcome === 'auth') { + connect() + } else if (outcome === 'abort') { + conn.value = 'login' + } else { + retryIn.value = Math.max(1, Math.round(wait / 1000)) + conn.value = 'waiting' + reconnectTimer = setTimeout(connect, wait) + } } } +// A login completed anywhere in the page (the banner's πŸ”‘ button, another +// panel's flow) reconnects a waiting editor at once instead of waiting out +// the backoff. paskia's auth iframe announces it as a window message. +function onAuthMessage(ev) { + if (ev.data?.type === 'auth-success' && conn.value !== 'open') connect() +} + onMounted(() => { // The first connection takes a staggered slot (see ./reconnect): page // load opens several sockets at once, and the burst trips throttling. @@ -1090,9 +1116,11 @@ onMounted(() => { addEventListener('pagerite:editor-shown', onEditorShown) // A section pen clicked while the page editor is already open. addEventListener('pagerite:edit-section', consumePendingLine) + addEventListener('message', onAuthMessage) }) onUnmounted(() => { + connectEpoch++ // invalidate a pending onclose auth probe clearTimeout(reconnectTimer) clearTimeout(connectWatchdog) if (ws) { @@ -1106,6 +1134,7 @@ onUnmounted(() => { removeEventListener('keydown', onKeydown) removeEventListener('pagerite:editor-shown', onEditorShown) removeEventListener('pagerite:edit-section', consumePendingLine) + removeEventListener('message', onAuthMessage) }) diff --git a/frontend/src/reconnect.js b/frontend/src/reconnect.js index 2656204..be9821b 100644 --- a/frontend/src/reconnect.js +++ b/frontend/src/reconnect.js @@ -10,6 +10,8 @@ // (a few seconds, doubling to half a minute), reset only after a // connection stayed open long enough to count as healthy. A socket // that closes right after opening must NOT reset the backoff. +import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia' + export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) { let delay = min let openedAt = 0 @@ -54,3 +56,36 @@ export function watchConnecting(ws, label) { } }, 10_000) } + +// Auth-aware close handling for the admin sockets (/_api/...). An expired +// session fails the WS handshake exactly like a dead network, but retrying +// the socket loops forever β€” the WS API cannot open the login dialog. So +// probe /_api first (plain fetchJson, which never pops a dialog) and: +// +// 'retry' β€” the session is fine or the trouble is a network/server one: +// take the policy's paced backoff. +// 'auth' β€” the session was gone, so the paskia login flow ran (apiJson +// pops the auth iframe and retries through it) and completed: +// reconnect at once, no backoff. +// 'abort' β€” the login was declined or the proxy offers no paskia flow: +// stop retrying; the caller decides what its UI does next. +// +// onLogin fires when the login flow starts, so the caller can say so. +export async function authReconnect(onLogin) { + try { + await fetchJson('/_api/settings') + return 'retry' + } catch (e) { + if (!(e instanceof ApiError && (e.status === 401 || e.status === 403))) return 'retry' + } + onLogin?.() + try { + await apiJson('/_api/settings') + return 'auth' + } catch (e) { + return e instanceof NetworkError + || (e instanceof ApiError && e.status !== 401 && e.status !== 403) + ? 'retry' + : 'abort' + } +} -- 2.56.0 From fb2914d8538de9e4829079c7076d63460ea57d1e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 25 Sep 2026 18:22:43 +0000 Subject: [PATCH 05/58] Don't accrue reading time across sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inactivity cutoff timer cannot fire while the computer is asleep, and performance.now() keeps running through sleep on some platforms, so after a wake the whole slept span could be credited as reading time (inflating min/visit stats). A gap longer than the inactivity timeout now closes the span at its intended cutoff, and the cutoff timer clamps its accumulation to its scheduled fire time β€” covering both event-first and timer-first wake orderings. --- docs/analytics.md | 6 +++++- frontend/src/pagerite.js | 24 +++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 36bad59..dc20e2d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -119,7 +119,11 @@ transition applies the new `` only after the ping goes out) and follows the session, so the activity of a visit stays tied together, and while the user is active the accumulated reading time is flushed every few seconds: the times are incremental, so a disconnection simply leaves the -last reported time in place (no close beacon). After 5 minutes without +last reported time in place (no close beacon). A sleeping computer accrues +no reading time: the inactivity-cutoff timer cannot fire while asleep (and +`performance.now()` keeps running through sleep on some platforms), so a +gap longer than the timeout closes the span at its intended cutoff, and a +late-firing cutoff timer clamps its accumulation the same way. After 5 minutes without any activity the client closes the socket itself β€” a sleeping browser tab would lose it anyway β€” and the next activity reconnects; reconnects are attempted only on user activity, with an exponential backoff between diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 2f4c2ec..770b117 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -684,16 +684,34 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; let reading = true; let readInactivityTimer = null; let idleTimer = null; + let lastActivityAt = performance.now(); function markReadActivity() { - if (!reading) { + const t = performance.now(); + if (reading) { + // A gap longer than the inactivity timeout means the cutoff timer + // could not run on time (the computer slept, or the tab was throttled + // hard): close the span at its intended cutoff β€” the gap itself was + // not reading time, and performance.now() keeps running through sleep + // on some platforms. + const cutoff = lastActivityAt + INACTIVE_MS; + if (t > cutoff) { + readElapsed += Math.max(0, cutoff - readStart); + readStart = t; + } + } else { reading = true; - readStart = performance.now(); + readStart = t; } + lastActivityAt = t; clearTimeout(readInactivityTimer); + // Same sleep guard, other ordering: when this timer itself fires late + // (waking from sleep before any user event), clamp the accumulation to + // its intended fire time. + const stopAt = t + INACTIVE_MS; readInactivityTimer = setTimeout(() => { if (reading) { - readElapsed += performance.now() - readStart; + readElapsed += Math.max(0, Math.min(performance.now(), stopAt) - readStart); reading = false; } }, INACTIVE_MS); -- 2.56.0 From 3c9ef04ffdb88af4e61e4d7636930440b2ea859d Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 25 Sep 2026 20:23:20 +0000 Subject: [PATCH 06/58] Transition map beads: connector-colored elongated pulses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beads now take their connector's color β€” accent for internal links, plain text color for external ones (matching the tconn-exit styling) β€” and render as an elongated pulse along the direction of travel instead of a plain dot: a rounded capsule with a gradient-faded tail, rotated to the edge's angle (cached per flow). Flow objects carry an 'external' tag from buildFlows; the gradients live in the svg defs with stop colors resolved from CSS so theme variables apply. --- docs/analytics.md | 5 +++- frontend/src/TransitionGraph.vue | 42 +++++++++++++++++++++++---- frontend/src/analytics/transitions.js | 17 +++++++---- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index dc20e2d..4e563e5 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -465,7 +465,10 @@ flow along each edge, persisting across data reloads (emitters are keyed per edge direction and beads tracked by progress, so an unrelated count change never reshuffles them), emitted at a rate linearly proportional to the directional count with no in-flight limit, opposing directions -offset onto parallel lanes. External sources and exits whose connectors are +offset onto parallel lanes; each bead renders as an elongated pulse +(gradient-faded tail) along its direction of travel, colored like its +connector β€” accent for internal edges, plain text color for external +ones. External sources and exits whose connectors are all culled by the width threshold are dropped from their rows themselves (the site's own page nodes always stay, connected or not). External sources show as a node row above the map: each visit is attributed to `utm_campaign`, then `utm_source`, then the diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index 4b4ceb6..f13c4af 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -13,6 +13,7 @@ import { TNODE_W, TNODE_H, BEAD_R, + BEAD_LEN, buildTransitionGraph, } from './analytics/transitions.js' @@ -145,7 +146,17 @@ const tick = (t) => { continue } const f = b.e.flow - out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p }) + // The pulse is elongated along the direction of travel (constant per + // flow) and colored like its connector (external edges use --text). + if (f.deg === undefined) { + f.deg = (Math.atan2(f.y2 - f.y1, f.x2 - f.x1) * 180) / Math.PI + } + out.push({ + x: f.x1 + (f.x2 - f.x1) * b.p, + y: f.y1 + (f.y2 - f.y1) * b.p, + deg: f.deg, + ext: f.external, + }) } beads.value = out rafId = requestAnimationFrame(tick) @@ -184,6 +195,21 @@ const countLabel = (n) =>
+ + + + + + + + + + + + +
${p.split(/\r\n|\r|\n/).map(escHtml).join('
')}
`) + .join('') + document.execCommand('insertHTML', false, html) +} + +function onDrop(ev) { + if (ev.dataTransfer?.files?.length) { + ev.preventDefault() + uploadFiles(ev.dataTransfer.files) + } +} + +async function uploadFiles(files) { + for (const file of files) { + if (file.size > 20 * 1024 * 1024) { + console.warn('contact upload skipped (over 20 MB):', file.name) + continue + } + uploading.value++ + try { + const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}` + const init = { method: 'PUT', body: file } + if (props.token) init.headers = { 'x-contact-token': props.token } + const r = await (props.token ? fetch(path, init) : apiFetch(path, init)) + if (!r.ok) throw new Error(`upload failed with status ${r.status}`) + insertUpload(await r.json()) + } catch (e) { + console.error('contact upload failed:', e) + } finally { + uploading.value-- + } + } +} + +// An uploaded file becomes an inline image or an attachment chip; both +// serialize back to /_f/ Markdown references on send. +function insertUpload({ path, image, name }) { + const html = image + ? `${escHtml(name)}` + : `πŸ“Ž ${escHtml(name)} ` + box.value?.focus() + document.execCommand('insertHTML', false, html) +} + +function send() { + const md = serializeRich(box.value) + if (!md || uploading.value) return + emit('send', md) + box.value.innerHTML = '' +} + +function onKeydown(ev) { + if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') send() +} + +defineExpose({ focus: () => box.value?.focus() }) + + + + + diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue new file mode 100644 index 0000000..4ea2625 --- /dev/null +++ b/frontend/src/ContactPanel.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index ab095bc..d0b5c45 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -878,6 +878,37 @@ article { opacity: 1; } +/* {contact: Label} directive: the button opening the visitor contact + panel (docs/messages.md). A standalone accent button in the article + flow; .has-unread (set by contact-main) marks a new admin reply. */ +.contact-trigger { + position: relative; + padding: 0.35em 1.1em; + font: inherit; + color: var(--accent); + background: none; + border: 1px solid var(--accent); + border-radius: 1.2em; + cursor: pointer; +} + +.contact-trigger:hover { + color: var(--bg); + background: var(--accent); +} + +.contact-trigger.has-unread::after { + content: ""; + position: absolute; + top: -0.25em; + right: -0.25em; + width: 0.6em; + height: 0.6em; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 0.15em var(--bg); +} + .edit-link { position: absolute; top: 0.2rem; diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js new file mode 100644 index 0000000..081e109 --- /dev/null +++ b/frontend/src/contact-main.js @@ -0,0 +1,74 @@ +// Contact panel entry: imported on demand by pagerite.js when a +// {contact:} trigger button is clicked. The panel rides the activity +// socket through the bridge pagerite.js hands over ({send, register}) β€” +// no extra connection is opened. The session token is a localStorage +// random; the server stores only its hash. +import { createApp, reactive } from 'vue' +import ContactPanel from './ContactPanel.vue' + +const TOKEN_KEY = 'pagerite-contact-token' +const SEEN_KEY = 'pagerite-contact-seen' + +let app = null +const state = reactive({ open: false, thread: null, unread: false }) + +function ensureToken() { + let token = localStorage.getItem(TOKEN_KEY) + if (!token) { + token = crypto.randomUUID() + localStorage.setItem(TOKEN_KEY, token) + } + return token +} + +function seenAt() { + return Number(localStorage.getItem(SEEN_KEY) || 0) +} + +function markSeen() { + localStorage.setItem(SEEN_KEY, String(Date.now())) + state.unread = false + refreshBadges() +} + +// A "new reply" dot on every contact trigger button while the panel is +// closed and the newest admin message is younger than the last look. +// Called on thread pushes and (from pagerite.js) after navigations, +// whose swap rebuilds the buttons. +export function refreshBadges() { + for (const btn of document.querySelectorAll('.contact-trigger')) { + btn.classList.toggle('has-unread', state.unread) + } +} + +function onThread(thread) { + state.thread = thread + const lastAdmin = [...thread.messages].reverse().find((m) => m.admin) + state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !state.open + if (state.open) markSeen() + else refreshBadges() +} + +export function open(bridge) { + if (!app) { + const token = ensureToken() + app = createApp(ContactPanel, { + state, + token, + onSend: (md) => bridge.send({ token, msg: md }), + onClose: () => { + state.open = false + refreshBadges() + }, + }) + const el = document.createElement('div') + el.id = 'contact-root' + document.body.append(el) + app.mount(el) + // Registration doubles as the resync: the server pushes the thread + // state back, and pagerite.js re-registers on every socket reconnect. + bridge.register(token, onThread) + } + state.open = true + markSeen() +} diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 770b117..c9b17cd 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -456,6 +456,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; // Editor-driven swaps don't go through load(): re-evaluate the // language selector from the fresh copy too. mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html")); + contactMod?.refreshBadges(); }); // Editors mutate site-wide state (theme, structure, headings, banners), @@ -629,6 +630,20 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; ws.onopen = () => { wsPolicy.opened(); for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg)); + // Re-registering resyncs the contact thread after a reconnect. + if (contactToken) ws.send(JSON.stringify({ token: contactToken })); + }; + ws.onmessage = (ev) => { + // Contact thread pushes ride this socket (docs/messages.md). + if (!contactListener) return; + let msg = null; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (msg?.type === "thread") contactListener(msg); + else if (msg?.type === "contact-error") console.warn("contact:", msg.detail); }; ws.onclose = () => { ws = null; @@ -869,6 +884,61 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; ping({ to: currentPath, lang: tag }); }); + // --- Contact panel ------------------------------------------------------ + // {contact: Label} directives in articles render .contact-trigger buttons. + // The panel bundle (Vue composer + thread view) loads on first click and + // rides the activity socket for realtime thread state β€” no extra + // connection (docs/messages.md). Registration re-sends on every socket + // (re)connect, and the server pushes the thread back as the resync. + let contactMod = null; + let contactToken = ""; + let contactListener = null; + const contactBridge = { + register(token, onThread) { + contactToken = token; + contactListener = onThread; + contactBridge.send({ token }); + }, + send(msg) { + msg.fr = currentPath; + activityWs(); + if (ws?.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(msg)); + return; + } catch { /* fall through to queueing */ } + } + wsQueue.push(msg); + }, + }; + + async function openContactPanel() { + try { + contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]); + for (const css of (assets["pagerite:contact-css"] || "").split(",")) { + if (css && !document.querySelector(`link[href="${css}"]`)) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = css; + link.dataset.pagerite = "contact-css"; + document.head.append(link); + } + } + contactMod.open(contactBridge); + } catch (e) { + console.error("contact panel load failed:", e); + } + } + + addEventListener("click", (ev) => { + const btn = ev.target.closest?.(".contact-trigger"); + if (!btn) return; + ev.preventDefault(); + // In the editor preview the button is inert. + if (document.body.classList.contains("editing")) return; + openContactPanel(); + }); + // --- Fetch navigation ------------------------------------------------ async function load(url, push = true, back = false) { // Navigating with the editor open keeps it open: the shell retargets to @@ -978,6 +1048,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; applyEffects(); mountAnalytics(doc); mountLangselect(doc); + // The swap rebuilt any contact-trigger buttons: re-apply the + // "new reply" dot to the fresh elements. + contactMod?.refreshBadges(); }; // Rotating cube page transition (styles injected as #pagerite-transition // from the selected design's transition.css, e.g. themes/cube/); diff --git a/frontend/src/richtext.js b/frontend/src/richtext.js new file mode 100644 index 0000000..044b497 --- /dev/null +++ b/frontend/src/richtext.js @@ -0,0 +1,101 @@ +// Serialize the constrained contenteditable composer DOM to the contact +// message Markdown subset (emphasis, links, lists, code, /_f/ images and +// file chips). The wire format is text: the server re-renders it with a +// strict, html-less MarkdownIt (pagerite/messages.py), so no user markup +// of any kind ever reaches a page. This serializer is for fidelity, not +// safety β€” anything it does not know is simply unwrapped. + +const ESCAPE_RE = /([\\`*_[\]])/g + +export function escapeMd(text) { + return text.replace(ESCAPE_RE, '\\$1') +} + +// One message's Markdown from the composer root's child nodes: each +// top-level node becomes a block, joined by blank lines. +export function serializeRich(root) { + const blocks = [] + for (const node of root.childNodes) block(node, blocks) + return blocks + .map((b) => b.trim()) + .filter((b) => b) + .join('\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function block(node, out) { + if (node.nodeType === Node.TEXT_NODE) { + out.push(escapeMd(node.textContent)) + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + switch (node.tagName) { + case 'UL': + out.push([...node.children].map((li) => `- ${inline(li).trim()}`).join('\n')) + break + case 'OL': + out.push([...node.children].map((li, i) => `${i + 1}. ${inline(li).trim()}`).join('\n')) + break + case 'BLOCKQUOTE': + out.push(inline(node).split('\n').map((l) => `> ${l}`).join('\n')) + break + case 'PRE': + out.push('```\n' + node.textContent + '\n```') + break + case 'IMG': + // Uploaded image (data-path set by the composer on insert). + if (node.dataset.path) out.push(`![${escapeMd(node.alt || 'image')}](${node.dataset.path})`) + break + case 'BR': + break + default: + // DIV, P and anything else: a paragraph of inline content. + out.push(inline(node)) + } +} + +function inline(node) { + let out = '' + for (const child of node.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + out += escapeMd(child.textContent) + continue + } + if (child.nodeType !== Node.ELEMENT_NODE) continue + switch (child.tagName) { + case 'B': + case 'STRONG': + out += `**${inline(child)}**` + break + case 'I': + case 'EM': + out += `*${inline(child)}*` + break + case 'CODE': + out += '`' + child.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`' + break + case 'BR': + out += '\n' + break + case 'A': { + const text = inline(child) + const href = child.getAttribute('href') || '' + out += /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text + break + } + case 'IMG': + if (child.dataset.path) out += `![${escapeMd(child.alt || 'image')}](${child.dataset.path})` + break + case 'SPAN': + // Attachment chip (data-path + data-name); other spans unwrap. + out += child.dataset.path && child.dataset.name + ? `[πŸ“Ž ${escapeMd(child.dataset.name)}](${child.dataset.path})` + : inline(child) + break + default: + out += inline(child) + } + } + return out +} -- 2.56.0 From bbb11bf17e29849168c610e39e6055ec0d5cf93c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 01:14:52 +0000 Subject: [PATCH 12/58] contact messages: admin inbox on /_a The analytics viewer gains a messages tab over the same socket: frames with a type field are inbox payloads (thread list on connect, live thread upserts), the type-less snapshot stays analytics. MessagesView shows the thread list with unread dots and the selected thread with the shared composer for replies; opening a thread marks it read server-side. The header badge counts unread threads. --- frontend/src/AnalyticsView.vue | 83 ++++++++++- frontend/src/MessagesView.vue | 250 +++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 frontend/src/MessagesView.vue diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 0b79ba0..fe2fa2f 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -2,7 +2,9 @@ // 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. Read-only. +// 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). // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { fetchJson } from 'paskia' @@ -31,6 +33,7 @@ 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. @@ -70,6 +73,28 @@ 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' @@ -84,7 +109,22 @@ function connect() { } ws.onmessage = (event) => { try { - data.value = JSON.parse(event.data) + 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 + } + data.value = msg if (!rangePinned) { rangePinned = true const starts = (data.value?.visits || []) @@ -300,7 +340,15 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c

Analytics

-
+ +

⚠️ {{ error }}

loading…

+
@@ -484,7 +535,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c margin-left: auto; } -.ranges button { +.ranges button, +.tabs button { padding: 0.2rem 0.7rem; font: inherit; font-size: 0.9rem; @@ -495,13 +547,32 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c cursor: pointer; } -.ranges button:hover { color: var(--text); } +.ranges button:hover, +.tabs button:hover { color: var(--text); } -.ranges button.active { +.ranges button.active, +.tabs button.active { color: var(--text); border-color: var(--accent); } +.tabs { + display: flex; + gap: 0.25rem; +} + +.tabs .badge { + display: inline-block; + min-width: 1.1em; + margin-left: 0.3em; + padding: 0 0.25em; + border-radius: 1em; + background: var(--accent); + color: var(--bg, Canvas); + font-size: 0.8em; + text-align: center; +} + .close { padding: 0 0.3rem; background: none; diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue new file mode 100644 index 0000000..4322466 --- /dev/null +++ b/frontend/src/MessagesView.vue @@ -0,0 +1,250 @@ + + + + + -- 2.56.0 From 975ebfe5e7fc4644b372b05af1920ae63358ac7c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 01:17:30 +0000 Subject: [PATCH 13/58] 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. --- AGENTS.md | 8 ++- docs/frontend-runtime.md | 2 +- docs/messages.md | 123 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 docs/messages.md diff --git a/AGENTS.md b/AGENTS.md index ddb5460..8ae252e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``, 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,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`). diff --git a/docs/frontend-runtime.md b/docs/frontend-runtime.md index 5c86b46..d0f56aa 100644 --- a/docs/frontend-runtime.md +++ b/docs/frontend-runtime.md @@ -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, `` 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 ` diff --git a/frontend/src/richtext.js b/frontend/src/richtext.js index 044b497..80475af 100644 --- a/frontend/src/richtext.js +++ b/frontend/src/richtext.js @@ -11,91 +11,109 @@ export function escapeMd(text) { return text.replace(ESCAPE_RE, '\\$1') } -// One message's Markdown from the composer root's child nodes: each -// top-level node becomes a block, joined by blank lines. +// Tags that open a new block at the composer's top level. Everything else +// there β€” text, B/I/A/SPAN formatting elements, inline images β€” is an +// inline run and joins the paragraph being accumulated: browsers happily +// put formatting directly under the editable root (a bolded segment of an +// undivided first line is a top-level ), and treating those as blocks +// would split the paragraph and drop the formatting. +const BLOCK_TAGS = new Set([ + 'DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE', + 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'TABLE', +]) + +// One message's Markdown from the composer root: inline runs become +// paragraphs, block elements become their own blocks, blank lines between. export function serializeRich(root) { const blocks = [] - for (const node of root.childNodes) block(node, blocks) - return blocks - .map((b) => b.trim()) - .filter((b) => b) - .join('\n\n') - .replace(/\n{3,}/g, '\n\n') - .trim() + let run = [] + const flush = () => { + const text = run.join('').trim() + run = [] + if (text) blocks.push(text) + } + for (const node of root.childNodes) { + if (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName)) { + flush() + block(node, blocks) + } else { + run.push(inline(node)) + } + } + flush() + return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() } function block(node, out) { - if (node.nodeType === Node.TEXT_NODE) { - out.push(escapeMd(node.textContent)) - return - } - if (node.nodeType !== Node.ELEMENT_NODE) return switch (node.tagName) { case 'UL': - out.push([...node.children].map((li) => `- ${inline(li).trim()}`).join('\n')) + out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n')) break case 'OL': - out.push([...node.children].map((li, i) => `${i + 1}. ${inline(li).trim()}`).join('\n')) + out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n')) break case 'BLOCKQUOTE': - out.push(inline(node).split('\n').map((l) => `> ${l}`).join('\n')) + out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n')) break case 'PRE': out.push('```\n' + node.textContent + '\n```') break - case 'IMG': - // Uploaded image (data-path set by the composer on insert). - if (node.dataset.path) out.push(`![${escapeMd(node.alt || 'image')}](${node.dataset.path})`) + case 'HR': break - case 'BR': - break - default: - // DIV, P and anything else: a paragraph of inline content. - out.push(inline(node)) + default: { + // DIV, P, headings: a paragraph of inline content. + const text = inlineChildren(node).trim() + if (text) out.push(text) + } } } +function inlineChildren(node) { + return [...node.childNodes].map(inline).join('') +} + function inline(node) { - let out = '' - for (const child of node.childNodes) { - if (child.nodeType === Node.TEXT_NODE) { - out += escapeMd(child.textContent) - continue + if (node.nodeType === Node.TEXT_NODE) return escapeMd(node.textContent) + if (node.nodeType !== Node.ELEMENT_NODE) return '' + switch (node.tagName) { + case 'B': + case 'STRONG': + return `**${inlineChildren(node)}**` + case 'I': + case 'EM': + return `*${inlineChildren(node)}*` + case 'CODE': + return '`' + node.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`' + case 'BR': + return '\n' + case 'A': { + const text = inlineChildren(node) + const href = node.getAttribute('href') || '' + return /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text } - if (child.nodeType !== Node.ELEMENT_NODE) continue - switch (child.tagName) { - case 'B': - case 'STRONG': - out += `**${inline(child)}**` - break - case 'I': - case 'EM': - out += `*${inline(child)}*` - break - case 'CODE': - out += '`' + child.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`' - break - case 'BR': - out += '\n' - break - case 'A': { - const text = inline(child) - const href = child.getAttribute('href') || '' - out += /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text - break + case 'IMG': + // Uploaded image (data-path set by the composer on insert). + return node.dataset.path ? `![${escapeMd(node.alt || 'image')}](${node.dataset.path})` : '' + case 'SPAN': + // Attachment chip (data-path + data-name); other spans unwrap. + if (node.dataset.path && node.dataset.name) { + return `[πŸ“Ž ${escapeMd(node.dataset.name)}](${node.dataset.path})` } - case 'IMG': - if (child.dataset.path) out += `![${escapeMd(child.alt || 'image')}](${child.dataset.path})` - break - case 'SPAN': - // Attachment chip (data-path + data-name); other spans unwrap. - out += child.dataset.path && child.dataset.name - ? `[πŸ“Ž ${escapeMd(child.dataset.name)}](${child.dataset.path})` - : inline(child) - break - default: - out += inline(child) - } + return wrapStyle(node, inlineChildren(node)) + default: + return wrapStyle(node, inlineChildren(node)) + } +} + +// Some browsers style formatting instead of tagging it (Firefox's +// execCommand produces ): keep span-level +// bold/italic alive into the Markdown. +function wrapStyle(node, text) { + if (!text.trim()) return text + const s = node.style + if (s) { + if (/bold|bolder|[6-9]00/.test(s.fontWeight)) text = `**${text}**` + if (s.fontStyle === 'italic' || s.fontStyle === 'oblique') text = `*${text}*` } - return out + return text } diff --git a/pagerite/messages.py b/pagerite/messages.py index f291280..f07ac21 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -302,11 +302,12 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None: # --- Uploads ----------------------------------------------------------------- #: Contact-panel uploads: raster images get the usual derivative pipeline, -#: a few document types are stored as-is. SVG and HTML are excluded on -#: purpose β€” script carriers have no place in a thread whose HTML must -#: stay markdown-it output only. +#: everything else is stored as-is and renders as a download chip β€” except +#: script carriers, which are refused outright: an attachment URL can +#: always be opened directly in the site's origin, where served HTML/JS/ +#: SVG/XML would execute. IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".heic", ".heif"} -FILE_EXTS = {".pdf", ".txt", ".md", ".csv", ".zip"} +BLOCKED_EXTS = {".svg", ".html", ".htm", ".xhtml", ".xht", ".js", ".mjs", ".xml"} UPLOAD_MAX_BYTES = 20 * 1024 * 1024 @@ -315,11 +316,12 @@ async def contact_upload(name: str, request: Request) -> dict: """Store a contact-panel upload in the content-addressed store. Public like the messaging socket (only /_api is gated): the caller - must present a session-shaped token, size and type are capped, and - abuse limiting is meant to ride on the proof-of-work stamp once - plugged in. Images get the usual AVIF/WebP/JPEG derivatives and are - referenced extension-less; other allowed types are stored as-is. - Returns the /_f/ path and whether the file is an inline-able image. + must present a session-shaped token, size is capped, script-carrying + types are refused, and abuse limiting is meant to ride on the + proof-of-work stamp once plugged in. Images get the usual + AVIF/WebP/JPEG derivatives and are referenced extension-less; any + other file is stored as-is for download. Returns the /_f/ path and + whether the file is an inline-able image. """ if not TOKEN_RE.fullmatch(request.headers.get("x-contact-token", "")): raise HTTPException(403, "no session") @@ -339,10 +341,10 @@ async def contact_upload(name: str, request: Request) -> dict: if ext in IMAGE_EXTS: stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif") image = True - elif ext in FILE_EXTS: + elif ext in BLOCKED_EXTS: + raise HTTPException(415, "this file type is not allowed") + else: stored = _hash_name(body, orig) await asyncio.to_thread(file_store.put, stored, body) image = False - else: - raise HTTPException(415, "unsupported file type") return {"path": f"/_f/{stored}", "image": image, "name": orig} -- 2.56.0 From 95554b4165f93c6246d0139b7db74300f1a9bf23 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 01:59:08 +0000 Subject: [PATCH 15/58] contact messages: allow all attachment types, enforce downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the script-carrier blocklist: any file type (html, exe, svg, ...) may be attached now, with safety from enforced downloads instead of type filtering, verified twice: - the attachment chip anchor carries a download attribute (already), - the link is /_f/{name}/{original-filename}, a new file-store route that answers content-disposition: attachment naming the original file (ASCII-safe fallback + RFC 5987 UTF-8 form). The filename rides in the URL itself β€” no filename registry in the database. Close the residual gap on the plain /_f/{name} route: a stored file whose mime executes script in origin (html/xml/svg/js) is forced to download there too, so stripping the filename segment off an attachment URL cannot turn it into an in-origin page. All /_f/ responses now carry x-content-type-options: nosniff. Subresource fetches (, favicons) ignore Content-Disposition, so inline images and icons are unaffected. Undecodable fake images (store_image falls back to as-is storage) now attach as files instead of being marked inline images. --- AGENTS.md | 2 +- docs/messages.md | 21 ++++++++++------ pagerite/files.py | 60 +++++++++++++++++++++++++++++++++++++++++--- pagerite/messages.py | 35 +++++++++++++------------- 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ae252e..9eb7be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `pagerite/` β€” Python backend package (hatchling build target). - `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). + - `files.py` β€” `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/` β€” incl. `/_f/{name}/{filename}` forced-download attachments, `/_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, 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. diff --git a/docs/messages.md b/docs/messages.md index e994f79..3239c85 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -94,13 +94,20 @@ injection point, fed only by the strict renderer. `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, **anything else as-is**, -rendering as a download chip in the thread. The one exclusion is script -carriers (svg/html/xhtml/js/xml…) β€” an attachment URL can always be -opened directly in the site's origin, where such a file would execute. -Gates today: a session-shaped `x-contact-token` header, a 20 MB size cap -and the type blocklist. 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). +rendering as a download chip in the thread. Every file type is allowed +(html, exe, svg…) β€” safety comes from enforced downloads rather than +type filtering, verified twice: the chip anchor carries a `download` +attribute, and the attachment link is `/_f/{name}/{original-filename}`, +which the file server answers with `content-disposition: attachment` +naming the original file (the name rides in the URL; there is no +filename registry). The plain `/_f/{name}` form additionally forces a +download whenever the stored type is a script carrier (html/xml/svg/ +js), so stripping the filename segment cannot turn an attachment into +an in-origin page; all `/_f/` responses carry `nosniff`. 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 diff --git a/pagerite/files.py b/pagerite/files.py index 17ec5f6..bdc512d 100644 --- a/pagerite/files.py +++ b/pagerite/files.py @@ -8,7 +8,9 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives ``.orig`` (never served). Routes: upload/delete under ``/_api/files``, the favicon settings endpoints, the /favicon.ico redirect to the configured icon, the ``/_f/`` server with -Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``). +Accept-negotiated formats (``/_f/{name}/{filename}`` serves the same +file as a named forced download), and the user assets (``/_themes/``, +``/_fonts/``). """ import asyncio @@ -17,7 +19,7 @@ import mimetypes import tempfile from contextlib import suppress from pathlib import Path -from urllib.parse import urlsplit +from urllib.parse import quote, urlsplit import blake3 import httpx @@ -367,8 +369,52 @@ async def user_font_file(name: str, filename: str, request: Request) -> Response return await _serve_user_file(views.font_file(name, filename), request) +#: Mimes that execute script when navigated to in the site's origin. Store +#: files with such a type are always forced to download, so that stripping +#: the file-name segment off an attachment URL cannot turn it into an +#: in-origin page (see stored_file_download). Subresource fetches (, +#: favicons) ignore Content-Disposition and are unaffected. +_SCRIPT_MIMES = { + "text/html", + "application/xhtml+xml", + "text/xml", + "application/xml", + "image/svg+xml", + "text/javascript", + "application/javascript", + "application/x-javascript", +} + + +def _content_disposition(filename: str) -> str: + """An attachment Content-Disposition for an original file name: an + ASCII-safe quoted fallback plus the RFC 5987 UTF-8 form.""" + fallback = "".join( + c if 32 <= ord(c) < 127 and c not in '";\\' else "_" for c in filename + ) + return ( + f'attachment; filename="{fallback or "file"}"; ' + f"filename*=UTF-8''{quote(filename, safe='')}" + ) + + @router.get("/_f/{name}") async def stored_file(name: str, request: Request) -> Response: + return _stored_response(name, request) + + +@router.get("/_f/{name}/{filename}") +async def stored_file_download(name: str, filename: str, request: Request) -> Response: + """The attachment form of a store URL: same file, always served as a + download named after the original upload (the link form contact + attachments carry, `/_f/{name}/{original}` β€” the file name lives in + the URL, not the database). The name segment only labels the + download; lookup is by ``name`` alone. + """ + return _stored_response(name, request, Path(filename).name or "file") + + +def _stored_response(name: str, request: Request, filename: str = "") -> Response: """Serve a file from the content-addressed store (immutable: the name is its own hash, so cache forever). Bodies are served from the RAM cache, zstd-compressed when the client accepts it and compression @@ -379,7 +425,10 @@ async def stored_file(name: str, request: Request) -> Response: only when the Accept header lists it explicitly β€” ``image/avif`` β†’ AVIF, ``image/webp`` β†’ WebP, anything else (including ``image/*`` and ``*/*``) β†’ JPEG. An explicit extension pins the format. ``.orig.`` - originals are internal (they may carry EXIF data) and never served.""" + originals are internal (they may carry EXIF data) and never served. + + Every response carries ``nosniff``; a ``filename`` (attachment URLs) + or a script-carrier mime forces a download via Content-Disposition.""" if ".orig." in name: raise HTTPException(404) etag = name @@ -413,4 +462,9 @@ async def stored_file(name: str, request: Request) -> Response: if vary: headers["vary"] = vary mime = mimetypes.guess_type(etag)[0] or "application/octet-stream" + if filename: + headers["content-disposition"] = _content_disposition(filename) + elif mime in _SCRIPT_MIMES: + headers["content-disposition"] = _content_disposition(name) + headers["x-content-type-options"] = "nosniff" return Response(body, media_type=mime, headers=headers) diff --git a/pagerite/messages.py b/pagerite/messages.py index f07ac21..f73db60 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -26,6 +26,7 @@ import re import secrets from datetime import UTC, datetime from pathlib import Path +from urllib.parse import quote import blake3 from fastapi import APIRouter, HTTPException, Request, WebSocket @@ -301,13 +302,13 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None: # --- Uploads ----------------------------------------------------------------- -#: Contact-panel uploads: raster images get the usual derivative pipeline, -#: everything else is stored as-is and renders as a download chip β€” except -#: script carriers, which are refused outright: an attachment URL can -#: always be opened directly in the site's origin, where served HTML/JS/ -#: SVG/XML would execute. +#: Contact-panel uploads: raster images get the usual derivative pipeline +#: and inline in the message; every other type (html, exe, svg, anything) +#: is stored as-is and renders as a download chip whose link carries the +#: original file name: /_f/{name}/{filename}. Downloads are enforced +#: twice β€” the chip anchor has a download attribute and that URL is served +#: content-disposition: attachment β€” so no file type needs refusing. IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".heic", ".heif"} -BLOCKED_EXTS = {".svg", ".html", ".htm", ".xhtml", ".xht", ".js", ".mjs", ".xml"} UPLOAD_MAX_BYTES = 20 * 1024 * 1024 @@ -316,12 +317,12 @@ async def contact_upload(name: str, request: Request) -> dict: """Store a contact-panel upload in the content-addressed store. Public like the messaging socket (only /_api is gated): the caller - must present a session-shaped token, size is capped, script-carrying - types are refused, and abuse limiting is meant to ride on the - proof-of-work stamp once plugged in. Images get the usual - AVIF/WebP/JPEG derivatives and are referenced extension-less; any - other file is stored as-is for download. Returns the /_f/ path and - whether the file is an inline-able image. + must present a session-shaped token, size is capped, and abuse + limiting is meant to ride on the proof-of-work stamp once plugged in. + Images get the usual AVIF/WebP/JPEG derivatives and are referenced + extension-less; any other file is stored as-is and referenced with + its original name in the path (served as a forced download). Returns + the /_f/ path and whether the file is an inline-able image. """ if not TOKEN_RE.fullmatch(request.headers.get("x-contact-token", "")): raise HTTPException(403, "no session") @@ -340,11 +341,11 @@ async def contact_upload(name: str, request: Request) -> dict: ext = _ext(orig) if ext in IMAGE_EXTS: stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif") - image = True - elif ext in BLOCKED_EXTS: - raise HTTPException(415, "this file type is not allowed") + # Decodable images come back extension-less (GIFs keep .gif); an + # undecodable body is stored as-is and attaches like any file. + if "." not in stored or ext == ".gif": + return {"path": f"/_f/{stored}", "image": True, "name": orig} else: stored = _hash_name(body, orig) await asyncio.to_thread(file_store.put, stored, body) - image = False - return {"path": f"/_f/{stored}", "image": image, "name": orig} + return {"path": f"/_f/{stored}/{quote(orig, safe='')}", "image": False, "name": orig} -- 2.56.0 From 6b34719397e4e3982697760c3df215563a9ef979 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 03:35:08 +0000 Subject: [PATCH 16/58] contact messages: inert .file attachment blobs, universal forced download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (, - +
@@ -102,7 +100,7 @@ function send(md) {
{{ m.admin || thread.name || 'visitor' }} - +
diff --git a/pagerite/data.py b/pagerite/data.py index 9757536..1f3fd4b 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -83,8 +83,9 @@ class ContactThread(msgspec.Struct, omit_defaults=True): #: When an admin last looked at the thread; visitor messages newer #: than this are unread in the admin inbox. admin_read: datetime | None = None - #: The visitor's self-entered identity, captured with their first - #: message and fixed afterwards ("" until then). + #: The visitor's self-entered identity (name required, email optional + #: and unvalidated), captured with their first message and fixed + #: afterwards ("" until then). name: str = "" email: str = "" diff --git a/pagerite/messages.py b/pagerite/messages.py index 7700416..67ce6e1 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -237,17 +237,15 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: if len(text) > MAX_MESSAGE: await ws.send_json({"type": "contact-error", "detail": "message too long"}) return - # The first message must introduce the visitor (name + email); the - # stored identity is fixed from then on. + # The first message must introduce the visitor (name; email optional, + # unvalidated). The stored identity is fixed from then on. thread = data.contact.get(key) name = email = "" if thread is None or not thread.name: name = str(raw.get("name") or "").strip()[:100] email = str(raw.get("email") or "").strip()[:200] - if not name or "@" not in email: - await ws.send_json( - {"type": "contact-error", "detail": "name and email required"} - ) + if not name: + await ws.send_json({"type": "contact-error", "detail": "name required"}) return now = datetime.now(UTC) _append( -- 2.56.0 From 4ad4e3bdff57143a2cf85cc6f573b0f748dce219 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 27 Sep 2026 18:59:22 +0000 Subject: [PATCH 23/58] contact messages: standalone /_m admin inbox, site-name reply label The inbox moves out of the analytics tab to its own page at /_m (own pens-row icon, own entry and socket lifecycle; the admin-gated /_api/ws/analytics socket keeps serving both, with typed messaging frames ignored by AnalyticsView). Admin replies are labeled with the currently configured site name in the wire payload (the SSO name stays stored for audit), and the thread view is capped to roughly the visitor panel's width. --- AGENTS.md | 4 +- docs/messages.md | 35 ++-- frontend/src/AnalyticsView.vue | 86 ++------ frontend/src/MessagesView.vue | 353 +++++++++++++++++++++++++++------ frontend/src/messages-main.js | 30 +++ frontend/src/pagerite.js | 124 +++++++----- frontend/vite.config.js | 3 +- pagerite/messages.py | 38 +++- pagerite/state.py | 10 + pagerite/tracking.py | 3 +- pagerite/views.py | 58 ++++++ 11 files changed, 535 insertions(+), 209 deletions(-) create mode 100644 frontend/src/messages-main.js 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…