Merge branch 'main' of https://git.zi.fi/LeoVasanko/pagerite
# Conflicts: # AGENTS.md # pagerite/views.py
This commit is contained in:
commit
766fb503d9
32 files changed
+3629
-106
No files matched your search
@@ -10,17 +10,18 @@ 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).
|
||||
- `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).
|
||||
- `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 holds the per-connection easypow challenge (a validated proof grants the per-session message/upload quota held in messages.py, reveals the site contact email, and records the visit's contact trail marker), 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 challenges live in `tracking.activity_ws` as a per-connection local; the granted quota here as a per-session RAM budget shared with the upload route; browser solver served at `/_contact/easypow.js`), the admin unread count for `/_api/settings`, and the public `/_contact/files` upload route (PoW quota + 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 `<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.
|
||||
- `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md).
|
||||
- `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh).
|
||||
- `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route.
|
||||
- `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh), plus the site-string translations (`site_string`/`set_string_translation`) for the contact label/subject (docs/messages.md).
|
||||
- `translate.py` — translator service protocol (msgspec structs), the connected-client `Dispatcher` (job pipeline, result validation) and pending/store core for the `/_translate/{key}` WebSocket (docs/localization.md); api.py only registers the route. Pending title/nav work includes the site contact strings, so the contact chrome localizes automatically.
|
||||
- `segments.py` — the translation round trip: fragments split into pure-prose wire segments (via markdown.make_md's verbatim parser; link- and formatting-carrying blocks stay whole, link/formatted texts inline, Markdown stripped) and translations spliced back by source offset, link/formatting markdown re-inserted at weight-mapped positions (docs/localization.md).
|
||||
- `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field.
|
||||
- `markdown.py` — markdown-it-py renderer.
|
||||
@@ -31,6 +32,8 @@ 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` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked, and at page load when a message session is live: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected while open — a background `watchAdmin` connection keeps the badge and notifications live at all times). A visitor's open conversation is flagged in localStorage and resumed on page load; closing it archives the thread server-side and rotates the token. Both sides notify new messages via the browser Notification API (`notify.js`), permission asked after the first message of a fresh thread.
|
||||
- `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.
|
||||
- `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`).
|
||||
@@ -45,7 +48,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
|
||||
|
||||
## Toolchain
|
||||
|
||||
- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`).
|
||||
- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `easypow`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`).
|
||||
- Key libraries:
|
||||
- **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML).
|
||||
- To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header
|
||||
|
||||
+5
-1
@@ -199,7 +199,11 @@ for misses.
|
||||
re-visited in a different language becomes a distinct trail step instead
|
||||
of merging into the existing item. The entry page's referer and
|
||||
`utm_*` tags come from the GET that loaded it (within 10 s before the
|
||||
first message).
|
||||
first message). A server-originated contact marker (the visitor opened
|
||||
the contact panel and solved its proof of work, docs/messages.md —
|
||||
recorded by the `/_ws` handler on the validated solution, never by the
|
||||
client) flags the trail step it happened on, the first one per visit,
|
||||
rendered with a ✉️ marker in the viewer.
|
||||
- **Crawler hits**: a document GET no activity message matched within
|
||||
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
||||
documents never register as visits. JS-running crawlers (Googlebot,
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -503,6 +503,13 @@ offerable to clients of another approach.
|
||||
placeholders lost is skipped individually and likewise stays pending
|
||||
for a scoped title job.
|
||||
|
||||
Site-level settings strings (the contact-messaging label/subject,
|
||||
docs/messages.md) localize through the same machinery: they are emitted
|
||||
as title-kind fragments by `pending_items` and ride nav jobs as extra
|
||||
top-level list items; `i18n.site_string` looks them up at render time,
|
||||
and manual overrides are edited in the site editor's messaging block
|
||||
(`i18n.set_string_translation`).
|
||||
|
||||
`scripts/llm_translator.py` is the reference markdown+article+nav client
|
||||
(instruct LLMs via an OpenAI Chat Completions endpoint or ollama's native
|
||||
API); `scripts/translator.py` (Seed-X) is untouched and announces
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# Contact messages
|
||||
|
||||
Visitor→admin messaging built into the site: a `{contact: Label}` directive
|
||||
in article Markdown renders a button that opens the messaging overlay, and
|
||||
the corner ✉️ icon (top right, for everyone) opens it too — the visitor's
|
||||
own thread for visitors, the admin inbox for editors. Messages are stored
|
||||
in the kanta database. 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, the admin unread
|
||||
count and the `/_contact/files` upload route.
|
||||
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`)
|
||||
and the site settings (`contact_label` — which doubles as the on/off
|
||||
switch — `contact_email`, `contact_subject`, `contact_mailserver`).
|
||||
- `pagerite/tracking.py` — the sockets the protocol rides (see below).
|
||||
- `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin
|
||||
icon badge seed — the probe runs on every page load, no polling) and the
|
||||
contact configuration (with `?lang=` overrides for the label/subject).
|
||||
- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`), the
|
||||
localized contact strings (`_contact_config`) and the on-demand bundle's
|
||||
asset config.
|
||||
- `frontend/src/contact-main.js` + `ContactOverlay.vue` — the overlay entry
|
||||
and shell (paskia's shared blur backdrop, centered dialog, full-screen on
|
||||
mobile), imported on demand by `pagerite.js` on icon/trigger clicks.
|
||||
- `frontend/src/ContactPanel.vue` — the visitor thread content.
|
||||
- `frontend/src/MessagesView.vue` — the admin inbox content (thread list,
|
||||
replies, read marking); connects its admin-gated socket only while open.
|
||||
- `frontend/src/Composer.vue` + `richtext.js` — the shared composer and its
|
||||
DOM→Markdown serializer.
|
||||
|
||||
## Trigger
|
||||
|
||||
A lone `{contact: Label}` line in article Markdown (the block-directive
|
||||
machinery of `markdown.py`) renders
|
||||
`<button class="contact-trigger">Label</button>`. An empty label falls
|
||||
back to the configured site label (`Data.contact_label`, localized —
|
||||
below; "Contact us" is the last-resort fallback when neither is set).
|
||||
`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. A per-button 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). When
|
||||
messaging is disabled (no site label configured) the tag renders nothing
|
||||
at all, in the editor preview just as on the served page.
|
||||
|
||||
## Site settings and localization
|
||||
|
||||
The site editor's messaging row holds the feature's configuration
|
||||
(`/_api/settings`): `contact_label` — the {contact:} buttons' label and
|
||||
the visitor dialog's title, doubling as the on/off switch (empty = no
|
||||
trigger buttons, no visitor corner icon, sends rejected; admins keep the
|
||||
inbox for existing threads) — `contact_email` (a mailto link in the
|
||||
visitor dialog: the recipient is addressed `Brand <email>`, the link
|
||||
text the bare address, the subject prefilled) and `contact_subject`
|
||||
(defaults to the label). `contact_mailserver` is a mailbridge URL
|
||||
(`smtp://user:pass@host:port`, a bare `host[:port]`, or a backend URL
|
||||
like `sendgrid:API-KEY`) reserved for email notifications — the actual
|
||||
sending is not implemented yet. Saves re-render the page in place, so
|
||||
button and dialog strings update without a reload.
|
||||
|
||||
Both sides also get browser notifications (the Notification API,
|
||||
`frontend/src/notify.js`): permission is asked at one entry point only —
|
||||
right after the first message of a fresh thread (a denied permission is
|
||||
permanent in the browser and never re-asked; a dismissed prompt comes up
|
||||
again on the next fresh thread). With permission granted the visitor is
|
||||
notified of new admin replies while not viewing the thread and the admin
|
||||
of new visitor messages; clicking a notification focuses the tab and
|
||||
opens the thread/inbox at what was new.
|
||||
|
||||
The two strings localize like titles: translations live in the
|
||||
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
|
||||
render time) and the translator service picks them up automatically
|
||||
(`pending_items` emits them as title-kind fragments; nav-mode jobs carry
|
||||
them as extra top-level list items). Manual overrides are edited in the
|
||||
same messaging row: the flag selector (the article editor's LangSelect)
|
||||
switches the label/subject fields to a translation, saved via
|
||||
`PUT /_api/settings?lang=` into the
|
||||
same store (`i18n.set_string_translation`; empty or the original's text
|
||||
drops the override). The localized strings reach the client through the
|
||||
page's asset config (`pagerite:contact-label` / `-subject`, absent
|
||||
entirely when disabled — pagerite.js reads that as "visitors get no
|
||||
icon"); `contact-main.js` re-reads them on every open and on page swaps,
|
||||
so a language switch or a settings save is reflected immediately. The
|
||||
email address itself is never in the config: it is revealed over the
|
||||
messaging socket only after the proof of work is solved (and only when
|
||||
configured).
|
||||
|
||||
## 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`) and the newest admin reply's timestamp
|
||||
(`pagerite-contact-lastadmin`) drive the unread marker (a periodic
|
||||
scale-and-shake nudge animation, `has-unread` in pagerite.css) on trigger
|
||||
buttons and the corner ✉️ icon when an admin reply arrives while the panel
|
||||
is closed — including on a fresh page load before any socket activity
|
||||
(pagerite.js reads the pair directly; the bundle keeps it live once
|
||||
loaded). A live conversation is also flagged
|
||||
(`pagerite-contact-session`): while set, page loads register the session
|
||||
right away (no panel click needed), so a granted notification permission
|
||||
reports new replies immediately.
|
||||
|
||||
**Closing a conversation**: the panel's ⟲ button sends `{token, close}` —
|
||||
the server marks the thread `closed` (archived in the admin inbox,
|
||||
excluded from the unread count, replies rejected, the token's pushes
|
||||
stopped) — then wipes the local session keys (token, seen, lastadmin,
|
||||
session flag) and rotates the token, so the next message starts an
|
||||
all-new thread.
|
||||
|
||||
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, pow}` — a solved proof-of-work, submitted as soon as the
|
||||
background solve finishes (below); grants the message/upload quota,
|
||||
reveals the site's contact email and marks the visit's analytics trail.
|
||||
- `{token, msg, fr, name?, email?}` — append a message; `msg` is the
|
||||
Markdown-subset source, `fr` the page path it was sent from (stored
|
||||
for context). Spends one quota; with none left it is answered with a
|
||||
fresh `required` challenge instead, which the client solves, re-proves
|
||||
and retries the message once. The **first** message
|
||||
of a thread must carry `name` (and optionally `email`, unvalidated)
|
||||
introducing the visitor; the identity is stored on the thread and fixed
|
||||
from then on.
|
||||
- `{token, close}` — end the conversation (archived for admins; above).
|
||||
|
||||
Frames **from** the server: `{type: "thread", id, created, unread,
|
||||
closed, name, email, messages: [{id, t, admin, html, page}]}` — a full
|
||||
thread snapshot
|
||||
(replace/upsert by `id`); `{type: "pow", challenge, required}` — a fresh
|
||||
single-use PoW challenge (`required` means the last message was rejected
|
||||
for an empty quota and should be re-sent once re-proven); `{type: "pow",
|
||||
quota}` — a submitted proof was accepted, this many messages/uploads it
|
||||
grants; `{type: "contact-email", email}` — the site's contact email,
|
||||
revealed only after a solve; or `{type: "contact-error", detail}`.
|
||||
|
||||
### Proof of work (anti-spam)
|
||||
|
||||
Sends and uploads draw from a quota granted by easypow (PBKDF2
|
||||
challenges, ~5 s solving cost, 10 actions per solve). The server hands a
|
||||
fresh single-use challenge on registration/resync and whenever the quota
|
||||
runs out; the panel solves it in the background with the bundled solver
|
||||
module (served from the installed package at `/_contact/easypow.js`, so
|
||||
client and validator versions always match) and submits the solution as
|
||||
soon as it is ready — typically long before the user finishes writing,
|
||||
so sends just work. Solutions are single-use and challenges are a
|
||||
per-connection local in the `/_ws` handler; the granted quota lives in
|
||||
RAM keyed by the token hash (single worker), shared with the HTTP upload
|
||||
route and kept across reconnects. A validated proof also reveals the
|
||||
site's contact email (the address never appears in pages — scrapers
|
||||
cannot lift it without solving) and records the visit's contact trail
|
||||
marker in analytics (a panel opened without a solve leaves no trace).
|
||||
|
||||
## Admin side
|
||||
|
||||
Editors open the inbox from the same ✉️ corner icon (the trigger buttons
|
||||
always open the visitor side, so both can be tested side by side): the
|
||||
same overlay, wider to fit the thread list, served by the existing
|
||||
`/_api/ws/analytics` socket (admin-gated like all of `/_api`) which
|
||||
`MessagesView` connects while the overlay is open — and a background
|
||||
watch in `contact-main.js` (`watchAdmin`) connects at every page load
|
||||
while messaging is enabled, keeping the icon badge live and notifying
|
||||
new visitor messages without waiting for the inbox (MessagesView owns
|
||||
notifications while open). 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, archived
|
||||
last); new
|
||||
messages and replies push single-thread updates. Admin→server frames:
|
||||
`{type: "reply", id, text}` and `{type: "read", id}`.
|
||||
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. The icon's
|
||||
unread marker (any unanswered thread, archived ones excluded) is seeded by the `/_api/settings`
|
||||
probe's `contact_unread` on every page load and kept live by the watch
|
||||
and the inbox.
|
||||
|
||||
## Message format and the XSS model
|
||||
|
||||
Bodies are a **Markdown subset** — emphasis, links, lists, code, plus
|
||||
`/_f/` image and file references — serialized client-side from a
|
||||
constrained contenteditable composer (plain-text paste only; formatting
|
||||
via toolbar; uploads insert image/attachment-chip nodes).
|
||||
|
||||
**No user HTML is ever rendered or sanitized.** Sanitizing arbitrary HTML
|
||||
is unwinnable, so the design never attempts it: the composer serializes to
|
||||
*text*, and the server re-renders that text with a strict `MarkdownIt`
|
||||
instance (`messages.py`): `html=False` (the page renderer's trusted-author
|
||||
raw passthrough does not apply here), no plugins. Every byte of the stored
|
||||
`html` is markdown-it output from the constrained grammar. markdown-it
|
||||
also validates link protocols (`javascript:` & co. never become `href`s);
|
||||
the link rule adds `rel="nofollow ugc noopener"` + `target="_blank"` (and
|
||||
`download` + a chip class for `/_f/` attachment links); images are
|
||||
restricted to `/_f/` store names, anything else collapses to its alt text.
|
||||
Both panels inject this server-rendered HTML verbatim (`v-html`) — the one
|
||||
injection point, fed only by the strict renderer.
|
||||
|
||||
## Uploads
|
||||
|
||||
`PUT /_contact/files/{name}` (public, like the messaging socket) stores
|
||||
panel uploads in the content-addressed file store. Images (raster and
|
||||
SVG) go through the usual AVIF/WebP/JPEG derivative pipeline and inline
|
||||
in the message — SVGs included: the extension-less link negotiates
|
||||
raster derivatives, and even an as-is-served `.svg` is script-inert
|
||||
inside an `<img>`. Everything else (videos, archives, html, exe…) is
|
||||
stored as an inert `{hash}.file` blob — the original extension is
|
||||
dropped on disk, so the store can never be double-click-executed — and
|
||||
linked as `/_f/{hash}/{original-name}`; the `.file` suffix exists only
|
||||
on the filesystem and is rejected in URLs. Serving is strictly
|
||||
separated: `/_f/{name}` serves media only (hash with or without
|
||||
extension), `.orig.` originals stay blocked, and the filename-carrying
|
||||
route serves only `{name}.file` blobs (never media, never by hash).
|
||||
Every `/_f/` response carries `content-disposition: attachment` (no
|
||||
filename needed — the URL's last segment names the download) and
|
||||
`x-content-type-options: nosniff`, so a store file can never be
|
||||
rendered or sniffed into something executable in the site's origin;
|
||||
subresource fetches (`<img>`, `<video>`, favicons) ignore the
|
||||
disposition, so inline media is unaffected. The chip anchors
|
||||
additionally carry a `download` attribute. Uploads spend the same PoW
|
||||
quota as messages (a solved challenge covers both; the gates are a
|
||||
session-shaped `x-contact-token` header, a 20 MB size cap, and the
|
||||
quota itself) — an upload answered 429 "pow" makes the client re-prove
|
||||
and retry once. Every upload also stays *pending* per session (10
|
||||
un-sent files cap, answered 429 — not 403, which could trip auth
|
||||
flows): a sent message strikes the files it references off the pending
|
||||
set, and when the session's last socket closes the remaining pending
|
||||
files are deleted from the store. Admin uploads from the same composer
|
||||
go to the admin-gated `/_api/files` instead. Files left behind by a
|
||||
server restart (the pending set is RAM-only) still linger — no 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`, the visitor's self-entered
|
||||
`name`/`email` (name required, email optional; captured with the first
|
||||
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 — the wire
|
||||
payload shows the current site name instead) and `page`.
|
||||
Appends are single transactions (`contact` / `contact:reply` /
|
||||
`contact:read`).
|
||||
|
||||
## Limits and deliberate gaps (MVP)
|
||||
|
||||
- Message source ≤ 8000 chars; uploads ≤ 20 MB and ≤ 10 pending per
|
||||
session; payloads cap at 100 threads × last 200 messages.
|
||||
- No thread moderation (delete/block), no orphan-upload GC for files
|
||||
orphaned by a server restart, and the panels' own UI strings (composer
|
||||
hints, "you", inbox labels) are English-only — the *configurable* site
|
||||
strings (label, subject) do localize (above).
|
||||
@@ -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 also carries the contact-messaging
|
||||
// protocol (typed frames) for the admin inbox overlay (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'
|
||||
@@ -84,7 +86,12 @@ function connect() {
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
data.value = JSON.parse(event.data)
|
||||
const msg = JSON.parse(event.data)
|
||||
// Contact-messaging frames (docs/messages.md) carry a type field and
|
||||
// are for the admin inbox overlay; the type-less snapshot is
|
||||
// analytics data.
|
||||
if (msg?.type) return
|
||||
data.value = msg
|
||||
if (!rangePinned) {
|
||||
rangePinned = true
|
||||
const starts = (data.value?.visits || [])
|
||||
@@ -309,6 +316,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
<a href="/" class="close" title="home">✕</a>
|
||||
</header>
|
||||
<ConnNote :text="connNote" />
|
||||
<div>
|
||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||
<p v-else-if="!data" class="loading">loading…</p>
|
||||
<template v-else>
|
||||
@@ -447,6 +455,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup>
|
||||
// The message composer shared by the visitor contact panel and the admin
|
||||
// inbox: a constrained contenteditable (plain-text paste, toolbar-driven
|
||||
// formatting, upload chips) serialized to the Markdown subset on send
|
||||
// (./richtext.js). The server re-renders that with its strict parser —
|
||||
// what shows in threads is never this DOM.
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { apiFetch } from 'paskia'
|
||||
import { serializeRich } from './richtext.js'
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: { type: String, default: 'Write a message…' },
|
||||
// Visitor session token for the public upload endpoint; empty = admin,
|
||||
// whose uploads go to the /_api file store instead.
|
||||
token: { type: String, default: '' },
|
||||
autofocus: { type: Boolean, default: false },
|
||||
// When true, send (button and Ctrl+Enter) is inert and the draft is
|
||||
// kept — e.g. the contact panel's first-message name/email fields are
|
||||
// still empty.
|
||||
sendDisabled: { type: Boolean, default: false },
|
||||
// Visitor side: an upload hit the end of the PoW quota (429 "pow") —
|
||||
// re-prove and resolve, then the upload retries once.
|
||||
reauthorize: { type: Function, default: null },
|
||||
})
|
||||
const emit = defineEmits(['send'])
|
||||
|
||||
const box = ref(null)
|
||||
const imageInput = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(0)
|
||||
const error = ref('')
|
||||
let errorTimer = null
|
||||
|
||||
function showError(text) {
|
||||
error.value = text
|
||||
clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { error.value = '' }, 6000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) box.value?.focus()
|
||||
})
|
||||
|
||||
function cmd(name, value = null) {
|
||||
box.value?.focus()
|
||||
document.execCommand(name, false, value)
|
||||
}
|
||||
|
||||
function link() {
|
||||
const url = window.prompt('Link URL', 'https://')
|
||||
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
|
||||
}
|
||||
|
||||
const escHtml = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
// Paste is plain text only — the grammar (bold, links, chips…) comes from
|
||||
// the toolbar and uploads, never from foreign HTML.
|
||||
function onPaste(ev) {
|
||||
ev.preventDefault()
|
||||
if (ev.clipboardData?.files?.length) {
|
||||
uploadFiles(ev.clipboardData.files)
|
||||
return
|
||||
}
|
||||
const text = ev.clipboardData?.getData('text/plain') || ''
|
||||
if (!text) return
|
||||
const html = text
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<div>${p.split(/\r\n|\r|\n/).map(escHtml).join('<br>')}</div>`)
|
||||
.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) {
|
||||
showError(`"${file.name}" is over 20 MB`)
|
||||
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 put = () => (props.token ? fetch(path, init) : apiFetch(path, init))
|
||||
let r = await put()
|
||||
if (!r.ok && r.status === 429 && props.reauthorize) {
|
||||
// The PoW quota (shared with messages) ran out: re-prove, retry once.
|
||||
const detail = await r.json().then((d) => d?.detail, () => null)
|
||||
if (detail === 'pow' && (await props.reauthorize())) r = await put()
|
||||
}
|
||||
if (!r.ok) {
|
||||
const detail = await r.json().then((d) => d?.detail, () => null)
|
||||
throw new Error(detail || `status ${r.status}`)
|
||||
}
|
||||
insertUpload(await r.json())
|
||||
} catch (e) {
|
||||
console.error('contact upload failed:', e)
|
||||
showError(`could not upload "${file.name}": ${e.message}`)
|
||||
} 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
|
||||
? `<img class="contact-img" src="${escHtml(path)}" data-path="${escHtml(path)}" alt="${escHtml(name)}">`
|
||||
: `<span class="contact-file" contenteditable="false" data-path="${escHtml(path)}" data-name="${escHtml(name)}">📎 ${escHtml(name)}</span> `
|
||||
box.value?.focus()
|
||||
document.execCommand('insertHTML', false, html)
|
||||
}
|
||||
|
||||
function send() {
|
||||
const md = serializeRich(box.value)
|
||||
if (!md || uploading.value || props.sendDisabled) return
|
||||
emit('send', md)
|
||||
box.value.innerHTML = ''
|
||||
}
|
||||
|
||||
function onKeydown(ev) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') send()
|
||||
}
|
||||
|
||||
defineExpose({ focus: () => box.value?.focus() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer">
|
||||
<div
|
||||
ref="box"
|
||||
class="composer-box"
|
||||
contenteditable="true"
|
||||
:data-placeholder="placeholder"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
@paste="onPaste"
|
||||
@drop="onDrop"
|
||||
@keydown="onKeydown"
|
||||
></div>
|
||||
<div class="composer-bar">
|
||||
<button type="button" title="bold" @mousedown.prevent @click="cmd('bold')"><b>B</b></button>
|
||||
<button type="button" title="italic" @mousedown.prevent @click="cmd('italic')"><i>I</i></button>
|
||||
<button type="button" title="link" @mousedown.prevent @click="link">🔗</button>
|
||||
<button type="button" title="list" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
|
||||
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button>
|
||||
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button>
|
||||
<span v-if="uploading" class="uploading">uploading…</span>
|
||||
<button type="button" class="send" title="send (Ctrl+Enter)" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
|
||||
</div>
|
||||
<p v-if="error" class="composer-error">{{ error }}</p>
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg, Canvas);
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-box {
|
||||
min-height: 3.2rem;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.7rem;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.composer-box:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.composer-box :deep(img.contact-img) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 10rem;
|
||||
margin: 0.2rem 0;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.composer-box :deep(.contact-file) {
|
||||
display: inline-block;
|
||||
padding: 0.05em 0.5em;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1em;
|
||||
background: color-mix(var(--muted) 10%, transparent);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.composer-bar button {
|
||||
min-width: 1.7rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border: none;
|
||||
border-radius: 0.3rem;
|
||||
background: none;
|
||||
color: var(--text, CanvasText);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.composer-bar button:hover {
|
||||
background: color-mix(var(--muted) 15%, transparent);
|
||||
}
|
||||
.composer-bar button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
background: none;
|
||||
}
|
||||
.composer-bar .uploading {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.composer-bar .send {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-error {
|
||||
margin: 0;
|
||||
padding: 0.2rem 0.7rem 0.35rem;
|
||||
color: #b3261e;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup>
|
||||
// The messaging overlay shell (docs/messages.md): one centered dialog over
|
||||
// paskia's shared blur backdrop, on any page. The mode decides the content:
|
||||
// 'visitor' shows the visitor's own thread (ContactPanel — what the
|
||||
// {contact:} trigger buttons open, for admins too), 'admin' the contact
|
||||
// inbox (MessagesView — what the corner ✉️ icon opens for admins).
|
||||
// contact-main holds the backdrop while state.open and owns the store.
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import ContactPanel from './ContactPanel.vue'
|
||||
import MessagesView from './MessagesView.vue'
|
||||
|
||||
const props = defineProps({
|
||||
state: { type: Object, required: true },
|
||||
// Visitor-side PoW quota re-grant for the composer's upload retries.
|
||||
reauthorize: { type: Function, default: null },
|
||||
})
|
||||
const emit = defineEmits(['send', 'close', 'close-thread'])
|
||||
|
||||
const admin = computed(() => props.state.mode === 'admin')
|
||||
|
||||
// The visitor dialog's title is the localized site contact label (the
|
||||
// {contact:} buttons' default label). The mailto href carries the
|
||||
// server-encoded "Brand <email>" recipient and the localized subject;
|
||||
// the link text is the bare address.
|
||||
const mailto = computed(
|
||||
() => `mailto:${props.state.mailtoTo}?subject=${encodeURIComponent(props.state.subject)}`
|
||||
)
|
||||
|
||||
// Escape closes, like the paskia dialogs whose backdrop we share.
|
||||
function onKey(ev) {
|
||||
if (ev.key === 'Escape' && props.state.open) emit('close')
|
||||
}
|
||||
onMounted(() => addEventListener('keydown', onKey))
|
||||
onUnmounted(() => removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="state.open" class="contact-overlay" @click.self="emit('close')">
|
||||
<section
|
||||
class="contact-panel"
|
||||
:class="{ admin }"
|
||||
role="dialog"
|
||||
:aria-label="admin ? 'Messages' : state.label"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ admin ? 'Messages' : state.label }}</h2>
|
||||
<a v-if="!admin && state.email" class="mailto" :href="mailto">{{ state.email }}</a>
|
||||
<button
|
||||
v-if="!admin && state.thread?.messages?.length"
|
||||
type="button"
|
||||
class="close-thread"
|
||||
title="close this conversation — your next message starts a fresh thread"
|
||||
@click="emit('close-thread')"
|
||||
>⟲</button>
|
||||
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
<ContactPanel
|
||||
v-if="!admin"
|
||||
:state="state"
|
||||
:token="state.token"
|
||||
:reauthorize="reauthorize"
|
||||
@send="(p) => emit('send', p)"
|
||||
/>
|
||||
<MessagesView v-else :state="state" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above paskia's shared blur backdrop (1099, held by contact-main while
|
||||
open), below the authentication iframe (9999). Transparent: the blur
|
||||
is the backdrop's job, this layer only centers the panel and catches
|
||||
outside clicks (the backdrop itself is pointer-events: none). */
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contact-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Same dimensions as paskia's own dialog. */
|
||||
width: min(36rem, 100%);
|
||||
height: min(42rem, 92vh);
|
||||
padding: 0.8rem;
|
||||
gap: 0.6rem;
|
||||
background: var(--bg, Canvas);
|
||||
color: var(--text, CanvasText);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 2rem rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* The admin inbox adds the thread-list sidebar: a wider dialog. */
|
||||
.contact-panel.admin {
|
||||
width: min(54rem, 100%);
|
||||
}
|
||||
|
||||
.contact-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.contact-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.contact-panel .mailto {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.contact-panel .mailto:hover {
|
||||
color: var(--text, CanvasText);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.contact-panel .close {
|
||||
margin-left: auto;
|
||||
}
|
||||
.contact-panel .close,
|
||||
.contact-panel .close-thread {
|
||||
padding: 0 0.3rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-panel .close:hover,
|
||||
.contact-panel .close-thread:hover {
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
|
||||
/* Mobile: full screen (the admin layout's own sidebar stacking kicks in
|
||||
at the same width). */
|
||||
@media (max-width: 36rem) {
|
||||
.contact-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.contact-panel.admin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
// The visitor's contact thread: messages pushed live over the activity
|
||||
// socket (via the bridge pagerite.js hands to contact-main), with the
|
||||
// composer shared with the admin inbox. state is contact-main's reactive
|
||||
// store ({open, mode, thread, unread, ...}). The overlay shell (backdrop,
|
||||
// dialog frame, close) is ContactOverlay.vue.
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { formatWhen } from './analytics/format.js'
|
||||
import Composer from './Composer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
state: { type: Object, required: true },
|
||||
token: { type: String, required: true },
|
||||
// Re-grant the PoW quota (an upload hit its end): resolves once the
|
||||
// fresh proof is accepted, so the upload can retry.
|
||||
reauthorize: { type: Function, default: null },
|
||||
})
|
||||
const emit = defineEmits(['send'])
|
||||
|
||||
const scroll = ref(null)
|
||||
|
||||
async function scrollDown() {
|
||||
await nextTick()
|
||||
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
|
||||
}
|
||||
|
||||
// Scroll to the newest message: on mount (the overlay is v-if'd, so every
|
||||
// open is a fresh mount) and whenever the message count changes — sent or
|
||||
// received, both arrive as server-pushed thread updates.
|
||||
watch(() => props.state.thread?.messages?.length, scrollDown)
|
||||
onMounted(scrollDown)
|
||||
|
||||
// Relative timestamps like in analytics.
|
||||
const fmtTime = (t) => formatWhen(t)
|
||||
|
||||
const NAME_KEY = 'pagerite-contact-name'
|
||||
const EMAIL_KEY = 'pagerite-contact-email'
|
||||
|
||||
// Name (and optionally email) are asked with the first message; from
|
||||
// then on the thread's stored identity labels the visitor's messages.
|
||||
const intro = reactive({
|
||||
name: localStorage.getItem(NAME_KEY) || '',
|
||||
email: localStorage.getItem(EMAIL_KEY) || '',
|
||||
})
|
||||
const showIntro = computed(() => !props.state.thread?.name)
|
||||
// Sending stays disabled until the name field is filled.
|
||||
const introInvalid = computed(() => showIntro.value && !intro.name.trim())
|
||||
|
||||
function onSend(md) {
|
||||
if (showIntro.value) {
|
||||
const name = intro.name.trim()
|
||||
const email = intro.email.trim()
|
||||
localStorage.setItem(NAME_KEY, name)
|
||||
localStorage.setItem(EMAIL_KEY, email)
|
||||
emit('send', { md, name, email })
|
||||
return
|
||||
}
|
||||
emit('send', { md })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scroll" class="contact-messages">
|
||||
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">
|
||||
Send us a message — the reply appears here.
|
||||
</p>
|
||||
<div
|
||||
v-for="m in state.thread?.messages || []"
|
||||
:key="m.id"
|
||||
class="contact-msg"
|
||||
:class="{ mine: !m.admin }"
|
||||
>
|
||||
<!-- m.html is the server's strict-markdown rendering of the
|
||||
message source (pagerite/messages.py, html=False): markdown-it
|
||||
output only, never user markup. -->
|
||||
<header>
|
||||
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span>
|
||||
<time>{{ fmtTime(m.t) }}</time>
|
||||
</header>
|
||||
<div class="contact-msg-body" v-html="m.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showIntro" class="contact-intro">
|
||||
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
|
||||
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200">
|
||||
</div>
|
||||
<Composer
|
||||
:token="token"
|
||||
:send-disabled="introInvalid"
|
||||
:reauthorize="reauthorize"
|
||||
autofocus
|
||||
@send="onSend"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
.contact-empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.contact-msg {
|
||||
padding: 0.55rem 0;
|
||||
}
|
||||
.contact-msg + .contact-msg {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.contact-msg-body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.contact-msg-body :deep(p) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.contact-msg-body :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.contact-msg-body :deep(a.attachment) {
|
||||
display: inline-block;
|
||||
padding: 0.05em 0.5em;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1em;
|
||||
background: var(--bg, Canvas);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.contact-msg header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.contact-msg.mine header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.contact-msg header .who {
|
||||
font-weight: 600;
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
|
||||
.contact-intro {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.contact-intro input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.3rem;
|
||||
background: var(--bg, Canvas);
|
||||
color: var(--text, CanvasText);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.contact-intro input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<script setup>
|
||||
// The admin contact inbox, mounted inside the messaging overlay
|
||||
// (ContactOverlay.vue) when an admin opens the corner ✉️ icon. Owns its
|
||||
// connection to the admin-gated /_api/ws/analytics socket (the same
|
||||
// endpoint the analytics viewer uses) for exactly as long as the overlay
|
||||
// is open: frames carrying a `type` field are messaging payloads
|
||||
// (threads/thread/contact-error), the type-less analytics snapshot is
|
||||
// ignored. Replies and read-markings go back down the same socket
|
||||
// (docs/messages.md). Message HTML is the server's strict-markdown
|
||||
// rendering — injected verbatim, never user markup (pagerite/messages.py).
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { formatWhen } from './analytics/format.js'
|
||||
import Composer from './Composer.vue'
|
||||
import ConnNote from './ConnNote.vue'
|
||||
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
|
||||
|
||||
// The shared store (contact-main): adminUnread mirrors the inbox's unread
|
||||
// count onto the corner icon's badge.
|
||||
const props = defineProps({
|
||||
state: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const threads = ref([])
|
||||
const selected = ref('')
|
||||
const scroll = ref(null)
|
||||
|
||||
let ws = null
|
||||
let reconnectTimer = null
|
||||
let connectWatchdog = null
|
||||
const reconnects = reconnectPolicy()
|
||||
let mounted = false
|
||||
let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail
|
||||
let hiddenAt = 0
|
||||
// 'threads' full-state pushes are the baseline, history never notifies.
|
||||
const seenLastMsg = new Map()
|
||||
|
||||
// The inbox is live data over its socket: while it is connecting, waiting
|
||||
// to reconnect or logged out, say so (ConnNote) instead of showing a
|
||||
// silent stale view.
|
||||
const conn = ref('connecting') // connecting | open | waiting | login
|
||||
const retryIn = ref(0)
|
||||
const connNote = computed(() =>
|
||||
conn.value === 'connecting' ? 'connecting to the server…'
|
||||
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
||||
: conn.value === 'login' ? 'login required — authenticate to continue…'
|
||||
: '',
|
||||
)
|
||||
|
||||
const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread && !t.closed ? 1 : 0), 0))
|
||||
watch(unreadTotal, (n) => { props.state.adminUnread = n }, { immediate: true })
|
||||
|
||||
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)
|
||||
// Most recently active first, visitor-closed (archived) threads last.
|
||||
threads.value = [thread, ...rest].sort(
|
||||
(a, b) => (a.closed - b.closed) || lastT(b).localeCompare(lastT(a))
|
||||
)
|
||||
}
|
||||
|
||||
function notifyThread(t) {
|
||||
const last = t.messages[t.messages.length - 1]
|
||||
const prev = seenLastMsg.get(t.id)
|
||||
seenLastMsg.set(t.id, last?.id || '')
|
||||
// prev undefined = a brand-new thread, which should notify. Not when the
|
||||
// thread is already on screen.
|
||||
if (!last || last.admin || prev === last.id || t.id === selected.value) return
|
||||
notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () => {
|
||||
selected.value = t.id
|
||||
})
|
||||
}
|
||||
|
||||
function wsSend(obj) {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws || !mounted) return
|
||||
conn.value = 'connecting'
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
|
||||
clearTimeout(connectWatchdog)
|
||||
connectWatchdog = watchConnecting(ws, 'messages')
|
||||
ws.onopen = () => {
|
||||
conn.value = 'open'
|
||||
reconnects.opened()
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
let msg = null
|
||||
try {
|
||||
msg = JSON.parse(event.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// The type-less snapshot is analytics data, not for this view.
|
||||
if (msg?.type === 'threads') {
|
||||
threads.value = msg.threads
|
||||
for (const t of msg.threads) seenLastMsg.set(t.id, lastMsg(t)?.id || '')
|
||||
} else if (msg?.type === 'thread') {
|
||||
notifyThread(msg)
|
||||
upsertThread(msg)
|
||||
} else if (msg?.type === 'contact-error') {
|
||||
console.warn('contact:', msg.detail)
|
||||
}
|
||||
}
|
||||
// No error banner from onerror: it is always followed by onclose, which
|
||||
// probes the cause — a lost login gets the login flow, not an error loop.
|
||||
ws.onclose = onSocketClose
|
||||
}
|
||||
|
||||
// The socket dropped. Find out why before retrying (./reconnect's
|
||||
// authReconnect): an expired session fails the handshake exactly like a
|
||||
// dead network, but retrying the socket loops forever — the WS API cannot
|
||||
// open the login dialog. A lost session runs the paskia login flow and
|
||||
// reconnects the moment it completes; an aborted login (declined, or a
|
||||
// proxy offering no paskia flow) closes the inbox; anything else gets the
|
||||
// paced backoff.
|
||||
async function onSocketClose() {
|
||||
ws = null
|
||||
if (!mounted) return
|
||||
const gen = epoch
|
||||
const wait = reconnects.closed()
|
||||
const outcome = await authReconnect(() => { conn.value = 'login' })
|
||||
if (!mounted || gen !== epoch) return
|
||||
if (outcome === 'auth') {
|
||||
connectNow()
|
||||
} else if (outcome === 'abort') {
|
||||
props.state.open = false
|
||||
} else {
|
||||
scheduleRetry(wait)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRetry(wait) {
|
||||
clearTimeout(reconnectTimer)
|
||||
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
||||
conn.value = 'waiting'
|
||||
reconnectTimer = setTimeout(connect, wait)
|
||||
}
|
||||
|
||||
// Connect (or recycle) right away, skipping any pending backoff: used after
|
||||
// login and when the page wakes — a socket that survived sleep still looks
|
||||
// OPEN but is dead (the server gave up on it while we were asleep), and
|
||||
// only a fresh connect pushes the full inbox that catches the view up.
|
||||
function connectNow() {
|
||||
epoch++
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
connect()
|
||||
}
|
||||
|
||||
// Waking up (sleep, or a long while hidden): recycle the connection rather
|
||||
// than trust a zombie socket or wait out a stale backoff. A short tab
|
||||
// switch with a live socket is left alone.
|
||||
const WAKE_RECYCLE_MS = 30_000
|
||||
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hiddenAt = Date.now()
|
||||
return
|
||||
}
|
||||
if (!mounted || conn.value === 'login') return
|
||||
const longAway = hiddenAt && Date.now() - hiddenAt > WAKE_RECYCLE_MS
|
||||
hiddenAt = 0
|
||||
if (conn.value !== 'open' || longAway) connectNow()
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
if (mounted && conn.value !== 'open' && conn.value !== 'login') connectNow()
|
||||
}
|
||||
|
||||
function onPageShow(ev) {
|
||||
// A bfcache restore comes back with dead sockets.
|
||||
if (ev.persisted && mounted) connectNow()
|
||||
}
|
||||
|
||||
// paskia's auth iframe announces its result as a window message: a login
|
||||
// completed anywhere in the page reconnects at once instead of waiting out
|
||||
// the backoff timer; a logout closes the inbox — it is useless without a
|
||||
// session.
|
||||
function onAuthMessage(ev) {
|
||||
if (!mounted) return
|
||||
const type = ev.data?.type
|
||||
if (type === 'auth-success') {
|
||||
if (conn.value === 'open' || conn.value === 'login') return
|
||||
connectNow()
|
||||
} else if (type === 'auth-logout') {
|
||||
epoch++
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
props.state.open = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
// The first connection takes a staggered slot (see ./reconnect).
|
||||
reconnectTimer = setTimeout(connect, socketSlot())
|
||||
addEventListener('visibilitychange', onVisibility)
|
||||
addEventListener('online', onOnline)
|
||||
addEventListener('pageshow', onPageShow)
|
||||
addEventListener('message', onAuthMessage)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mounted = false
|
||||
clearTimeout(reconnectTimer)
|
||||
clearTimeout(connectWatchdog)
|
||||
removeEventListener('visibilitychange', onVisibility)
|
||||
removeEventListener('online', onOnline)
|
||||
removeEventListener('pageshow', onPageShow)
|
||||
removeEventListener('message', onAuthMessage)
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
})
|
||||
|
||||
const thread = computed(() => threads.value.find((t) => t.id === selected.value) || null)
|
||||
|
||||
// Auto-open the first thread once the list arrives.
|
||||
watch(
|
||||
() => threads.value.length,
|
||||
(n) => {
|
||||
if (n && !selected.value) selected.value = threads.value[0].id
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Opening a thread marks it read on the server (the updated payload
|
||||
// comes back around and clears the dot).
|
||||
watch(thread, (t) => {
|
||||
if (!t) return
|
||||
if (t.unread) wsSend({ type: 'read', id: t.id })
|
||||
scrollDown()
|
||||
})
|
||||
|
||||
watch(() => thread.value?.messages?.length, scrollDown)
|
||||
|
||||
async function scrollDown() {
|
||||
await nextTick()
|
||||
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
|
||||
}
|
||||
|
||||
function lastMsg(t) {
|
||||
return t.messages[t.messages.length - 1] || null
|
||||
}
|
||||
|
||||
// List-row preview: the last message's visible text (the html is
|
||||
// server-rendered strict markdown, safe to parse here).
|
||||
function preview(t) {
|
||||
const m = lastMsg(t)
|
||||
if (!m) return ''
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = m.html
|
||||
const who = m.admin ? `${m.admin}: ` : ''
|
||||
return (who + (div.textContent || '')).slice(0, 80)
|
||||
}
|
||||
|
||||
// Relative timestamps like elsewhere in analytics.
|
||||
const fmtTime = (t) => formatWhen(t)
|
||||
|
||||
function send(md) {
|
||||
// First reply doubles as the notification-permission gesture.
|
||||
askNotifyPermission()
|
||||
if (thread.value) wsSend({ type: 'reply', id: thread.value.id, text: md })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConnNote :text="connNote" />
|
||||
<div class="messages-view">
|
||||
<aside class="thread-list">
|
||||
<p v-if="!threads.length" class="empty">no messages yet</p>
|
||||
<button
|
||||
v-for="t in threads"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="thread-row"
|
||||
:class="{ active: t.id === selected, archived: t.closed }"
|
||||
@click="selected = t.id"
|
||||
>
|
||||
<span class="dot" :class="{ unread: t.unread && !t.closed }"></span>
|
||||
<span class="peek">
|
||||
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
|
||||
<em v-if="t.closed" class="archived-tag">archived</em>
|
||||
{{ preview(t) }}
|
||||
</span>
|
||||
<time>{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
|
||||
</button>
|
||||
</aside>
|
||||
<section v-if="thread" class="thread">
|
||||
<header v-if="thread.name" class="thread-head">
|
||||
<strong>{{ thread.name }}</strong>
|
||||
<a v-if="thread.email" :href="`mailto:${thread.email}`">{{ thread.email }}</a>
|
||||
</header>
|
||||
<div ref="scroll" class="thread-messages">
|
||||
<div
|
||||
v-for="m in thread.messages"
|
||||
:key="m.id"
|
||||
class="contact-msg"
|
||||
:class="{ mine: m.admin }"
|
||||
>
|
||||
<!-- Server-rendered strict markdown only (see ContactPanel). -->
|
||||
<header>
|
||||
<span class="who">{{ m.admin || thread.name || 'visitor' }}</span>
|
||||
<time>{{ fmtTime(m.t) }}</time>
|
||||
</header>
|
||||
<div class="contact-msg-body" v-html="m.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
<Composer
|
||||
:placeholder="thread.closed ? 'archived — the visitor closed this conversation' : 'Reply…'"
|
||||
:send-disabled="thread.closed"
|
||||
@send="send"
|
||||
/>
|
||||
</section>
|
||||
<section v-else class="thread">
|
||||
<p class="empty">select a thread</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.messages-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.thread-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 16rem;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.thread-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.4rem;
|
||||
background: none;
|
||||
color: var(--text, CanvasText);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thread-row.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.thread-row .dot {
|
||||
flex-shrink: 0;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
.thread-row .dot.unread {
|
||||
background: var(--accent);
|
||||
}
|
||||
.thread-row .peek {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--muted);
|
||||
}
|
||||
.thread-row .peek strong {
|
||||
color: var(--text, CanvasText);
|
||||
font-weight: 600;
|
||||
}
|
||||
.thread-row.archived {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.thread-row .archived-tag {
|
||||
font-style: normal;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.6em;
|
||||
padding: 0 0.4em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.thread-row time {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.thread {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.thread-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.thread-head {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.thread-head a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.thread-head a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.contact-msg {
|
||||
padding: 0.55rem 0;
|
||||
}
|
||||
.contact-msg + .contact-msg {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.contact-msg-body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.contact-msg-body :deep(p) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.contact-msg-body :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.contact-msg-body :deep(a.attachment) {
|
||||
display: inline-block;
|
||||
padding: 0.05em 0.5em;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1em;
|
||||
background: var(--bg, Canvas);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.contact-msg header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.contact-msg.mine header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.contact-msg header .who {
|
||||
font-weight: 600;
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.messages-view {
|
||||
flex-direction: column;
|
||||
}
|
||||
.thread-list {
|
||||
width: 100%;
|
||||
max-height: 30%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@ import { html } from '@codemirror/lang-html'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
|
||||
import { apiFetch, apiJson } from 'paskia'
|
||||
import { editorLang } from './editorLang'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import { flagFor, langName, langSort } from './langs'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
@@ -57,6 +60,103 @@ async function errorDetail(res) {
|
||||
// empty brand removes the header link and the title suffix entirely.
|
||||
const brand = ref('')
|
||||
const theme = ref('')
|
||||
|
||||
// --- Messaging (contact) ---------------------------------------------------
|
||||
// Contact messaging settings: a two-row grid (labels above, the flag
|
||||
// selector and the boxes below). The label (the {contact:} buttons'
|
||||
// label and the visitor dialog's title) doubles as the on/off switch:
|
||||
// empty = disabled. The label and email subject are per-language
|
||||
// strings — the LangSelect (the article editor's flag dropdown, on the
|
||||
// shared shell-wide editorLang) switches the two fields between the
|
||||
// original and a translation override (stored in the chunk-keyed trans
|
||||
// store; auto translators fill the same store, so this is the
|
||||
// manual-override UI). The email and mail server are global: the
|
||||
// email's reveal after the PoW solve is server-side, and the mail
|
||||
// server (a mailbridge URL, docs/messages.md; empty = no email) is
|
||||
// reserved for email notifications.
|
||||
const contactEmail = ref('')
|
||||
const contactLabel = ref('')
|
||||
const contactSubject = ref('')
|
||||
const contactMailserver = ref('')
|
||||
const contactLabelOrig = ref('')
|
||||
const contactSubjectOrig = ref('')
|
||||
// Password managers offer credential autofill on these boxes no matter
|
||||
// the autocomplete setting; readonly until the first interaction keeps
|
||||
// them off (the vendors' ignore attributes on the inputs back it up).
|
||||
const pmGuard = ref(true)
|
||||
const pmUnlock = () => { pmGuard.value = false }
|
||||
const lang = editorLang // '' = the original strings (the primary language)
|
||||
const primaryLang = ref('en')
|
||||
const translateLangs = ref([])
|
||||
const translating = computed(() => lang.value && lang.value !== primaryLang.value)
|
||||
|
||||
// The flag selector's options, built like the article editor's: the
|
||||
// primary language first (tag ''), then the configured targets in the
|
||||
// geographic grouping.
|
||||
const contactLangOptions = computed(() => {
|
||||
const others = langSort(translateLangs.value.filter((l) => l && l !== primaryLang.value))
|
||||
return [primaryLang.value, ...others].map((code) => ({
|
||||
tag: code === primaryLang.value ? '' : code,
|
||||
code,
|
||||
name: langName(code),
|
||||
flag: flagFor(code),
|
||||
primary: code === primaryLang.value,
|
||||
}))
|
||||
})
|
||||
|
||||
async function onContactLangChange() {
|
||||
if (!translating.value) {
|
||||
contactLabel.value = contactLabelOrig.value
|
||||
contactSubject.value = contactSubjectOrig.value
|
||||
return
|
||||
}
|
||||
try {
|
||||
const s = await apiJson(`/_api/settings?lang=${lang.value}`)
|
||||
contactLabel.value = s.contact_label_tr || ''
|
||||
contactSubject.value = s.contact_subject_tr || ''
|
||||
} catch { /* keep empty */ }
|
||||
}
|
||||
watch(lang, onContactLangChange)
|
||||
|
||||
function onContactInput() {
|
||||
// Capture the language being edited: a debounced save must not land in
|
||||
// whatever language happens to be selected when the timer fires.
|
||||
const tag = lang.value
|
||||
if (!tag || tag === primaryLang.value) {
|
||||
contactLabelOrig.value = contactLabel.value
|
||||
contactSubjectOrig.value = contactSubject.value
|
||||
}
|
||||
debounce('contact', () => saveContact(tag, contactLabel.value, contactSubject.value))
|
||||
}
|
||||
|
||||
async function saveContact(tag = lang.value, label = '', subject = '') {
|
||||
if (tag && tag !== primaryLang.value) {
|
||||
// Translation override: only the label/subject apply (empty = drop).
|
||||
const res = await apiFetch(`/_api/settings?lang=${tag}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
brand: brand.value,
|
||||
theme: theme.value,
|
||||
custom_css: customCss.value,
|
||||
contact_label: label,
|
||||
contact_subject: subject,
|
||||
}),
|
||||
})
|
||||
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
||||
if (res.ok) rerender()
|
||||
return
|
||||
}
|
||||
if (await saveSettings()) rerender()
|
||||
}
|
||||
|
||||
// The rendered {contact:} buttons are server HTML: after a save the page
|
||||
// re-renders (like a theme change), updating buttons and dialog strings
|
||||
// without a reload.
|
||||
async function rerender() {
|
||||
dropPageCache()
|
||||
loadPlain(path.value)
|
||||
}
|
||||
// Theme options come from the backend (theme folders on disk, see GET
|
||||
// /_api/settings), so added themes need no frontend changes.
|
||||
const themeOptions = ref([{ value: '', label: '☀️ none' }])
|
||||
@@ -88,6 +188,16 @@ async function loadSettings() {
|
||||
]
|
||||
transition.value = s.transition || 'cube'
|
||||
transitionOptions.value = s.transitions || []
|
||||
contactEmail.value = s.contact_email || ''
|
||||
contactMailserver.value = s.contact_mailserver || ''
|
||||
contactLabelOrig.value = s.contact_label || ''
|
||||
contactSubjectOrig.value = s.contact_subject || ''
|
||||
if (!translating.value) {
|
||||
contactLabel.value = contactLabelOrig.value
|
||||
contactSubject.value = contactSubjectOrig.value
|
||||
}
|
||||
primaryLang.value = s.primary_lang || 'en'
|
||||
translateLangs.value = s.translate_langs || []
|
||||
fontOptions.value = [
|
||||
...BASE_FONT_OPTIONS,
|
||||
...(s.fonts || []).map((f) => ({
|
||||
@@ -255,6 +365,12 @@ async function saveSettings(opts = {}) {
|
||||
custom_css: customCss.value,
|
||||
brand_html: brandHtml.value,
|
||||
transition: transition.value,
|
||||
contact_email: contactEmail.value,
|
||||
contact_mailserver: contactMailserver.value,
|
||||
// The originals: while a translation override is being edited the
|
||||
// input refs hold the override, not the original.
|
||||
contact_label: contactLabelOrig.value,
|
||||
contact_subject: contactSubjectOrig.value,
|
||||
...opts,
|
||||
}),
|
||||
})
|
||||
@@ -264,6 +380,7 @@ async function saveSettings(opts = {}) {
|
||||
} else {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
return res.ok
|
||||
}
|
||||
|
||||
async function onThemeChange() {
|
||||
@@ -528,6 +645,9 @@ onMounted(async () => {
|
||||
parent: brandEl.value,
|
||||
})
|
||||
await loadSettings()
|
||||
// The shared editorLang may already point at a translation (picked in
|
||||
// another tab): load its label/subject overrides.
|
||||
await onContactLangChange()
|
||||
parseFonts(customCss.value)
|
||||
setCssDocument(customCss.value)
|
||||
applyCustomCss(customCss.value)
|
||||
@@ -655,6 +775,88 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="msg-grid">
|
||||
<div class="msg-flag">
|
||||
<span class="field-label" aria-hidden="true"></span>
|
||||
<LangSelect
|
||||
v-model="lang"
|
||||
:options="contactLangOptions"
|
||||
title="Language of the button label and email subject strings (edits the translation override)"
|
||||
/>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">contact button label</span>
|
||||
<input
|
||||
v-model="contactLabel"
|
||||
class="text-input"
|
||||
:placeholder="translating ? contactLabelOrig : ''"
|
||||
title="The {contact:} button label and visitor dialog title — empty disables messaging"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
@pointerdown="pmUnlock"
|
||||
@focus="pmUnlock"
|
||||
@input="onContactInput"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">email subject</span>
|
||||
<input
|
||||
v-model="contactSubject"
|
||||
class="text-input"
|
||||
:placeholder="translating ? contactSubjectOrig : ''"
|
||||
title="Subject line for the mailto link — empty = the button label"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
@pointerdown="pmUnlock"
|
||||
@focus="pmUnlock"
|
||||
@input="onContactInput"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">our email</span>
|
||||
<input
|
||||
v-model="contactEmail"
|
||||
class="text-input"
|
||||
title="A mailto link in the visitor dialog, revealed after the visitor solves the proof of work — empty = no link"
|
||||
:disabled="!!translating"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
@pointerdown="pmUnlock"
|
||||
@focus="pmUnlock"
|
||||
@input="debounce('contact-email', () => saveSettings())"
|
||||
/>
|
||||
</label>
|
||||
<!-- Mail server (SMTP notification backend): plumbing in place
|
||||
(contactMailserver ref, settings load/save, Data field) but
|
||||
the sending is not implemented yet — the field stays hidden
|
||||
until then. Re-adds a fourth column to .msg-grid.
|
||||
<label class="field">
|
||||
<span class="field-label">mail server</span>
|
||||
<input
|
||||
v-model="contactMailserver"
|
||||
class="text-input"
|
||||
title="Server for email notifications — smtp://user:pass@host:port, a bare host[:port], or a backend URL like sendgrid:API-KEY — empty = no email"
|
||||
:disabled="!!translating"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
@pointerdown="pmUnlock"
|
||||
@focus="pmUnlock"
|
||||
@input="debounce('contact-mailserver', () => saveSettings())"
|
||||
/>
|
||||
</label>
|
||||
-->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block grow" @paste="onBrandPaste">
|
||||
<div class="block-head">
|
||||
<span class="field-label">brand code (replaces the brand link)</span>
|
||||
@@ -785,6 +987,48 @@ onUnmounted(() => {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* The messaging block: a two-row grid — labels on the first row, the
|
||||
article editor's flag selector and the boxes on the second, so the
|
||||
flag centers with the boxes and every label sits directly above its
|
||||
own field (contact button label = the enable switch, email subject,
|
||||
our email; the hidden mail-server field joins the email group once the
|
||||
backend lands). The flag column sizes
|
||||
to the flag; when only one language is configured the selector renders
|
||||
nothing and the column collapses.
|
||||
The boxes are readonly until first interaction (+ vendor ignore
|
||||
attributes): password managers offer credential autofill on them no
|
||||
matter the autocomplete setting. */
|
||||
.msg-grid {
|
||||
display: grid;
|
||||
/* The hidden mail-server field (template comment) re-adds a column. */
|
||||
grid-template-columns: auto repeat(3, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Field wrappers vanish into the grid: labels land on row 1 (the
|
||||
flag's empty spacer keeps the fields in the 1fr columns whether or
|
||||
not the flag renders), the flag and boxes on row 2. */
|
||||
.msg-grid .field,
|
||||
.msg-grid .msg-flag {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.msg-grid .field-label {
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-grid .lang-select,
|
||||
.msg-grid .text-input {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.msg-grid .text-input {
|
||||
flex: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
|
||||
@@ -24,6 +24,9 @@ const title = computed(() => {
|
||||
if (hasError.value) {
|
||||
parts.push(`${props.step.status}`)
|
||||
}
|
||||
if (props.step.contact) {
|
||||
parts.push('contact panel opened, proof of work solved')
|
||||
}
|
||||
return parts.filter(Boolean).join(' — ')
|
||||
})
|
||||
</script>
|
||||
@@ -39,6 +42,7 @@ const title = computed(() => {
|
||||
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
||||
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
|
||||
<span>{{ step.slug }}</span>
|
||||
<span v-if="step.contact" class="contact-mark">✉️</span>
|
||||
<span v-for="(f, fi) in flags" :key="fi" class="flag" v-html="f"></span>
|
||||
</a>
|
||||
</template>
|
||||
@@ -51,6 +55,12 @@ const title = computed(() => {
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
/* The contact-panel marker (PoW solved on this step). */
|
||||
.contact-mark {
|
||||
margin-left: 0.25em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Same flag chip as the visitor cells (VisitorCell.vue). */
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -656,6 +656,8 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
|
||||
if (item.read) step.readSeconds = item.read
|
||||
if (item.status) step.status = item.status
|
||||
if (item.lang) step.lang = item.lang
|
||||
// Contact panel opened here and its PoW solved (✉️ marker).
|
||||
if (item.contact) step.contact = true
|
||||
}
|
||||
return step
|
||||
})
|
||||
@@ -667,6 +669,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
|
||||
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
|
||||
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
|
||||
if (step.status) prev.status = step.status
|
||||
if (step.contact) prev.contact = true
|
||||
} else {
|
||||
step.langs = step.lang ? [step.lang] : []
|
||||
trail.push(step)
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
--font-heading: var(--font-source-serif);
|
||||
/* The brand follows the heading font unless overridden separately. */
|
||||
--font-brand: var(--font-heading);
|
||||
/* Navbar typography (family and weight only — size/letter-case stay
|
||||
per-theme #nav rules); the {contact:} trigger button matches it. */
|
||||
--font-nav: var(--font-body);
|
||||
--nav-weight: 400;
|
||||
--font-code: var(--font-fira-code);
|
||||
/* Target x-height ratio for code text: set to the body font's ratio
|
||||
(here Source Sans 3's 0.478) so font-size-adjust can scale the code
|
||||
@@ -258,6 +262,8 @@ body {
|
||||
|
||||
/* Nav overlaid at the bottom of the banner */
|
||||
#nav {
|
||||
font-family: var(--font-nav);
|
||||
font-weight: var(--nav-weight);
|
||||
font-size: 1.3em;
|
||||
padding: 0.35rem 1.25rem;
|
||||
}
|
||||
@@ -407,6 +413,22 @@ body.editing #sidebar {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* The corner ✉️ (docs/messages.md) is in the pens row for everyone, so
|
||||
needs its own positioning context. Unread (a new reply for visitors,
|
||||
unanswered threads for admins) lifts it out of the icon-btn dimming
|
||||
and animates it (see contact-nudge by the trigger button).
|
||||
✉️'s envelope glyph runs smaller than its emoji neighbors (📊, 🔐, the
|
||||
flag) at the same font size; bump it to match their height (scoped to
|
||||
.editor-pens to out-specify the row's `font: inherit` reset). */
|
||||
.editor-pens .contact-icon {
|
||||
position: relative;
|
||||
font-size: 1.38em;
|
||||
}
|
||||
|
||||
.contact-icon.has-unread {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
grid-column: 1;
|
||||
/* Pinned to the page's start edge (not the article's) and kept in view
|
||||
@@ -878,6 +900,76 @@ 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.
|
||||
Filled with the accent and hovered like the accent-filled navbar tab:
|
||||
text in --ink (nitro; --bg elsewhere), hover switches to the bright
|
||||
--tab-hover with the same glow (text-shadow, which fades dimmer, so
|
||||
the color stays bright). */
|
||||
.contact-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
margin: 0.3em 0.1em;
|
||||
padding: 0.35em 1.1em;
|
||||
/* Same font and weight as the navbar, size/leading as the article text. */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-family: var(--font-nav);
|
||||
font-weight: var(--nav-weight);
|
||||
color: var(--ink, var(--bg));
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 1.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-trigger .icon {
|
||||
/* Match the corner ✉️'s optical size against the button text. The
|
||||
reduced line-height keeps the larger glyph from inflating the
|
||||
button: 1.38 × 1.2 ≈ the label text's own 1.65 line box. */
|
||||
font-size: 1.38em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.contact-trigger:hover {
|
||||
color: var(--tab-hover, var(--bg));
|
||||
text-shadow: 0 0 0.1em;
|
||||
}
|
||||
|
||||
/* Unread (a new admin reply, or unanswered threads on the corner icon —
|
||||
the .has-unread class, set by contact-main): a scale-up with a shake
|
||||
every few seconds to draw the eye, stopping when read (the class goes
|
||||
away). No static badge; reduced-motion users just get the lift above. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.contact-trigger.has-unread,
|
||||
.contact-icon.has-unread {
|
||||
animation: contact-nudge 4.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes contact-nudge {
|
||||
0%, 16%, 100% { transform: none; }
|
||||
2% { transform: scale(1.15) rotate(-8deg); }
|
||||
5% { transform: scale(1.15) rotate(7deg); }
|
||||
8% { transform: scale(1.15) rotate(-5deg); }
|
||||
11% { transform: scale(1.15) rotate(4deg); }
|
||||
14% { transform: scale(1.12); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Any button in the article flow (the {contact:} trigger and raw
|
||||
<button>s in the markup — not the .icon-btn editor pens or .copy):
|
||||
a slight lift on hover. */
|
||||
article button:not(.icon-btn):not(.copy) {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
article button:not(.icon-btn):not(.copy):hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
// Messaging overlay entry: imported on demand by pagerite.js when a
|
||||
// {contact:} trigger button or the corner ✉️ icon is clicked — and right
|
||||
// at page load when there is a live message session (a visitor's open
|
||||
// conversation, flagged in localStorage, resumes checking for replies
|
||||
// without waiting for the panel; for admins the always-on inbox watch
|
||||
// keeps the badge and notifications live while messaging is enabled).
|
||||
// The icon opens the admin inbox for admins, the visitor thread for
|
||||
// visitors; the trigger buttons always open the visitor thread (so
|
||||
// admins can test both sides). The visitor thread rides the activity
|
||||
// socket through the bridge pagerite.js hands over ({send, register}) —
|
||||
// no extra connection; the admin inbox connects its own admin-gated
|
||||
// socket (MessagesView while open, the background watch otherwise). The
|
||||
// session token is a localStorage random; the server stores only its
|
||||
// hash. Closing a conversation archives the thread server-side, wipes
|
||||
// the local session keys and rotates the token, so the next message
|
||||
// starts an all-new thread.
|
||||
import { createApp, reactive, watch } from 'vue'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import ContactOverlay from './ContactOverlay.vue'
|
||||
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
|
||||
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect.js'
|
||||
|
||||
const TOKEN_KEY = 'pagerite-contact-token'
|
||||
const SEEN_KEY = 'pagerite-contact-seen'
|
||||
// The newest admin reply seen, so the corner icon's unread dot survives
|
||||
// page loads without any socket activity (pagerite.js reads it too).
|
||||
const LASTADMIN_KEY = 'pagerite-contact-lastadmin'
|
||||
// An open conversation: set while the visitor has a live thread, so page
|
||||
// loads resume checking for replies immediately (cleared on close).
|
||||
const SESSION_KEY = 'pagerite-contact-session'
|
||||
|
||||
let app = null
|
||||
const state = reactive({
|
||||
open: false,
|
||||
mode: 'visitor', // 'visitor' | 'admin'
|
||||
thread: null,
|
||||
unread: false, // visitor: an unread admin reply
|
||||
adminUnread: 0, // admin: unanswered threads
|
||||
viewerAdmin: false, // set once the inbox has been opened: icon badge = adminUnread
|
||||
// The session token (a localStorage random; the server stores only its
|
||||
// hash). Reactive in state because closing a conversation rotates it
|
||||
// and the composer's uploads send it as a header.
|
||||
token: '',
|
||||
// Localized site contact strings (pagerite:contact-* asset config,
|
||||
// refreshed on every open — they change with the page language):
|
||||
// label doubles as the visitor dialog's title; email gets a mailto
|
||||
// link with subject prefilled.
|
||||
label: 'Contact us',
|
||||
email: '',
|
||||
mailtoTo: '', // RFC-encoded "Brand <email>" recipient for the mailto href
|
||||
subject: '',
|
||||
})
|
||||
|
||||
// The localized contact strings ride the page's asset config (the
|
||||
// production JSON block, or pagerite:* meta tags in dev).
|
||||
function readContactStrings() {
|
||||
let assets = {}
|
||||
const el = document.getElementById('pagerite-assets')
|
||||
if (el) {
|
||||
try {
|
||||
assets = JSON.parse(el.textContent)
|
||||
} catch { /* keep empty */ }
|
||||
} else {
|
||||
for (const m of document.querySelectorAll('meta[name^="pagerite:contact-"]')) {
|
||||
assets[m.name] = m.content
|
||||
}
|
||||
}
|
||||
state.label = assets['pagerite:contact-label'] || 'Contact us'
|
||||
state.subject = assets['pagerite:contact-subject'] || state.label
|
||||
// state.email is NOT reset here: it arrives over the socket after the
|
||||
// PoW is solved and stays valid for the session.
|
||||
}
|
||||
|
||||
// Page swaps (fetch navigation, editor re-renders after settings saves)
|
||||
// carry fresh per-language config: keep the dialog strings in sync
|
||||
// without reopening.
|
||||
addEventListener('pagerite:page-fetched', readContactStrings)
|
||||
|
||||
// The panel is main content, not a sidebar: while open it holds paskia's
|
||||
// shared blur backdrop (refcounted, z-index 1099; the overlay itself sits
|
||||
// at 1100, below the auth iframe's 9999).
|
||||
watch(() => state.open, (open) => {
|
||||
if (open) holdGlobalBackdrop()
|
||||
else releaseGlobalBackdrop()
|
||||
})
|
||||
|
||||
watch(() => state.adminUnread, () => refreshBadges())
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// PoW (easypow, docs/messages.md): the server issues a fresh single-use
|
||||
// challenge on registration/resync and whenever the quota runs out. The
|
||||
// solver runs in the background and the solution is submitted as soon as
|
||||
// it is ready; a validated proof grants a quota of messages and uploads
|
||||
// and reveals the site's contact email (never carried in the page's
|
||||
// asset config). The solver module is served by the backend from the
|
||||
// easypow package, so both sides always match.
|
||||
const SOLVER_URL = '/_contact/easypow.js' // backend-served; kept in a variable so Rollup leaves the import dynamic
|
||||
let bridge = null
|
||||
let solverPromise = null
|
||||
let powSolution = null // Promise<string> solving the current challenge
|
||||
let powAborter = null
|
||||
let unsent = null // last message frame, retried once when its send races the quota
|
||||
let quotaWaiters = [] // uploads waiting for a re-granted quota
|
||||
|
||||
// The solved solution goes out immediately, ahead of any send.
|
||||
async function submitProof() {
|
||||
const p = powSolution
|
||||
if (!p) return
|
||||
try {
|
||||
const pow = await p
|
||||
if (pow && p === powSolution) bridge.send({ token: state.token, pow })
|
||||
} catch { /* aborted: a newer solve is in flight */ }
|
||||
}
|
||||
|
||||
function sendMessage(md, name = '', email = '') {
|
||||
// The first message of a fresh thread doubles as the
|
||||
// notification-permission gesture (denied is never re-asked; a
|
||||
// dismissed prompt comes up again on the next fresh thread).
|
||||
if (!localStorage.getItem(SESSION_KEY)) askNotifyPermission()
|
||||
unsent = { md, name, email, retried: false }
|
||||
bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
|
||||
}
|
||||
|
||||
function onPow(msg) {
|
||||
if (typeof msg.quota === 'number') {
|
||||
// Proof accepted, quota granted: queued uploads and the racing send
|
||||
// (rejected for a stale/empty quota) continue.
|
||||
const waiters = quotaWaiters
|
||||
quotaWaiters = []
|
||||
for (const resolve of waiters) resolve(true)
|
||||
if (unsent && !unsent.retried) {
|
||||
const { md, name, email } = unsent
|
||||
unsent.retried = true
|
||||
bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
|
||||
}
|
||||
return
|
||||
}
|
||||
powAborter?.abort() // superseded challenge: stop the stale solve
|
||||
const ac = (powAborter = new AbortController())
|
||||
solverPromise ??= import(/* @vite-ignore */ SOLVER_URL)
|
||||
solverPromise.then(({ solve }) => {
|
||||
powSolution = solve(msg.challenge, ac.signal)
|
||||
powSolution.catch(() => {}) // aborts are expected
|
||||
submitProof()
|
||||
})
|
||||
}
|
||||
|
||||
function onEmail(msg) {
|
||||
state.email = msg.email || ''
|
||||
state.mailtoTo = msg.to || '' // RFC-encoded "Brand <email>" recipient
|
||||
}
|
||||
|
||||
// An upload hit the end of the PoW quota: re-register (the server pushes
|
||||
// a fresh challenge, solved and proven in the background) and resolve
|
||||
// once the new quota lands.
|
||||
function requestQuota() {
|
||||
return new Promise((resolve) => {
|
||||
quotaWaiters.push(resolve)
|
||||
bridge.send({ token: state.token }) // re-registration: a fresh challenge follows
|
||||
})
|
||||
}
|
||||
|
||||
// Unread markers: the has-unread class (a periodic nudge animation, see
|
||||
// pagerite.css) on every contact trigger button and on the corner ✉️ icon
|
||||
// — a new admin reply for visitors, any unanswered thread for admins.
|
||||
// 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)
|
||||
}
|
||||
const icon = document.getElementById('contact-icon')
|
||||
if (!icon) return
|
||||
icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread)
|
||||
}
|
||||
|
||||
let notifyBaseline = '' // the first push after load is history, only baselines
|
||||
|
||||
function onThread(thread) {
|
||||
state.thread = thread
|
||||
unsent = null // the send landed (a thread push acknowledges it)
|
||||
// A thread with messages is a live session: page loads resume checking
|
||||
// it right away (cleared when the conversation is closed).
|
||||
if (thread.messages.length) localStorage.setItem(SESSION_KEY, '1')
|
||||
const lastAdmin = [...thread.messages].reverse().find((m) => m.admin)
|
||||
if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t)
|
||||
const viewing = state.open && state.mode === 'visitor'
|
||||
state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !viewing
|
||||
// ISO timestamps compare lexicographically.
|
||||
if (lastAdmin && lastAdmin.t > notifyBaseline) {
|
||||
const fresh = Boolean(notifyBaseline)
|
||||
notifyBaseline = lastAdmin.t
|
||||
if (fresh && !viewing) {
|
||||
notifyMessage(state.label, messageText(lastAdmin.html), () => open(bridge))
|
||||
}
|
||||
}
|
||||
if (viewing) markSeen()
|
||||
else refreshBadges()
|
||||
}
|
||||
|
||||
// The visitor ended the conversation: archive it server-side (the admin
|
||||
// inbox keeps it), wipe the local session and rotate the token, so the
|
||||
// next message starts an all-new thread. The panel stays open on the
|
||||
// fresh empty state.
|
||||
function closeConversation() {
|
||||
if (!confirm('Close this conversation? A fresh thread starts with your next message.')) return
|
||||
bridge.send({ token: state.token, close: true })
|
||||
for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
state.token = ensureToken() // TOKEN_KEY is gone: a fresh random
|
||||
state.thread = null
|
||||
state.unread = false
|
||||
notifyBaseline = ''
|
||||
unsent = null
|
||||
bridge.register(state.token, onThread, onPow, onEmail)
|
||||
refreshBadges()
|
||||
}
|
||||
|
||||
let registered = false
|
||||
|
||||
// Registration doubles as the resync: the server pushes the thread state
|
||||
// back, and pagerite.js re-registers on every socket reconnect. Done once
|
||||
// per page (also without opening the panel — resume()), in either mode —
|
||||
// the visitor thread is also the admin's own (the trigger buttons open it
|
||||
// for them).
|
||||
function ensureRegistered(br) {
|
||||
bridge = br
|
||||
if (registered) return
|
||||
registered = true
|
||||
state.token = ensureToken()
|
||||
bridge.register(state.token, onThread, onPow, onEmail)
|
||||
}
|
||||
|
||||
// A live session found at page load: check for replies immediately,
|
||||
// without opening the panel (pagerite.js calls this when the session flag
|
||||
// is set; with a granted notification permission new replies notify right
|
||||
// away).
|
||||
export function resume(br) {
|
||||
ensureRegistered(br)
|
||||
}
|
||||
|
||||
export function open(br, { admin = false } = {}) {
|
||||
ensureRegistered(br)
|
||||
if (admin) state.viewerAdmin = true
|
||||
if (!app) {
|
||||
app = createApp(ContactOverlay, {
|
||||
state,
|
||||
reauthorize: requestQuota,
|
||||
onSend: ({ md, name, email }) => sendMessage(md, name, email),
|
||||
onClose: () => {
|
||||
state.open = false
|
||||
refreshBadges()
|
||||
},
|
||||
onCloseThread: closeConversation,
|
||||
})
|
||||
const el = document.createElement('div')
|
||||
el.id = 'contact-root'
|
||||
document.body.append(el)
|
||||
app.mount(el)
|
||||
}
|
||||
state.mode = admin ? 'admin' : 'visitor'
|
||||
readContactStrings()
|
||||
state.open = true
|
||||
if (!admin) markSeen()
|
||||
else refreshBadges()
|
||||
}
|
||||
|
||||
|
||||
// --- Admin watch -------------------------------------------------------------
|
||||
// For editors, while messaging is enabled on the site: a background
|
||||
// /_api/ws/analytics connection keeps the corner icon's badge live and
|
||||
// notifies new visitor messages right away — the admin always checks,
|
||||
// without waiting for the inbox to open (pagerite.js starts the watch at
|
||||
// page load). While the inbox is open MessagesView owns its own socket and
|
||||
// the notifications; the watch then only tracks the badge. Reconnect
|
||||
// pacing and the auth-aware close handling are the shared reconnect.js
|
||||
// policy, same as MessagesView's.
|
||||
let watchWs = null
|
||||
let watchStarted = false
|
||||
let watchTimer = null
|
||||
let watchEpoch = 0
|
||||
let watchHiddenAt = 0
|
||||
const watchReconnects = reconnectPolicy()
|
||||
const watchThreads = new Map() // id -> thread payload (badge + notify baseline)
|
||||
const WATCH_WAKE_MS = 30_000
|
||||
|
||||
function watchBadge() {
|
||||
state.adminUnread = [...watchThreads.values()].filter(
|
||||
(t) => t.unread && !t.closed
|
||||
).length
|
||||
}
|
||||
|
||||
function watchThread(t) {
|
||||
const last = t.messages[t.messages.length - 1]
|
||||
const prev = watchThreads.get(t.id)?.messages?.at(-1)?.id
|
||||
watchThreads.set(t.id, t)
|
||||
watchBadge()
|
||||
// No notification for history (the first 'threads' push baselines), the
|
||||
// admin's own replies, non-message updates (prev === last.id — or an
|
||||
// unread:false push, e.g. a read marking for a thread beyond the
|
||||
// baseline cap), closed threads — or while the inbox is open
|
||||
// (MessagesView notifies then).
|
||||
if (!last || last.admin || !t.unread || prev === last.id || t.closed) return
|
||||
if (state.open && state.mode === 'admin') return
|
||||
notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () =>
|
||||
open(bridge, { admin: true })
|
||||
)
|
||||
}
|
||||
|
||||
function watchConnect() {
|
||||
if (watchWs) return
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = (watchWs = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`))
|
||||
watchConnecting(ws, 'contact-watch')
|
||||
ws.onopen = () => watchReconnects.opened()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg = null
|
||||
try {
|
||||
msg = JSON.parse(ev.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (msg?.type === 'threads') {
|
||||
watchThreads.clear()
|
||||
for (const t of msg.threads) watchThreads.set(t.id, t)
|
||||
watchBadge()
|
||||
} else if (msg?.type === 'thread') {
|
||||
watchThread(msg)
|
||||
}
|
||||
}
|
||||
ws.onclose = async () => {
|
||||
if (watchWs !== ws) return
|
||||
watchWs = null
|
||||
const gen = watchEpoch
|
||||
const wait = watchReconnects.closed()
|
||||
const outcome = await authReconnect()
|
||||
if (gen !== watchEpoch) return
|
||||
if (outcome === 'auth') watchConnect()
|
||||
else if (outcome === 'retry') watchTimer = setTimeout(watchConnect, wait)
|
||||
// 'abort': no session — the next auth-success restarts the watch.
|
||||
}
|
||||
ws.onerror = () => ws.close()
|
||||
}
|
||||
|
||||
function watchConnectNow() {
|
||||
watchEpoch++
|
||||
clearTimeout(watchTimer)
|
||||
if (watchWs) {
|
||||
watchWs.onclose = null
|
||||
watchWs.close()
|
||||
watchWs = null
|
||||
}
|
||||
watchConnect()
|
||||
}
|
||||
|
||||
export function watchAdmin(br) {
|
||||
bridge = br
|
||||
if (watchStarted) return
|
||||
watchStarted = true
|
||||
watchTimer = setTimeout(watchConnect, socketSlot())
|
||||
addEventListener('online', () => {
|
||||
if (!watchWs) watchConnectNow()
|
||||
})
|
||||
addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
watchHiddenAt = Date.now()
|
||||
return
|
||||
}
|
||||
// A long-hidden tab's socket is a zombie: reconnect to catch up.
|
||||
if (watchHiddenAt && Date.now() - watchHiddenAt > WATCH_WAKE_MS) watchConnectNow()
|
||||
watchHiddenAt = 0
|
||||
})
|
||||
// A completed login (re)starts the watch at once; a logout stops it.
|
||||
addEventListener('message', (ev) => {
|
||||
const type = ev.data?.type
|
||||
if (type === 'auth-success') {
|
||||
if (!watchWs) watchConnectNow()
|
||||
} else if (type === 'auth-logout') {
|
||||
watchEpoch++
|
||||
clearTimeout(watchTimer)
|
||||
if (watchWs) {
|
||||
watchWs.onclose = null
|
||||
watchWs.close()
|
||||
watchWs = null
|
||||
}
|
||||
watchThreads.clear()
|
||||
state.adminUnread = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Browser notifications for contact messaging (docs/messages.md): the
|
||||
// visitor is notified of admin replies, the admin of new visitor
|
||||
// messages. Permission is requested at one entry point only — right
|
||||
// after the first message of a fresh thread (the Notification API
|
||||
// requires a user gesture). A denied permission is permanent in the
|
||||
// browser, so we never re-ask; a dismissed prompt stays "default" and is
|
||||
// asked again on the next fresh thread.
|
||||
|
||||
export function askNotifyPermission() {
|
||||
if (!('Notification' in window) || Notification.permission !== 'default') return
|
||||
Notification.requestPermission()
|
||||
}
|
||||
|
||||
// Fire a notification (a no-op without a granted permission). Clicking
|
||||
// it focuses the site tab and runs onclick (opening the thread/inbox
|
||||
// view), so the user reliably lands on what was new.
|
||||
export function notifyMessage(title, body, onclick) {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||
const n = new Notification(title, { body, icon: '/favicon.ico' })
|
||||
n.onclick = () => {
|
||||
window.focus()
|
||||
onclick?.()
|
||||
n.close()
|
||||
}
|
||||
}
|
||||
|
||||
// The visible text of a server-rendered strict-markdown message (safe to
|
||||
// parse: the stored html is markdown-it output, never user markup).
|
||||
export function messageText(html) {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
return (div.textContent || '').trim()
|
||||
}
|
||||
+192
-38
@@ -182,6 +182,37 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
return btn;
|
||||
}
|
||||
|
||||
// The corner ✉️ (for everyone): opens the messaging overlay — the
|
||||
// visitor's own thread, or the admin inbox for editors (the {contact:}
|
||||
// trigger buttons always open the visitor side, so admins can test it).
|
||||
// The unread marker is seeded without any socket: for visitors from
|
||||
// localStorage (the newest admin reply vs. the last look, both written
|
||||
// by the contact bundle), for editors from the /_api/settings probe's
|
||||
// contact_unread. Once the bundle has loaded, its refreshBadges keeps
|
||||
// the marker live.
|
||||
let adminUnreadSeed = 0;
|
||||
// Messaging disabled in the site settings (no pagerite:contact-label in
|
||||
// the asset config): no icon for visitors — admins keep theirs, the
|
||||
// inbox still holds the existing threads.
|
||||
const contactEnabled = !!assets["pagerite:contact-label"];
|
||||
function makeContactIcon(admin) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.id = "contact-icon";
|
||||
b.className = "contact-icon icon-btn";
|
||||
b.title = admin ? "messages" : (assets["pagerite:contact-label"] || "contact us");
|
||||
b.textContent = "✉️";
|
||||
if (admin) {
|
||||
if (adminUnreadSeed > 0) b.classList.add("has-unread");
|
||||
} else {
|
||||
const seen = Number(localStorage.getItem("pagerite-contact-seen") || 0);
|
||||
const last = localStorage.getItem("pagerite-contact-lastadmin") || "";
|
||||
if (last && Date.parse(last) > seen) b.classList.add("has-unread");
|
||||
}
|
||||
b.addEventListener("click", () => openContactPanel({ admin }));
|
||||
return b;
|
||||
}
|
||||
|
||||
// The banner top-right corner container: the language selector (first
|
||||
// item) plus the admin pens and auth links. renderAuthUi rebuilds it from
|
||||
// scratch; the selector's state lives in the shared store, not the DOM,
|
||||
@@ -213,13 +244,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||
// Paskia SSO is detected at all.
|
||||
const canEdit = isAdmin || !ssoAvailable;
|
||||
// The analytics page is a read-only dashboard: editing pens and the side
|
||||
// panel do not apply there. Login/logout links are still useful.
|
||||
const onAnalytics = currentPath === "/_a";
|
||||
// The analytics page is a read-only dashboard: editing pens and the
|
||||
// side panel do not apply there. Login/logout links are still useful.
|
||||
const onToolPage = currentPath === "/_a";
|
||||
if (document.getElementById("page-banner")) {
|
||||
const pens = pensContainer();
|
||||
if (canEdit && !onAnalytics) {
|
||||
// Analytics viewer is now a normal page at /_a.
|
||||
// The contact icon is there for everyone — except visitors when
|
||||
// messaging is disabled (admins keep the inbox).
|
||||
if (canEdit || contactEnabled) pens.append(makeContactIcon(canEdit));
|
||||
if (canEdit && !onToolPage) {
|
||||
// Analytics viewer is a normal page at /_a.
|
||||
const a = document.createElement("a");
|
||||
a.className = "edit-link analytics-link icon-btn";
|
||||
a.href = "/_a";
|
||||
@@ -231,7 +265,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
if (ssoAvailable) pens.append(makeAuthButton(isAdmin));
|
||||
if (!pens.firstElementChild) pens.remove();
|
||||
}
|
||||
if (canEdit && !onAnalytics) injectPagePen();
|
||||
if (canEdit && !onToolPage) injectPagePen();
|
||||
}
|
||||
// Re-mount the selector into the fresh container (no-op until the
|
||||
// bundle has been loaded once).
|
||||
@@ -263,8 +297,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
|
||||
isAdmin = false;
|
||||
try {
|
||||
await fetchJson("/_api/settings");
|
||||
const settings = await fetchJson("/_api/settings");
|
||||
isAdmin = true;
|
||||
// Seeds the contact icon's admin badge (docs/messages.md).
|
||||
adminUnreadSeed = settings.contact_unread || 0;
|
||||
} catch {
|
||||
// Anonymous, no pagerite:admin, or no auth proxy / dev.
|
||||
}
|
||||
@@ -285,6 +321,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
renderAuthUi();
|
||||
placeEditPen();
|
||||
pingEntryOnce();
|
||||
contactAutoStart();
|
||||
}
|
||||
|
||||
// Returning to the page via history back/forward may restore a cached
|
||||
@@ -456,6 +493,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 +667,22 @@ 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 === "pow") contactPowListener?.(msg);
|
||||
else if (msg?.type === "contact-email") contactEmailListener?.(msg);
|
||||
else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
@@ -640,7 +694,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
|
||||
function report(msg) {
|
||||
if (document.body.classList.contains("editing")) return;
|
||||
if (msg.to === "/_a") return;
|
||||
if (isToolPage(msg.to)) return;
|
||||
if (ssoAvailable && isAdmin) msg.hide = true;
|
||||
activityWs();
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
@@ -653,9 +707,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
}
|
||||
|
||||
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
|
||||
// Reading-time updates from the analytics page itself are not tracked
|
||||
// (/_a is admin machinery; the server would reject the path anyway).
|
||||
if (!to && currentPath === "/_a") return;
|
||||
// Reading-time updates from the tool pages themselves are not tracked
|
||||
// (admin machinery; the server would reject the path anyway).
|
||||
if (!to && isToolPage(currentPath)) return;
|
||||
const msg = {};
|
||||
if (fr) msg.fr = fr;
|
||||
if (to) msg.to = to;
|
||||
@@ -774,27 +828,39 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
ping({ to: currentPath, fr: "" });
|
||||
}
|
||||
|
||||
// --- Analytics page mount/unmount --------------------------------------
|
||||
// The analytics page is a normal page whose body is rendered by the server
|
||||
// but whose content is a Vue app. In dev the entry module is imported from
|
||||
// the Vite dev server on demand; in production it is inlined into the /_a
|
||||
// page as script#pagerite-js-analytics, which a fetch-navigation swap does
|
||||
// not execute — re-create the element so the fresh module auto-mounts on
|
||||
// #analytics-app (see analytics-main.js). The module exposes its unmount
|
||||
// as window.__pageriteAnalyticsUnmount.
|
||||
function teardownAnalytics() {
|
||||
// Remove even the server-rendered script element so a later return to
|
||||
// /_a re-mounts from a fresh copy (the module has torn itself down).
|
||||
document.getElementById("pagerite-js-analytics")?.remove();
|
||||
window.__pageriteAnalyticsUnmount?.();
|
||||
window.__pageriteAnalyticsUnmount = null;
|
||||
// --- Admin tool pages mount/unmount ------------------------------------
|
||||
// /_a (analytics) is a normal page whose body is rendered by the server
|
||||
// but whose content is a Vue app. In dev the
|
||||
// entry module is imported from the Vite dev server on demand; in
|
||||
// production it is inlined into the page as a script element, which a
|
||||
// fetch-navigation swap does not execute — re-create the element so the
|
||||
// fresh module auto-mounts on its container. Each module exposes its
|
||||
// unmount as a window global (see analytics-main.js).
|
||||
const TOOL_PAGES = {
|
||||
"/_a": { container: "analytics-app", script: "pagerite-js-analytics",
|
||||
asset: "pagerite:analytics-src", unmount: "__pageriteAnalyticsUnmount" },
|
||||
};
|
||||
|
||||
function isToolPage(path) {
|
||||
return Object.hasOwn(TOOL_PAGES, path || "");
|
||||
}
|
||||
|
||||
async function mountAnalytics(doc) {
|
||||
if (!doc.getElementById("analytics-app")) return;
|
||||
// Already mounted: on a full /_a load the inline script has run.
|
||||
if (document.getElementById("pagerite-js-analytics")) return;
|
||||
const inline = doc.getElementById("pagerite-js-analytics");
|
||||
function teardownTools() {
|
||||
// Remove even the server-rendered script element so a later return
|
||||
// re-mounts from a fresh copy (the module has torn itself down).
|
||||
for (const p of Object.values(TOOL_PAGES)) {
|
||||
document.getElementById(p.script)?.remove();
|
||||
window[p.unmount]?.();
|
||||
window[p.unmount] = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mountTool(doc) {
|
||||
const page = Object.values(TOOL_PAGES).find((p) => doc.getElementById(p.container));
|
||||
if (!page) return;
|
||||
// Already mounted: on a full page load the inline script has run.
|
||||
if (document.getElementById(page.script)) return;
|
||||
const inline = doc.getElementById(page.script);
|
||||
if (inline) {
|
||||
const s = document.createElement("script");
|
||||
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
|
||||
@@ -806,11 +872,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// Dev: the cached module auto-mounts only on its first evaluation,
|
||||
// so call mount() explicitly for repeat visits (it no-ops when the
|
||||
// app is already up).
|
||||
const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]);
|
||||
const container = document.getElementById("analytics-app");
|
||||
const mod = await import(/* @vite-ignore */ assets[page.asset]);
|
||||
const container = document.getElementById(page.container);
|
||||
if (container) mod.mount(container);
|
||||
} catch (e) {
|
||||
console.error("analytics mount failed:", e);
|
||||
console.error("tool page mount failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,6 +935,91 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
ping({ to: currentPath, lang: tag });
|
||||
});
|
||||
|
||||
// --- Messaging overlay ---------------------------------------------------
|
||||
// {contact: Label} directives in articles render .contact-trigger buttons,
|
||||
// and the corner ✉️ icon (rendered by renderAuthUi for everyone) opens the
|
||||
// same overlay: the visitor's thread, or the admin inbox for editors
|
||||
// (docs/messages.md). The bundle (Vue overlay + composer) loads on first
|
||||
// click and rides the activity socket for realtime thread state — no extra
|
||||
// connection for visitors. 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;
|
||||
let contactPowListener = null;
|
||||
let contactEmailListener = null;
|
||||
const contactBridge = {
|
||||
register(token, onThread, onPow, onEmail) {
|
||||
contactToken = token;
|
||||
contactListener = onThread;
|
||||
contactPowListener = onPow;
|
||||
contactEmailListener = onEmail;
|
||||
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 loadContact() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return contactMod;
|
||||
}
|
||||
|
||||
async function openContactPanel(opts) {
|
||||
try {
|
||||
(await loadContact()).open(contactBridge, opts);
|
||||
} catch (e) {
|
||||
console.error("contact panel load failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Message sessions follow the page load, not the panel: a visitor with
|
||||
// an open conversation (the bundle's localStorage session flag) resumes
|
||||
// checking for replies right away — with a granted notification
|
||||
// permission new replies notify immediately; an admin's inbox watch
|
||||
// (badge + notifications) runs whenever messaging is enabled.
|
||||
async function contactAutoStart() {
|
||||
if (!contactEnabled) return;
|
||||
const wantAdmin = isAdmin;
|
||||
const wantResume = !!localStorage.getItem("pagerite-contact-session");
|
||||
if (!wantAdmin && !wantResume) return;
|
||||
try {
|
||||
const mod = await loadContact();
|
||||
if (wantAdmin) mod.watchAdmin(contactBridge);
|
||||
if (wantResume) mod.resume(contactBridge);
|
||||
} catch (e) {
|
||||
console.error("contact session resume 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;
|
||||
// Always the visitor side, even for admins (testing both sides).
|
||||
openContactPanel({ admin: false });
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open keeps it open: the shell retargets to
|
||||
@@ -879,10 +1030,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// bypassed while editing because the editor may have invalidated the
|
||||
// prefetched copies of other pages.
|
||||
const editing = document.body.classList.contains("editing");
|
||||
if (editing && new URL(url, location.href).pathname === "/_a") {
|
||||
if (editing && isToolPage(new URL(url, location.href).pathname)) {
|
||||
await editorModule?.then((m) => m.closeEditor({ navigating: true }));
|
||||
}
|
||||
teardownAnalytics();
|
||||
teardownTools();
|
||||
let doc;
|
||||
let finalUrl = url;
|
||||
const cached = !editing && pageCache.get(cacheKey(url));
|
||||
@@ -976,8 +1127,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
runScripts(document.getElementById("page-banner"));
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
mountAnalytics(doc);
|
||||
mountTool(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/);
|
||||
@@ -1085,7 +1239,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
}
|
||||
// Machinery and auth endpoints are never fetch-navigated, except the
|
||||
// public analytics viewer page at /_a.
|
||||
if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
|
||||
if ((url.pathname.startsWith("/_") && !isToolPage(url.pathname))
|
||||
|| url.pathname.startsWith("/auth")) return;
|
||||
ev.preventDefault();
|
||||
// Capture the source now: load() updates currentPath before pinging.
|
||||
@@ -1205,6 +1359,6 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
|
||||
setupAuth();
|
||||
applyEffects();
|
||||
mountAnalytics(document);
|
||||
mountTool(document);
|
||||
mountLangselect(document);
|
||||
})();
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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')
|
||||
}
|
||||
|
||||
// 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 <b>), 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 = []
|
||||
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) {
|
||||
switch (node.tagName) {
|
||||
case 'UL':
|
||||
out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n'))
|
||||
break
|
||||
case 'OL':
|
||||
out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n'))
|
||||
break
|
||||
case 'BLOCKQUOTE':
|
||||
out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n'))
|
||||
break
|
||||
case 'PRE':
|
||||
out.push('```\n' + node.textContent + '\n```')
|
||||
break
|
||||
case 'HR':
|
||||
break
|
||||
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) {
|
||||
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
|
||||
}
|
||||
case 'IMG':
|
||||
// Uploaded image (data-path set by the composer on insert).
|
||||
return 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})`
|
||||
}
|
||||
return wrapStyle(node, inlineChildren(node))
|
||||
default:
|
||||
return wrapStyle(node, inlineChildren(node))
|
||||
}
|
||||
}
|
||||
|
||||
// Some browsers style formatting instead of tagging it (Firefox's
|
||||
// execCommand produces <span style="font-weight: bold">): 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 text
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate"] }),
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }),
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
||||
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
||||
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
|
||||
langselect: fileURLToPath(new URL('./src/langselect-main.js', import.meta.url)),
|
||||
contact: fileURLToPath(new URL('./src/contact-main.js', import.meta.url)),
|
||||
// Only the base CSS is built; theme/banner-design stylesheets live
|
||||
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
||||
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
||||
|
||||
@@ -148,6 +148,10 @@ class Msg(msgspec.Struct, omit_defaults=True):
|
||||
#: Rendered language reported by the client for the page the activity
|
||||
#: happened on.
|
||||
lang: str = ""
|
||||
#: Server-originated contact marker (docs/messages.md): the visitor
|
||||
#: opened the contact panel and solved its proof of work on ``fr``.
|
||||
#: Never sent by clients — recorded by the /_ws handler.
|
||||
contact: bool = False
|
||||
|
||||
|
||||
class Client(msgspec.Struct, omit_defaults=True):
|
||||
@@ -217,6 +221,9 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
|
||||
#: page falling back to its GET's rendered language; "" when unknown
|
||||
#: (old clients or data from before language recording).
|
||||
lang: str = ""
|
||||
#: The visitor opened the contact panel and solved its proof of work
|
||||
#: on this page (docs/messages.md) — the trail's ✉️ marker.
|
||||
contact: bool = False
|
||||
|
||||
|
||||
class Visit(msgspec.Struct, omit_defaults=True):
|
||||
@@ -711,6 +718,34 @@ class Store:
|
||||
self._save()
|
||||
return client_hash if new else None
|
||||
|
||||
def record_contact(
|
||||
self, fr: str, ip: str, ua: str, accept_language: str = ""
|
||||
) -> bytes | None:
|
||||
"""Append one server-originated contact marker to the raw log: the
|
||||
visitor opened the contact panel on ``fr`` and solved its proof of
|
||||
work (docs/messages.md). Recorded by the /_ws handler on a
|
||||
validated solution — an unsolved panel open leaves no trace.
|
||||
Display lands the marker on the visit's trail step for ``fr``.
|
||||
|
||||
Returns the client hash when the client record was just created (so
|
||||
the caller can schedule async enrichment), else None.
|
||||
"""
|
||||
client_lang, country = _parse_accept_language(accept_language)
|
||||
client_hash = _client_hash(ip, ua, client_lang)
|
||||
new = client_hash not in self.data.clients
|
||||
if new:
|
||||
self._ensure_client(ip, ua, client_lang, country=country)
|
||||
self.data.msgs.append(
|
||||
Msg(
|
||||
t=datetime.now(UTC),
|
||||
client=client_hash,
|
||||
fr=(_internal_path(fr) or "") if fr else "",
|
||||
contact=True,
|
||||
)
|
||||
)
|
||||
self._save()
|
||||
return client_hash if new else None
|
||||
|
||||
def favicon_origins_needed(self) -> list[str]:
|
||||
"""External https origins seen in the raw events whose favicon needs
|
||||
fetching.
|
||||
@@ -775,6 +810,9 @@ class Store:
|
||||
rendered language), and a page re-visited in a different rendered
|
||||
language becomes a distinct trail step. The entry referer and
|
||||
UTM tags come from the GET that loaded the entry page.
|
||||
Server-originated contact markers (the contact panel's proof of
|
||||
work solved, docs/messages.md) flag the trail step they happened
|
||||
on — the first one per visit.
|
||||
|
||||
Hidden (admin) clients are excluded from every list and aggregate.
|
||||
``multilingual`` and ``primary_lang`` are site context (translation
|
||||
@@ -871,6 +909,19 @@ class Store:
|
||||
visit: Visit | None = None
|
||||
last_t: datetime | None = None
|
||||
for m in msgs:
|
||||
if m.contact:
|
||||
# Contact PoW proven (docs/messages.md): mark the trail
|
||||
# step it happened on — the first one per visit only.
|
||||
if visit is not None and not any(
|
||||
item.contact for item in visit.trail.values()
|
||||
):
|
||||
want = m.fr or visit.entry
|
||||
for item in visit.trail.values():
|
||||
if item.to == want:
|
||||
item.contact = True
|
||||
break
|
||||
last_t = m.t
|
||||
continue
|
||||
if m.to:
|
||||
if visit is None or (
|
||||
last_t is not None and m.t - last_t > _SESSION_GAP
|
||||
|
||||
+86
-20
@@ -23,8 +23,8 @@ from fastapi import (
|
||||
from html5tagger import E
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pagerite import i18n, views
|
||||
from pagerite.chunks import store_chunks
|
||||
from pagerite import i18n, messages, views
|
||||
from pagerite.chunks import chunk_key, store_chunks
|
||||
from pagerite.data import (
|
||||
Node,
|
||||
append_order,
|
||||
@@ -276,12 +276,18 @@ async def update_structure(op: StructureOp, request: Request) -> None:
|
||||
|
||||
|
||||
@router.get("/_api/settings")
|
||||
async def get_settings() -> dict:
|
||||
async def get_settings(lang: str | None = None) -> dict:
|
||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||
the themes, banner designs and user fonts available on disk for the
|
||||
selectors, the translator service keys and the wanted translation
|
||||
languages (for the /_translate socket)."""
|
||||
return {
|
||||
languages (for the /_translate socket), and the contact-messaging
|
||||
configuration.
|
||||
|
||||
With a ``?lang=`` translation language the contact label/subject also
|
||||
come out as ``*_tr`` (their per-language override, "" = none — the
|
||||
site settings' messaging section edits those, falling back to the
|
||||
original it edits without the query)."""
|
||||
out = {
|
||||
"brand": data.brand,
|
||||
"brand_html": data.brand_html,
|
||||
"theme": data.theme,
|
||||
@@ -293,11 +299,27 @@ async def get_settings() -> dict:
|
||||
"transition": data.transition,
|
||||
"transitions": views._transition_names(),
|
||||
"translate_keys": data.translate_keys,
|
||||
# Unanswered contact threads: the corner ✉️ icon's badge for
|
||||
# admins (this probe runs on every page load; no polling).
|
||||
"contact_unread": messages.unread_count(),
|
||||
# Messaging is enabled by configuring a label (empty = off).
|
||||
"contact_label": data.contact_label,
|
||||
"contact_email": data.contact_email,
|
||||
"contact_subject": data.contact_subject,
|
||||
"contact_mailserver": data.contact_mailserver,
|
||||
# The site default primary language: the front page's resolved
|
||||
# setting (every page may override it, inherited down the tree).
|
||||
"primary_lang": i18n.primary_lang(data.menu, ""),
|
||||
"translate_langs": sorted(data.translate_langs),
|
||||
}
|
||||
tag = i18n.base_tag(lang or "")
|
||||
if tag and tag != out["primary_lang"]:
|
||||
for field in ("contact_label", "contact_subject"):
|
||||
original = getattr(data, field)
|
||||
out[f"{field}_tr"] = (
|
||||
data.trans.get(chunk_key(original), {}).get(tag, "") if original else ""
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
@@ -310,17 +332,54 @@ class SettingsIn(BaseModel):
|
||||
transition: str = "cube"
|
||||
translate_langs: list[str] | None = None # None keeps the current set
|
||||
translate_keys: dict[str, str] | None = None # None keeps the current keys
|
||||
contact_label: str | None = None
|
||||
contact_email: str | None = None
|
||||
contact_subject: str | None = None
|
||||
contact_mailserver: str | None = None
|
||||
|
||||
|
||||
@router.put("/_api/settings", status_code=204)
|
||||
async def put_settings(settings: SettingsIn, request: Request) -> None:
|
||||
"""Update site-wide settings; invalidates cached pages and ETags."""
|
||||
async def put_settings(
|
||||
settings: SettingsIn, request: Request, lang: str | None = None
|
||||
) -> None:
|
||||
"""Update site-wide settings; invalidates cached pages and ETags.
|
||||
|
||||
With a ``?lang=`` translation language only the contact label/subject
|
||||
apply — as per-language overrides in the chunk-keyed trans store, the
|
||||
same storage machine translations use (i18n.set_string_translation);
|
||||
empty or the original's text drops the override."""
|
||||
tag = i18n.base_tag(lang or "")
|
||||
if tag and tag != i18n.primary_lang(data.menu, ""):
|
||||
with kanta.transaction(
|
||||
f"settings:{tag}", user=request.headers.get("remote-user")
|
||||
):
|
||||
changed = False
|
||||
for field in ("contact_label", "contact_subject"):
|
||||
value = getattr(settings, field)
|
||||
if value is None:
|
||||
continue
|
||||
original = getattr(data, field)
|
||||
if original:
|
||||
changed |= i18n.set_string_translation(
|
||||
data, tag, original, value.strip()
|
||||
)
|
||||
if changed:
|
||||
_invalidate_pages()
|
||||
return
|
||||
with kanta.transaction("settings", user=request.headers.get("remote-user")):
|
||||
data.brand = settings.brand
|
||||
data.brand_html = settings.brand_html
|
||||
data.theme = settings.theme
|
||||
data.custom_css = settings.custom_css
|
||||
data.transition = settings.transition
|
||||
if settings.contact_label is not None:
|
||||
data.contact_label = settings.contact_label.strip()
|
||||
if settings.contact_email is not None:
|
||||
data.contact_email = settings.contact_email.strip()
|
||||
if settings.contact_subject is not None:
|
||||
data.contact_subject = settings.contact_subject.strip()
|
||||
if settings.contact_mailserver is not None:
|
||||
data.contact_mailserver = settings.contact_mailserver.strip()
|
||||
if settings.translate_langs is not None:
|
||||
# Any language may be a target — including the site default
|
||||
# (an article in another language can be translated INTO it);
|
||||
@@ -544,11 +603,26 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
markdown = msg.get("markdown", "")
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
# Expand {cards} like page_content does, so the preview
|
||||
# shows real cards, not the literal tag. No translation
|
||||
# context: the preview has no lang of its own, so cards
|
||||
# render in their originals.
|
||||
has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None
|
||||
# {contact} buttons preview like the served page: inert,
|
||||
# and nothing at all when messaging is disabled (no
|
||||
# configured label). {cards} expands like page_content
|
||||
# does, so the preview shows real cards, not the literal
|
||||
# tag. No translation context: the preview has no lang of
|
||||
# its own, so cards render in their originals.
|
||||
directives: dict = {
|
||||
"contact": (
|
||||
(lambda args, _env: views._contact_tag(args, _env, data))
|
||||
if data.contact_label.strip()
|
||||
else (lambda _args, _env: "")
|
||||
)
|
||||
}
|
||||
if node is not None and has_cards_tag:
|
||||
directives["cards"] = (
|
||||
lambda args, _env, node=node, path=path: views._cards_tag(
|
||||
data.menu, data, node, path, args
|
||||
)
|
||||
)
|
||||
rendered = render(
|
||||
markdown,
|
||||
path,
|
||||
@@ -565,15 +639,7 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
if node
|
||||
else None
|
||||
),
|
||||
directives=(
|
||||
{
|
||||
"cards": lambda args, _env, node=node, path=path: (
|
||||
views._cards_tag(data.menu, data, node, path, args)
|
||||
)
|
||||
}
|
||||
if node is not None and has_cards_tag
|
||||
else None
|
||||
),
|
||||
directives=directives,
|
||||
)
|
||||
html = rendered.html
|
||||
if node is not None and not has_cards_tag:
|
||||
|
||||
+6
-2
@@ -11,6 +11,9 @@ The routes live in specialized modules, included below as APIRouters:
|
||||
(``/_api/*``, ``/_translate/{clientkey}``).
|
||||
- ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``,
|
||||
the ``/_a`` viewer page).
|
||||
- ``pagerite.messages`` — visitor contact threads (docs/messages.md): the
|
||||
``/_contact/files`` upload route; the messaging protocol itself rides
|
||||
the tracking sockets.
|
||||
- ``pagerite.feeds`` — machine-readable site exports: ``/llms.txt``,
|
||||
``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS).
|
||||
- ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``,
|
||||
@@ -41,7 +44,7 @@ from fastapi.responses import Response
|
||||
from fastapi_vue import Frontend, env
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from pagerite import api, feeds, files, pages, tracking
|
||||
from pagerite import api, feeds, files, messages, pages, tracking
|
||||
from pagerite.files import file_store
|
||||
from pagerite.state import analytics_store, config, kanta
|
||||
|
||||
@@ -119,9 +122,10 @@ async def _headers(request: Request, call_next) -> Response:
|
||||
|
||||
|
||||
# Our own routes first: the editor API and translator socket, the analytics
|
||||
# machinery, and the file store/user assets.
|
||||
# machinery, the contact-message upload route, and the file store/user assets.
|
||||
app.include_router(api.router)
|
||||
app.include_router(tracking.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(files.router)
|
||||
|
||||
# Vue build asset routes are inserted at this position during load(): the
|
||||
|
||||
@@ -50,6 +50,50 @@ class LangEdits(msgspec.Struct, omit_defaults=True):
|
||||
adds: dict[str, str] = {}
|
||||
|
||||
|
||||
class ContactMessage(msgspec.Struct, omit_defaults=True):
|
||||
"""One message in a visitor contact thread (docs/messages.md).
|
||||
|
||||
``md`` is the Markdown-subset source serialized by the composer;
|
||||
``html`` its rendering by the strict parser in pagerite.messages
|
||||
(html=False — no user markup ever reaches a page, only markdown-it
|
||||
output from the constrained grammar). Both are stored: the source for
|
||||
future edits/moderation, the rendering so panels inject it verbatim.
|
||||
"""
|
||||
|
||||
t: datetime
|
||||
#: Markdown-subset source.
|
||||
md: str = ""
|
||||
#: Strict server-side rendering of ``md``.
|
||||
html: str = ""
|
||||
#: Author: "" = the visitor, otherwise the admin's SSO remote-user.
|
||||
admin: str = ""
|
||||
#: Page path the message was sent from ("" for admin replies).
|
||||
page: str = ""
|
||||
|
||||
|
||||
class ContactThread(msgspec.Struct, omit_defaults=True):
|
||||
"""One visitor's message thread, keyed in Data.contact by token hash.
|
||||
|
||||
Messages are keyed (never a list) by a sortable timestamp-random id so
|
||||
a save's database diff touches only the appended message.
|
||||
"""
|
||||
|
||||
created: datetime
|
||||
messages: dict[str, ContactMessage] = {}
|
||||
#: 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 (name required, email optional
|
||||
#: and unvalidated), captured with their first message and fixed
|
||||
#: afterwards ("" until then).
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
#: The visitor closed the conversation (their next message starts a
|
||||
#: fresh thread under a new token); archived in the admin inbox,
|
||||
#: excluded from the unread count.
|
||||
closed: bool = False
|
||||
|
||||
|
||||
class Node(msgspec.Struct, omit_defaults=True):
|
||||
"""One item of the site hierarchy.
|
||||
|
||||
@@ -167,6 +211,27 @@ class Data(msgspec.Struct):
|
||||
#: path -> lang -> LangEdits (paths without leading slash). Replaces
|
||||
#: the old "patches" key (ignored on decode, discarding that data).
|
||||
overrides: dict[str, dict[str, LangEdits]] = {}
|
||||
#: Visitor contact threads (docs/messages.md), keyed by the blake3
|
||||
#: hash of the visitor's session token (never the token itself).
|
||||
contact: dict[bytes, ContactThread] = {}
|
||||
#: The {contact:} button label and visitor dialog title — and the
|
||||
#: feature's on/off switch: EMPTY disables messaging (no buttons, no
|
||||
#: visitor corner icon, sends rejected; admins keep the inbox). A
|
||||
#: per-button {contact: Label} arg overrides it. Translated per
|
||||
#: language in Data.trans, keyed by chunk hash like titles
|
||||
#: (i18n.site_string).
|
||||
contact_label: str = ""
|
||||
#: Contact email shown as a mailto link in the visitor dialog (with
|
||||
#: the subject prefilled), revealed over the socket only after the
|
||||
#: proof of work is solved. Empty = no link.
|
||||
contact_email: str = ""
|
||||
#: Subject for the mailto link; empty = the (localized) label.
|
||||
#: Translated like contact_label.
|
||||
contact_subject: str = ""
|
||||
#: Mail server for email notifications (docs/messages.md): a mailbridge
|
||||
#: URL — a bare host[:port] or smtp://user:pass@host:port for plain
|
||||
#: SMTP, or a backend URL like sendgrid:API-KEY. Empty = no email.
|
||||
contact_mailserver: str = ""
|
||||
|
||||
|
||||
def node_markdown(data: Data, node: Node) -> str | None:
|
||||
|
||||
+69
-19
@@ -8,7 +8,11 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
|
||||
``<hash>.orig<ext>`` (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 ``{name}.file``
|
||||
attachment blobs), and the user assets (``/_themes/``, ``/_fonts/``).
|
||||
All ``/_f/`` responses are forced downloads (``content-disposition:
|
||||
attachment`` — ignored by <img>/<video> subresource fetches) with
|
||||
``nosniff``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -215,6 +219,20 @@ def store_image(
|
||||
return digest
|
||||
|
||||
|
||||
def store_attachment(body: bytes) -> str:
|
||||
"""Store a non-media upload as an inert ``<hash>.file`` blob.
|
||||
|
||||
The original extension is deliberately dropped on disk (a stored
|
||||
``.exe``/``.html`` could otherwise be accidentally run from the
|
||||
filesystem); the original name lives only in the serving URL
|
||||
(``/_f/{hash}/{name}``), and ``.file`` blobs are served nowhere
|
||||
else. Returns the store name.
|
||||
"""
|
||||
name = blake3.blake3(body).hexdigest()[:12] + ".file"
|
||||
file_store.put(name, body)
|
||||
return name
|
||||
|
||||
|
||||
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
|
||||
FETCH_MAXSIZE = 20 * 1024 * 1024
|
||||
|
||||
@@ -369,6 +387,20 @@ async def user_font_file(name: str, filename: str, request: Request) -> Response
|
||||
|
||||
@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 URL shape. ``{name}`` identifies a ``{name}.file``
|
||||
blob (inert on disk, never served without the filename segment);
|
||||
``{filename}`` is the original upload name and lives only in this
|
||||
URL — the browser names the download after the last path segment.
|
||||
"""
|
||||
return _stored_response(name, request, attachment=True)
|
||||
|
||||
|
||||
def _stored_response(name: str, request: Request, *, attachment: bool = False) -> 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,33 +411,51 @@ 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,
|
||||
and ``.file`` attachment blobs are only served through the
|
||||
filename-carrying URL (stored_file_download), never by hash.
|
||||
|
||||
Every response is ``content-disposition: attachment`` with
|
||||
``nosniff``: subresource fetches (<img>, <video>, favicons) ignore
|
||||
the disposition, but a direct navigation always downloads and the
|
||||
body is never sniffed into something executable in this origin."""
|
||||
if ".orig." in name:
|
||||
raise HTTPException(404)
|
||||
etag = name
|
||||
vary = ""
|
||||
entry = file_store.get(name)
|
||||
if entry is None and "." not in name:
|
||||
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
||||
vary = "accept"
|
||||
accept = request.headers.get("accept", "")
|
||||
if "image/avif" in accept:
|
||||
order = ("avif", "webp", "jpg")
|
||||
elif "image/webp" in accept:
|
||||
order = ("webp", "jpg", "avif")
|
||||
else:
|
||||
order = ("jpg", "webp", "avif")
|
||||
for ext in order:
|
||||
etag = f"{name}.{ext}"
|
||||
entry = file_store.get(etag)
|
||||
if entry is not None:
|
||||
break
|
||||
if attachment:
|
||||
etag = f"{name}.file"
|
||||
entry = file_store.get(etag)
|
||||
else:
|
||||
if name.endswith(".file"):
|
||||
raise HTTPException(404)
|
||||
entry = file_store.get(name)
|
||||
if entry is None and "." not in name:
|
||||
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
||||
vary = "accept"
|
||||
accept = request.headers.get("accept", "")
|
||||
if "image/avif" in accept:
|
||||
order = ("avif", "webp", "jpg")
|
||||
elif "image/webp" in accept:
|
||||
order = ("webp", "jpg", "avif")
|
||||
else:
|
||||
order = ("jpg", "webp", "avif")
|
||||
for ext in order:
|
||||
etag = f"{name}.{ext}"
|
||||
entry = file_store.get(etag)
|
||||
if entry is not None:
|
||||
break
|
||||
if entry is None:
|
||||
raise HTTPException(404)
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
body, compressed = entry
|
||||
headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
|
||||
headers = {
|
||||
"etag": etag,
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
"content-disposition": "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
}
|
||||
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
|
||||
headers["content-encoding"] = "zstd"
|
||||
vary = f"{vary}, accept-encoding".lstrip(", ")
|
||||
|
||||
@@ -407,6 +407,36 @@ def set_title_translation(data: Data, node: Node, lang: str, title: str) -> bool
|
||||
return True
|
||||
|
||||
|
||||
def site_string(data: Data, lang: str, text: str) -> str:
|
||||
"""A site-level settings string (not article content — the contact
|
||||
label/subject) in ``lang``: its translation from the trans store,
|
||||
chunk-keyed like titles (the translator service picks these up via
|
||||
pending_items), falling back to the original. The site default
|
||||
language always gets the original."""
|
||||
if not text or not lang or lang == primary_lang(data.menu, ""):
|
||||
return text
|
||||
return data.trans.get(chunk_key(text), {}).get(lang) or text
|
||||
|
||||
|
||||
def set_string_translation(data: Data, lang: str, original: str, text: str) -> bool:
|
||||
"""Record (or drop) a site-string translation (i18n.site_string): a
|
||||
fragment in ``Data.trans`` keyed by the original's chunk hash — the
|
||||
same storage machine translations use, overriding them. Empty or the
|
||||
original's text drops the override. Returns True when anything changed.
|
||||
Pure data ops — the caller wraps in a transaction and invalidates."""
|
||||
key = chunk_key(original)
|
||||
current = data.trans.get(key, {}).get(lang)
|
||||
if not text or text == original:
|
||||
if current is None:
|
||||
return False
|
||||
del data.trans[key][lang]
|
||||
return True
|
||||
if current == text:
|
||||
return False
|
||||
data.trans.setdefault(key, {})[lang] = text
|
||||
return True
|
||||
|
||||
|
||||
def clear_translations(data: Data) -> None:
|
||||
"""Drop all machine translations (``Data.trans``) and rebuild the
|
||||
availability index (``node.langs``) from the surviving user overrides —
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
"""Visitor contact messages: storage, strict rendering, socket protocol.
|
||||
|
||||
Anonymous visitors open a panel from a ``{contact: Label}`` button in an
|
||||
article (views._contact_tag). The panel rides the public /_ws activity
|
||||
socket: tracking.activity_ws routes frames carrying a ``token`` field to
|
||||
``visitor_frame``. The token (a localStorage random, generated on first
|
||||
use) identifies the visitor's thread; only its blake3 hash is stored.
|
||||
Admins watch and reply from the same overlay panel (the corner ✉️ icon
|
||||
opens the inbox for them, the visitor thread for visitors) over the
|
||||
analytics socket (tracking.analytics_websocket), admin-gated like the
|
||||
rest of /_api; their frames go to ``admin_frame``. Both sides get
|
||||
pushed full thread payloads (threads are small); there is no REST
|
||||
polling anywhere.
|
||||
|
||||
Sends are proof-of-work gated (easypow): the /_ws handler
|
||||
(tracking.activity_ws) issues a fresh single-use challenge on
|
||||
registration/resync and whenever the quota runs out; the browser solves
|
||||
it in the background (bundled solver, served at /_contact/easypow.js)
|
||||
and submits the solution as soon as it is ready (``visitor_proven``),
|
||||
which grants a quota of messages and uploads (``_quota``, RAM only,
|
||||
keyed by token hash so the HTTP upload route shares it). The solution
|
||||
is single-use — a fresh challenge is pushed when the quota hits zero,
|
||||
which the client solves before continuing. A validated proof also
|
||||
unlocks the site's contact email (sent over the socket, never in the
|
||||
page's asset config) and records the visit's contact trail marker in
|
||||
analytics. Uploads without quota are answered 429; the client re-proves
|
||||
and retries.
|
||||
|
||||
Message bodies are a Markdown subset (emphasis, links, lists, code plus
|
||||
/_f/ image and file references) serialized client-side from a constrained
|
||||
contenteditable composer. They are rendered here with a strict MarkdownIt
|
||||
instance: html=False, so not a single byte of user markup can reach a
|
||||
page — the stored html is always markdown-it output from the constrained
|
||||
grammar, never sanitized user HTML (sanitization is unwinnable; grammar
|
||||
restriction is not). Links get rel="nofollow ugc noopener"; images are
|
||||
limited to /_f/ store names (the upload endpoint is the only way in).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import blake3
|
||||
import easypow
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from fastapi.responses import Response
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.common.utils import escapeHtml
|
||||
|
||||
from pagerite.data import ContactMessage, ContactThread
|
||||
from pagerite.files import _ext, file_store, store_attachment, store_image
|
||||
from pagerite.state import data, kanta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
#: Session-token shape: crypto-random from the browser (uuid or hex).
|
||||
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
|
||||
|
||||
#: Message source length cap (the rendered html is bounded by it too).
|
||||
MAX_MESSAGE = 8000
|
||||
|
||||
#: Live sockets: visitor token hash -> panels, and every connected admin.
|
||||
_visitor_ws: dict[bytes, set[WebSocket]] = {}
|
||||
_admin_ws: set[WebSocket] = set()
|
||||
|
||||
#: Uploaded-but-unreferenced file digests per session (RAM only) — deleted
|
||||
#: when the session's last socket closes. The PoW challenge state is a
|
||||
#: per-connection local in the WS handler (tracking.activity_ws).
|
||||
_pending_uploads: dict[bytes, set[str]] = {}
|
||||
|
||||
#: Remaining message/upload actions per session (token hash), granted by a
|
||||
#: solved PoW challenge (RAM only — single worker; the challenge itself is
|
||||
#: a per-connection local in tracking.activity_ws). Keyed by token hash
|
||||
#: rather than connection so the HTTP upload route (/_contact/files)
|
||||
#: shares the same budget, and a reconnect keeps it.
|
||||
_quota: dict[bytes, int] = {}
|
||||
|
||||
#: easypow solving cost per challenge, the actions a solution grants
|
||||
#: (messages and uploads alike), and the pending-upload quota.
|
||||
POW_SECONDS = 5.0
|
||||
POW_QUOTA = 10
|
||||
MAX_PENDING_UPLOADS = 10
|
||||
|
||||
|
||||
# --- Strict rendering -----------------------------------------------------
|
||||
# A separate instance from markdown.py's page renderer: no raw HTML (the
|
||||
# trusted-author passthrough would be an XSS hole here), no plugins, no
|
||||
# typographer. markdown-it escapes all markup in the source and validates
|
||||
# link protocols (javascript: etc. never become hrefs).
|
||||
|
||||
_md = MarkdownIt("default", {"html": False, "typographer": False, "breaks": True})
|
||||
|
||||
#: Images may only reference the content-addressed store (uploads land
|
||||
#: there via /_contact/files); anything else collapses to its alt text.
|
||||
_IMG_SRC = re.compile(r"/_f/[0-9a-f]{12}(?:\.[a-z0-9]+)?")
|
||||
|
||||
|
||||
def _link_rule(self, tokens, idx, options, env):
|
||||
"""Open external links in a new tab without window.opener; store-file
|
||||
links (uploaded attachments) render as download chips."""
|
||||
token = tokens[idx]
|
||||
href = token.attrs.get("href", "")
|
||||
if href.startswith("/_f/"):
|
||||
token.attrSet("class", "attachment")
|
||||
token.attrSet("download", "")
|
||||
else:
|
||||
token.attrSet("rel", "nofollow ugc noopener")
|
||||
token.attrSet("target", "_blank")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
def _image_rule(self, tokens, idx, options, env):
|
||||
"""Drop images that do not point at a /_f/ store file (escaped alt)."""
|
||||
token = tokens[idx]
|
||||
if not _IMG_SRC.fullmatch(token.attrs.get("src", "")):
|
||||
return escapeHtml(self.renderInlineAsText(token.children, options, env))
|
||||
token.attrSet("loading", "lazy")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
_md.add_render_rule("link_open", _link_rule)
|
||||
_md.add_render_rule("image", _image_rule)
|
||||
|
||||
|
||||
def render_message(text: str) -> str:
|
||||
"""Render message-source Markdown to thread HTML (the only HTML a
|
||||
visitor's message ever becomes)."""
|
||||
return _md.render(text)
|
||||
|
||||
|
||||
# --- Thread store ----------------------------------------------------------
|
||||
|
||||
|
||||
def thread_key(token: str) -> bytes:
|
||||
"""The Data.contact key for a session token (hash, never the token)."""
|
||||
return blake3.blake3(token.encode()).digest(12)
|
||||
|
||||
|
||||
def _new_id(now: datetime) -> str:
|
||||
"""Sortable message id: timestamp plus a random tiebreak."""
|
||||
return f"{now:%Y%m%d%H%M%S%f}-{secrets.token_hex(3)}"
|
||||
|
||||
|
||||
def _unread(thread: ContactThread) -> bool:
|
||||
"""Admin perspective: the last message is the visitor's and newer than
|
||||
the admin's last read."""
|
||||
for mid in reversed(thread.messages):
|
||||
m = thread.messages[mid]
|
||||
if m.admin:
|
||||
return False
|
||||
return thread.admin_read is None or m.t > thread.admin_read
|
||||
return False
|
||||
|
||||
|
||||
def _payload(key: bytes, thread: ContactThread) -> dict:
|
||||
"""Full thread state for the wire (same shape for visitors and admins;
|
||||
clients replace/upsert by id). Messages are capped to the last 200.
|
||||
Admin messages are labeled with the site name as currently configured
|
||||
(not the stored SSO name, which stays in the database for audit)."""
|
||||
brand = data.brand or "admin"
|
||||
return {
|
||||
"type": "thread",
|
||||
"id": key.hex(),
|
||||
"created": thread.created.isoformat(),
|
||||
"unread": _unread(thread),
|
||||
"closed": thread.closed,
|
||||
"name": thread.name,
|
||||
"email": thread.email,
|
||||
"messages": [
|
||||
{
|
||||
"id": mid,
|
||||
"t": m.t.isoformat(),
|
||||
"admin": brand if m.admin else "",
|
||||
"html": m.html,
|
||||
"page": m.page,
|
||||
}
|
||||
for mid, m in list(thread.messages.items())[-200:]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _empty_payload(key: bytes) -> dict:
|
||||
return {"type": "thread", "id": key.hex(), "created": "", "unread": False,
|
||||
"closed": False, "name": "", "email": "", "messages": []}
|
||||
|
||||
|
||||
async def _push(sockets, payload: dict) -> None:
|
||||
"""Send a payload to a socket set, dropping the ones that fail."""
|
||||
closed = set()
|
||||
for ws in sockets:
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
logger.exception("Message push failed; dropping client")
|
||||
closed.add(ws)
|
||||
for ws in closed:
|
||||
sockets.discard(ws)
|
||||
|
||||
|
||||
async def _broadcast(key: bytes, thread: ContactThread) -> None:
|
||||
"""Push a thread update to its visitor's panels and every admin."""
|
||||
payload = _payload(key, thread)
|
||||
if key in _visitor_ws:
|
||||
await _push(_visitor_ws[key], payload)
|
||||
await _push(_admin_ws, payload)
|
||||
|
||||
|
||||
def _append(
|
||||
key: bytes,
|
||||
msg: ContactMessage,
|
||||
action: str,
|
||||
user: str | None,
|
||||
name: str = "",
|
||||
email: str = "",
|
||||
) -> None:
|
||||
"""Append a message to the thread (created on first use), in one
|
||||
transaction so the database diff is just the new entry. A visitor's
|
||||
name/email are recorded alongside their first message."""
|
||||
with kanta.transaction(action, user=user, extra=key.hex()):
|
||||
thread = data.contact.get(key)
|
||||
if thread is None:
|
||||
thread = data.contact[key] = ContactThread(created=msg.t)
|
||||
if name and not thread.name:
|
||||
thread.name = name
|
||||
thread.email = email
|
||||
thread.messages[_new_id(msg.t)] = msg
|
||||
|
||||
|
||||
# --- Visitor protocol (/_ws frames carrying "token") ------------------------
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""Messaging on/off: the site contact label doubles as the switch —
|
||||
empty disables the feature (docs/messages.md)."""
|
||||
return bool(data.contact_label.strip())
|
||||
|
||||
|
||||
def quota_left(token: str) -> int:
|
||||
"""Remaining PoW-granted actions for a session token (0 for malformed
|
||||
tokens — they fail validation later anyway)."""
|
||||
if not TOKEN_RE.fullmatch(token):
|
||||
return 0
|
||||
return _quota.get(thread_key(token), 0)
|
||||
|
||||
|
||||
async def visitor_proven(ws: WebSocket, raw: dict) -> bytes | None:
|
||||
"""A solved PoW challenge submitted ahead of sending ({token, pow} —
|
||||
the solution itself is already validated and burned by the WS
|
||||
handler): grant the message/upload quota, acknowledge it, reveal the
|
||||
site's contact email (never carried in the page's asset config, so
|
||||
scrapers cannot lift it without solving) and return the thread key
|
||||
(the handler records the visit's contact trail marker in analytics).
|
||||
None for a malformed token."""
|
||||
token = str(raw.get("token") or "")
|
||||
if not TOKEN_RE.fullmatch(token):
|
||||
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
|
||||
return None
|
||||
key = thread_key(token)
|
||||
_visitor_ws.setdefault(key, set()).add(ws)
|
||||
_quota[key] = POW_QUOTA
|
||||
await ws.send_json({"type": "pow", "quota": POW_QUOTA})
|
||||
if data.contact_email:
|
||||
# The mailto recipient is "Brand <email>" (RFC-encoded here; the
|
||||
# link text is the bare address, the subject is the client's
|
||||
# localized contact_subject).
|
||||
recipient = (
|
||||
quote(f"{data.brand} <{data.contact_email}>", safe="@")
|
||||
if data.brand.strip()
|
||||
else quote(data.contact_email, safe="@")
|
||||
)
|
||||
await ws.send_json(
|
||||
{"type": "contact-email", "email": data.contact_email, "to": recipient}
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def _spend(key: bytes) -> None:
|
||||
"""Burn one message/upload of the session's PoW quota."""
|
||||
if _quota.get(key, 0) > 0:
|
||||
_quota[key] -= 1
|
||||
|
||||
|
||||
async def visitor_frame(ws: WebSocket, raw: dict) -> None:
|
||||
"""Handle one visitor frame: ``{token}`` registers/resyncs (the thread
|
||||
state is pushed back), ``{token, msg, fr?}`` appends a message and
|
||||
broadcasts the updated thread to both sides, ``{token, close}`` ends
|
||||
the conversation (archived in the admin inbox; a fresh thread needs a
|
||||
new token, which the client rotates itself). The PoW quota check
|
||||
happens in the WS handler (tracking.activity_ws) — message frames
|
||||
arrive here with quota remaining, spent here per accepted message."""
|
||||
token = str(raw.get("token") or "")
|
||||
if not TOKEN_RE.fullmatch(token):
|
||||
await ws.send_json({"type": "contact-error", "detail": "bad session token"})
|
||||
return
|
||||
key = thread_key(token)
|
||||
_visitor_ws.setdefault(key, set()).add(ws)
|
||||
if raw.get("close"):
|
||||
thread = data.contact.get(key)
|
||||
if thread and not thread.closed:
|
||||
with kanta.transaction("contact:close", extra=key.hex()):
|
||||
thread.closed = True
|
||||
await _push(_admin_ws, _payload(key, thread))
|
||||
# No more pushes to this token: drop the socket from its registry
|
||||
# and clean up its pending uploads like a disconnect would.
|
||||
sockets = _visitor_ws.get(key)
|
||||
if sockets is not None:
|
||||
sockets.discard(ws)
|
||||
if not sockets:
|
||||
del _visitor_ws[key]
|
||||
for digest in _pending_uploads.pop(key, ()):
|
||||
file_store.delete(digest)
|
||||
return
|
||||
text = str(raw.get("msg") or "").strip()
|
||||
if not text:
|
||||
thread = data.contact.get(key)
|
||||
# A closed thread resyncs as empty: the conversation is over, and a
|
||||
# stale client (same token) starts from a clean slate.
|
||||
if thread and not thread.closed:
|
||||
await ws.send_json(_payload(key, thread))
|
||||
else:
|
||||
await ws.send_json(_empty_payload(key))
|
||||
return
|
||||
if len(text) > MAX_MESSAGE:
|
||||
await ws.send_json({"type": "contact-error", "detail": "message too long"})
|
||||
return
|
||||
if not enabled():
|
||||
# Messaging off (no contact label configured): sends are rejected
|
||||
# (the UI is already hidden); existing threads and admin replies
|
||||
# stay.
|
||||
await ws.send_json({"type": "contact-error", "detail": "messaging is disabled"})
|
||||
return
|
||||
# The first message must introduce the visitor (name; email optional,
|
||||
# unvalidated). The stored identity is fixed from then on.
|
||||
thread = data.contact.get(key)
|
||||
if thread and thread.closed:
|
||||
# Defensive: the client rotates its token on close, so a message
|
||||
# for a closed thread only comes from a stale client.
|
||||
await ws.send_json({"type": "contact-error", "detail": "thread closed"})
|
||||
return
|
||||
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:
|
||||
await ws.send_json({"type": "contact-error", "detail": "name required"})
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
_append(
|
||||
key,
|
||||
ContactMessage(
|
||||
t=now,
|
||||
md=text,
|
||||
html=render_message(text),
|
||||
page=str(raw.get("fr") or "")[:200],
|
||||
),
|
||||
"contact",
|
||||
None,
|
||||
name,
|
||||
email,
|
||||
)
|
||||
_spend(key)
|
||||
# Files the message references are no longer pending cleanup.
|
||||
if pending := _pending_uploads.get(key):
|
||||
for digest in re.findall(r"/_f/([0-9a-f]{12})", text):
|
||||
pending.discard(digest)
|
||||
await _broadcast(key, data.contact[key])
|
||||
|
||||
|
||||
def visitor_disconnect(ws: WebSocket) -> None:
|
||||
"""Drop a closed /_ws socket from every registry. When a session's
|
||||
last socket closes, its uploaded files that no sent message ended up
|
||||
referencing are deleted from the store."""
|
||||
empty = [key for key, sockets in _visitor_ws.items() if not sockets - {ws}]
|
||||
for sockets in _visitor_ws.values():
|
||||
sockets.discard(ws)
|
||||
for key in empty:
|
||||
del _visitor_ws[key]
|
||||
for digest in _pending_uploads.pop(key, ()):
|
||||
file_store.delete(digest)
|
||||
|
||||
|
||||
# --- Admin protocol (/_api/ws/analytics frames) -----------------------------
|
||||
|
||||
|
||||
def unread_count() -> int:
|
||||
"""Threads with an unanswered visitor message — the admin inbox icon's
|
||||
badge, seeded by /_api/settings (no polling; live while the inbox's
|
||||
socket is open). Visitor-closed (archived) threads do not count."""
|
||||
return sum(1 for t in data.contact.values() if _unread(t) and not t.closed)
|
||||
|
||||
|
||||
def _key_from_hex(value) -> bytes | None:
|
||||
try:
|
||||
key = bytes.fromhex(str(value or ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return key if len(key) == 12 else None
|
||||
|
||||
|
||||
def _last_t(thread: ContactThread) -> datetime:
|
||||
"""The thread's last-activity time (its newest message, else creation)."""
|
||||
if thread.messages:
|
||||
return list(thread.messages.values())[-1].t
|
||||
return thread.created
|
||||
|
||||
|
||||
async def admin_connect(ws: WebSocket) -> None:
|
||||
"""Register an admin socket and send the inbox: all threads, most
|
||||
recently active first, visitor-closed (archived) ones last (capped at
|
||||
100)."""
|
||||
_admin_ws.add(ws)
|
||||
threads = sorted(data.contact.items(), key=lambda kv: _last_t(kv[1]), reverse=True)
|
||||
threads = [kv for kv in threads if not kv[1].closed] + [
|
||||
kv for kv in threads if kv[1].closed
|
||||
]
|
||||
await ws.send_json(
|
||||
{"type": "threads", "threads": [_payload(k, t) for k, t in threads[:100]]}
|
||||
)
|
||||
|
||||
|
||||
def admin_disconnect(ws: WebSocket) -> None:
|
||||
_admin_ws.discard(ws)
|
||||
|
||||
|
||||
async def admin_frame(ws: WebSocket, raw: dict) -> None:
|
||||
"""Handle one admin frame: ``{type: reply, id, text}`` appends an admin
|
||||
message, ``{type: read, id}`` marks a thread read."""
|
||||
match raw.get("type"):
|
||||
case "reply":
|
||||
key = _key_from_hex(raw.get("id"))
|
||||
text = str(raw.get("text") or "").strip()
|
||||
thread = data.contact.get(key) if key else None
|
||||
if thread is None:
|
||||
await ws.send_json({"type": "contact-error", "detail": "no such thread"})
|
||||
return
|
||||
if thread.closed:
|
||||
await ws.send_json(
|
||||
{"type": "contact-error", "detail": "the visitor closed this conversation"}
|
||||
)
|
||||
return
|
||||
if not text or len(text) > MAX_MESSAGE:
|
||||
await ws.send_json({"type": "contact-error", "detail": "bad message"})
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
user = ws.headers.get("remote-user")
|
||||
_append(
|
||||
key,
|
||||
ContactMessage(
|
||||
t=now,
|
||||
md=text,
|
||||
html=render_message(text),
|
||||
admin=user or "admin",
|
||||
),
|
||||
"contact:reply",
|
||||
user,
|
||||
)
|
||||
with kanta.transaction("contact:read", user=user, extra=key.hex()):
|
||||
thread.admin_read = now
|
||||
await _broadcast(key, thread)
|
||||
case "read":
|
||||
key = _key_from_hex(raw.get("id"))
|
||||
thread = data.contact.get(key) if key else None
|
||||
if thread is None:
|
||||
return
|
||||
with kanta.transaction(
|
||||
"contact:read", user=ws.headers.get("remote-user"), extra=key.hex()
|
||||
):
|
||||
thread.admin_read = datetime.now(UTC)
|
||||
await _push(_admin_ws, _payload(key, thread))
|
||||
|
||||
|
||||
# --- Uploads -----------------------------------------------------------------
|
||||
|
||||
#: Contact-panel uploads: images (raster and SVG) go through the usual
|
||||
#: derivative pipeline and inline in the message — SVGs included: the
|
||||
#: extension-less link negotiates raster derivatives, and even an
|
||||
#: as-is-served .svg is inert inside an <img> (and downloads when
|
||||
#: navigated to). Every other type is stored as an inert {hash}.file
|
||||
#: blob (store_attachment — no original extension on disk) and links as
|
||||
#: /_f/{hash}/{original-name}; the .file suffix never appears in URLs.
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".heic", ".heif"}
|
||||
UPLOAD_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@router.put("/_contact/files/{name}")
|
||||
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 and size is capped. Uploads spend
|
||||
the same PoW quota as messages (a solved challenge covers both): with
|
||||
no quota left the answer is 429 "pow" (429, not 403 — no auth flows
|
||||
here) and the client re-proves and retries. The file additionally
|
||||
stays pending per session and is deleted when the session's last
|
||||
socket closes without a sent message referencing it, and a separate
|
||||
quota caps those pending files.
|
||||
Images (raster and SVG) get the usual derivative treatment and are
|
||||
referenced extension-less; any other file is stored as an inert
|
||||
{hash}.file blob and referenced as /_f/{hash}/{original-name}, which
|
||||
is always served as a forced download. Returns the /_f/ path and
|
||||
whether the file is an inline-able image.
|
||||
"""
|
||||
token = request.headers.get("x-contact-token", "")
|
||||
if not TOKEN_RE.fullmatch(token):
|
||||
raise HTTPException(403, "no session")
|
||||
key = thread_key(token)
|
||||
if _quota.get(key, 0) <= 0:
|
||||
raise HTTPException(429, "pow")
|
||||
pending = _pending_uploads.setdefault(key, set())
|
||||
if len(pending) >= MAX_PENDING_UPLOADS:
|
||||
raise HTTPException(429, "too many un-sent uploads — send your message first")
|
||||
try:
|
||||
length = int(request.headers.get("content-length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length > UPLOAD_MAX_BYTES:
|
||||
raise HTTPException(413, "file too large")
|
||||
body = await request.body()
|
||||
if not body:
|
||||
raise HTTPException(400, "empty file")
|
||||
if len(body) > UPLOAD_MAX_BYTES:
|
||||
raise HTTPException(413, "file too large")
|
||||
_spend(key)
|
||||
orig = Path(name).name[:100] or "file"
|
||||
ext = _ext(orig)
|
||||
if ext in IMAGE_EXTS:
|
||||
stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif")
|
||||
# Decodable images come back extension-less (GIFs and as-is SVGs
|
||||
# keep their extension); an undecodable body attaches like any file.
|
||||
if "." not in stored or ext in (".gif", ".svg"):
|
||||
pending.add(stored.partition(".")[0])
|
||||
return {"path": f"/_f/{stored}", "image": True, "name": orig}
|
||||
stored = await asyncio.to_thread(store_attachment, body)
|
||||
pending.add(stored.removesuffix(".file"))
|
||||
return {
|
||||
"path": f"/_f/{stored.removesuffix('.file')}/{quote(orig, safe='')}",
|
||||
"image": False,
|
||||
"name": orig,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/_contact/easypow.js")
|
||||
async def pow_solver() -> Response:
|
||||
"""The easypow browser solver as an ES module, served from the
|
||||
installed package so the client solver and the server validator are
|
||||
always the same version."""
|
||||
return Response(
|
||||
easypow.js,
|
||||
media_type="text/javascript",
|
||||
headers={"cache-control": "public, max-age=86400"},
|
||||
)
|
||||
@@ -16,6 +16,7 @@
|
||||
--line: #12203f14;
|
||||
--font-body: var(--font-inter);
|
||||
--font-heading: var(--font-montserrat);
|
||||
--nav-weight: 600;
|
||||
--code-x-height: 0.546; /* Inter's x-height ratio */
|
||||
}
|
||||
|
||||
@@ -54,7 +55,6 @@
|
||||
|
||||
#nav {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: none;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Nitro theme: racing/HUD style — saturated orange + cyan, console/HUD-style
|
||||
monospace display type, an orange racing-tab nav with a bezier clip-path,
|
||||
and a dark bezier banner. Follows prefers-color-scheme: the light scheme
|
||||
matching slanted-parallelogram buttons, and a dark bezier banner. Follows
|
||||
prefers-color-scheme: the light scheme
|
||||
is a warm light-grey page, the dark scheme a deep violet page; the banner
|
||||
and other dark/bright elements carry over unchanged.
|
||||
|
||||
@@ -41,6 +42,8 @@
|
||||
|
||||
--font-body: var(--font-montserrat);
|
||||
--font-heading: var(--font-literata);
|
||||
--font-nav: var(--font-heading);
|
||||
--nav-weight: 700;
|
||||
--code-x-height: 0.517; /* Montserrat's x-height ratio */
|
||||
/* Neutral grey selection instead of the accent tint: accent-colored
|
||||
text (h2, links, markers) stays readable on it in both schemes. */
|
||||
@@ -86,9 +89,7 @@
|
||||
}
|
||||
|
||||
#nav {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.95em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
/* Racing tab: sitting at the bottom of the banner, with a bezier clip —
|
||||
@@ -135,6 +136,70 @@
|
||||
color: var(--tab-muted);
|
||||
}
|
||||
|
||||
/* Buttons in the article flow (the {contact:} trigger and any raw
|
||||
<button> in the markup): a racing parallelogram with / slanted sides,
|
||||
clipped with the same shape() language as the nav tab. All buttons
|
||||
are forced to one fixed 1.7em height (inline-flex centering, nowrap,
|
||||
no border — it would be invisible under the clip), so every button
|
||||
gets the identical shape and the geometry is exact: a ~62° slant from
|
||||
the 0.9em run. The obtuse top-left and bottom-right corners replicate
|
||||
the nav tab's top-left corner: a wide bezier sweep with the same
|
||||
0.8/0.6 control-handle split along the joining edges (over a 0.5em
|
||||
tangent run), mirrored between the two corners. The sharp corners
|
||||
keep small arcs, in balance with the wide sweeps (0.22em, arc
|
||||
≈ 0.45em — about half the obtuse sweep's, so the sharp corners stay
|
||||
subordinate). border-radius is the fallback for browsers without
|
||||
shape(). Asymmetric inline padding keeps the text clear of the
|
||||
slants (wider on the right: the wide bottom-right sweep cuts deeper
|
||||
into the label's end side). Editor chrome (.icon-btn pens, .copy)
|
||||
keeps its own styling. The {contact:} envelope's line-height is
|
||||
reduced in the base stylesheet so it doesn't outgrow this height. */
|
||||
.contact-trigger,
|
||||
article button:not(.icon-btn):not(.copy) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.7em;
|
||||
padding: 0 1.3em 0 1em;
|
||||
white-space: nowrap;
|
||||
/* Same typography as the contact trigger (raw <button>s otherwise get
|
||||
the small UA font): nav family/weight, article size and leading. */
|
||||
font-family: var(--font-nav);
|
||||
font-weight: var(--nav-weight);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 0.2em;
|
||||
cursor: pointer;
|
||||
clip-path: shape(
|
||||
/* bottom edge, left to right */
|
||||
from 0.365em 100%,
|
||||
hline to calc(100% - 1.4em),
|
||||
/* bottom-right corner: nav-style bezier, mirrored from top-left */
|
||||
curve to calc(100% - 0.666em) calc(100% - 0.442em)
|
||||
with calc(100% - 1.1em) 100% / calc(100% - 0.853em) calc(100% - 0.088em),
|
||||
/* right slant */
|
||||
line to calc(100% - 0.171em) 0.323em,
|
||||
/* top-right corner: sharp, small arc */
|
||||
arc to calc(100% - 0.365em) 0% of 0.22em ccw,
|
||||
/* top edge, right to left */
|
||||
hline to 1.4em,
|
||||
/* top-left corner: nav-style bezier */
|
||||
curve to 0.666em 0.442em with 1.1em 0 / 0.853em 0.088em,
|
||||
/* left slant */
|
||||
line to 0.171em calc(100% - 0.323em),
|
||||
/* bottom-left corner: sharp, small arc */
|
||||
arc to 0.365em 100% of 0.22em ccw,
|
||||
close);
|
||||
}
|
||||
|
||||
article button:not(.icon-btn):not(.copy):hover {
|
||||
color: var(--tab-hover);
|
||||
text-shadow: 0 0 0.1em;
|
||||
}
|
||||
|
||||
/* Console-style headings: uppercase monospace. h1 in the page text color
|
||||
with a hazard-stripe underline, h2 deep orange, h3 cyan. */
|
||||
article h1 {
|
||||
|
||||
+94
-4
@@ -23,13 +23,14 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import easypow
|
||||
import httpx
|
||||
import msgspec
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import Response
|
||||
from uarite import uaparse
|
||||
|
||||
from pagerite import analytics, i18n
|
||||
from pagerite import analytics, i18n, messages
|
||||
from pagerite.data import resolve
|
||||
from pagerite.files import _hash_name, file_store
|
||||
from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
||||
@@ -447,6 +448,18 @@ async def activity_ws(ws: WebSocket) -> None:
|
||||
abusive IPs are filtered at display time, not here. The reverse-DNS and
|
||||
DB-IP geoip lookups happen in background tasks so message handling is
|
||||
never delayed by slow DNS or the first MMDB decompress.
|
||||
|
||||
Frames carrying a ``token`` field are visitor contact messages
|
||||
(docs/messages.md), routed to pagerite.messages — the same socket
|
||||
serves the contact panel, which also receives thread pushes on it.
|
||||
Sends and uploads spend a per-session quota granted by solving an
|
||||
easypow challenge: a single-use challenge (a per-connection local) is
|
||||
issued on registration and whenever the quota runs out; the client
|
||||
submits the solution as soon as it is ready ({token, pow}), validated
|
||||
and burned here, and the quota itself lives in pagerite.messages
|
||||
keyed by token hash (shared with the HTTP upload route, kept across
|
||||
reconnects). A validated proof also reveals the site's contact email
|
||||
and records the visit's contact trail marker in analytics.
|
||||
"""
|
||||
ip = _client_ip(ws)
|
||||
ua = ws.headers.get("user-agent", "")
|
||||
@@ -458,11 +471,70 @@ async def activity_ws(ws: WebSocket) -> None:
|
||||
part for part in (uaparse(ua).pretty, lang) if part
|
||||
)
|
||||
await ws.accept()
|
||||
challenge = "" # outstanding PoW challenge for contact sends (below)
|
||||
try:
|
||||
while True:
|
||||
text = await ws.receive_text()
|
||||
try:
|
||||
msg = msgspec.json.decode(text.encode(), type=analytics.Ping)
|
||||
raw = msgspec.json.decode(text.encode())
|
||||
except msgspec.DecodeError:
|
||||
continue
|
||||
if isinstance(raw, dict) and "token" in raw:
|
||||
# Contact-messaging frames (docs/messages.md).
|
||||
if raw.get("msg"):
|
||||
# A send with no quota left gets a fresh challenge
|
||||
# instead; the client solves it, re-proves and retries.
|
||||
if messages.quota_left(str(raw.get("token") or "")) <= 0:
|
||||
challenge = easypow.generate(messages.POW_SECONDS)
|
||||
await ws.send_json(
|
||||
{"type": "pow", "challenge": challenge, "required": True}
|
||||
)
|
||||
continue
|
||||
await messages.visitor_frame(ws, raw)
|
||||
if messages.quota_left(str(raw.get("token") or "")) <= 0:
|
||||
# Quota just ran out: push the next challenge right
|
||||
# away so the client can pre-solve it.
|
||||
challenge = easypow.generate(messages.POW_SECONDS)
|
||||
await ws.send_json(
|
||||
{"type": "pow", "challenge": challenge, "required": False}
|
||||
)
|
||||
continue
|
||||
if "pow" in raw:
|
||||
# A solved challenge, submitted as soon as ready:
|
||||
# validate and burn it here, grant the quota in
|
||||
# pagerite.messages (shared with the upload route).
|
||||
try:
|
||||
if not challenge:
|
||||
raise ValueError("no outstanding challenge")
|
||||
easypow.validate(challenge, str(raw.pop("pow", "") or ""))
|
||||
except ValueError:
|
||||
challenge = easypow.generate(messages.POW_SECONDS)
|
||||
await ws.send_json(
|
||||
{"type": "pow", "challenge": challenge, "required": True}
|
||||
)
|
||||
continue
|
||||
challenge = "" # burned; a fresh one comes when the quota ends
|
||||
key = await messages.visitor_proven(ws, raw)
|
||||
if key is not None:
|
||||
# Panel opened AND PoW solved: mark the visit's
|
||||
# trail at the page it first happened on.
|
||||
new_client = analytics_store.record_contact(
|
||||
str(raw.get("fr") or ""), ip, ua, accept_language
|
||||
)
|
||||
if new_client is not None:
|
||||
_schedule_client_enrichment([new_client])
|
||||
_schedule_analytics_broadcast()
|
||||
continue
|
||||
# Registration/resync: the thread state is pushed back, and
|
||||
# a fresh challenge is issued for the client to pre-solve.
|
||||
await messages.visitor_frame(ws, raw)
|
||||
challenge = easypow.generate(messages.POW_SECONDS)
|
||||
await ws.send_json(
|
||||
{"type": "pow", "challenge": challenge, "required": False}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
msg = msgspec.convert(raw, type=analytics.Ping)
|
||||
except msgspec.DecodeError:
|
||||
continue
|
||||
new_client = analytics_store.record_msg(
|
||||
@@ -480,6 +552,8 @@ async def activity_ws(ws: WebSocket) -> None:
|
||||
_schedule_favicon_fetch()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
messages.visitor_disconnect(ws)
|
||||
|
||||
|
||||
@router.websocket("/_api/ws/analytics")
|
||||
@@ -488,15 +562,31 @@ async def analytics_websocket(ws: WebSocket) -> None:
|
||||
|
||||
Admin-only via the /_api forward-auth gate, like every management
|
||||
endpoint. Powers the analytics viewer rendered at /_a.
|
||||
|
||||
The same socket serves the admin contact inbox (the overlay opened
|
||||
from the corner ✉️ icon, docs/messages.md):
|
||||
on connect the thread list follows the snapshot, new visitor messages
|
||||
are pushed as they arrive, and incoming frames (``reply``/``read``)
|
||||
go to pagerite.messages. Analytics payloads carry no ``type`` field;
|
||||
messaging payloads always do, so the client can tell them apart.
|
||||
"""
|
||||
await ws.accept()
|
||||
await ws.send_text(_display_json())
|
||||
_analytics_ws_clients.add(ws)
|
||||
await messages.admin_connect(ws)
|
||||
try:
|
||||
# Receive until the client goes away; we only push.
|
||||
# Receive until the client goes away; messaging frames are handled,
|
||||
# anything else is ignored (analytics only pushes).
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
text = await ws.receive_text()
|
||||
try:
|
||||
raw = msgspec.json.decode(text.encode())
|
||||
except msgspec.DecodeError:
|
||||
continue
|
||||
if isinstance(raw, dict):
|
||||
await messages.admin_frame(ws, raw)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Analytics WS client disconnected")
|
||||
finally:
|
||||
_analytics_ws_clients.discard(ws)
|
||||
messages.admin_disconnect(ws)
|
||||
@@ -309,6 +309,15 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
|
||||
walk(node.children, path, node_lang)
|
||||
|
||||
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
||||
# Site-level messaging strings (Data.contact_label/contact_subject):
|
||||
# translated like titles and looked up at render time by chunk key
|
||||
# (i18n.site_string). Skipped when messaging is off (no label — the
|
||||
# label doubles as the enable switch) and in the site default
|
||||
# language, like titles in their article's primary language.
|
||||
if data.contact_label.strip() and lang != i18n.primary_lang(data.menu, ""):
|
||||
for text in (data.contact_label, data.contact_subject):
|
||||
if text and "\n" not in text:
|
||||
emit(chunk_key(text), text, "", "title")
|
||||
return items
|
||||
|
||||
|
||||
@@ -536,6 +545,19 @@ class Dispatcher:
|
||||
walk(node.children, depth + 1, node_lang)
|
||||
|
||||
walk(self.data.menu, 0, i18n.ORIGINAL_LANGUAGE)
|
||||
# The site-level messaging strings ride the same list (pending_items
|
||||
# covers them for scoped jobs): one round trip names the menu and
|
||||
# the contact chrome alike.
|
||||
if self.data.contact_label.strip() and lang != i18n.primary_lang(self.data.menu, ""):
|
||||
for text in (self.data.contact_label, self.data.contact_subject):
|
||||
if text and "\n" not in text:
|
||||
key = chunk_key(text)
|
||||
if (
|
||||
lang not in self.data.trans.get(key, {})
|
||||
and (lang, key) not in inflight
|
||||
and (lang, key, "nav") not in self.validation_failures
|
||||
):
|
||||
titles.append((0, text, key))
|
||||
if len(titles) < 2:
|
||||
return None
|
||||
md = "\n".join(f"{' ' * depth}- {title}" for depth, title, _ in titles)
|
||||
|
||||
+92
-8
@@ -337,6 +337,7 @@ def _layout(
|
||||
canonical: str = "",
|
||||
alternates: list[tuple[str, str]] = (),
|
||||
markdown_url: str = "",
|
||||
contact: dict[str, str] | None = None,
|
||||
) -> Template:
|
||||
"""Page layout template with standard assets and ES-module scripts.
|
||||
|
||||
@@ -369,6 +370,9 @@ def _layout(
|
||||
``alternates`` ((hreflang, href) pairs) are the page's language URLs
|
||||
(see docs/localization.md), emitted right after the viewport and before
|
||||
the social tags: canonical first, then the hreflang alternates.
|
||||
|
||||
``contact`` carries the localized contact-messaging strings
|
||||
(_contact_config; None = messaging disabled) into the asset config.
|
||||
"""
|
||||
doc = Document(
|
||||
E.Title, lang=lang, dir="rtl" if lang in i18n.RTL_LANGUAGES else "ltr"
|
||||
@@ -415,15 +419,27 @@ def _layout(
|
||||
vite_url = env.vite_url
|
||||
editor_scripts, editor_css = _editor_assets()
|
||||
langselect_scripts, langselect_css = _langselect_assets()
|
||||
contact_scripts, contact_css = _contact_assets()
|
||||
config = {
|
||||
"pagerite:editor-src": editor_scripts[-1],
|
||||
"pagerite:analytics-src": _analytics_assets()[0][0],
|
||||
"pagerite:langselect-src": langselect_scripts[-1],
|
||||
"pagerite:contact-src": contact_scripts[-1],
|
||||
}
|
||||
if langselect_css:
|
||||
config["pagerite:langselect-css"] = ",".join(langselect_css)
|
||||
if contact_css:
|
||||
config["pagerite:contact-css"] = ",".join(contact_css)
|
||||
if editor_css:
|
||||
config["pagerite:editor-css"] = editor_css
|
||||
# Localized contact-messaging strings (label = the {contact:} buttons'
|
||||
# default and the visitor dialog's title, mailto subject — the address
|
||||
# itself is revealed over the socket only after the PoW is solved).
|
||||
# Absent entirely when messaging is disabled: pagerite.js then shows
|
||||
# the corner ✉️ icon to admins only.
|
||||
if contact is not None:
|
||||
config["pagerite:contact-label"] = contact["label"]
|
||||
config["pagerite:contact-subject"] = contact["subject"]
|
||||
if vite_url:
|
||||
for key, value in config.items():
|
||||
doc.meta(name=key, content=value)
|
||||
@@ -900,13 +916,18 @@ def page_content(
|
||||
# A {cards} tag places the card rows itself (possibly several);
|
||||
# without one the children are appended after the content as before.
|
||||
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
|
||||
directives = None
|
||||
# Messaging disabled: {contact:} tags render nothing at all.
|
||||
directives = {
|
||||
"contact": (
|
||||
(lambda args, _env: _contact_tag(args, _env, data, lang))
|
||||
if data.contact_label.strip()
|
||||
else (lambda _args, _env: "")
|
||||
)
|
||||
}
|
||||
if has_cards_tag:
|
||||
directives = {
|
||||
"cards": lambda args, _env: _cards_tag(
|
||||
menu, data, node, path, args, translation, link_lang, lang
|
||||
)
|
||||
}
|
||||
directives["cards"] = lambda args, _env: _cards_tag(
|
||||
menu, data, node, path, args, translation, link_lang, lang
|
||||
)
|
||||
rendered = render(
|
||||
content,
|
||||
path,
|
||||
@@ -984,6 +1005,43 @@ def _cards(
|
||||
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _contact_config(data: Data, lang: str) -> dict[str, str]:
|
||||
"""The localized contact-messaging strings for the page's asset config
|
||||
(pagerite:contact-*): the {contact:} buttons' default label doubling as
|
||||
the visitor dialog's title, and the mailto subject (the label when no
|
||||
separate subject is configured). Translations come from the
|
||||
chunk-keyed trans store (i18n.site_string). The email address itself
|
||||
is NOT here — it is revealed over the messaging socket only after the
|
||||
proof of work is solved, so scrapers cannot lift it from pages."""
|
||||
label = i18n.site_string(data, lang, data.contact_label)
|
||||
return {
|
||||
"label": label,
|
||||
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
|
||||
}
|
||||
|
||||
|
||||
def _contact_tag(args: str, _env: dict, data: Data | None = None, lang: str = "") -> str:
|
||||
"""Expand a ``{contact: Label}`` directive: the button that opens the
|
||||
visitor contact panel (docs/messages.md). The args are a per-button
|
||||
label overriding the configured site label (Data.contact_label,
|
||||
localized here; "Contact us" is the last-resort fallback). Callers
|
||||
expand the tag only when messaging is enabled — with a blank
|
||||
contact_label the tag renders nothing at all. pagerite.js binds the
|
||||
click and lazy-loads the panel bundle; in the editor preview the
|
||||
button stays inert."""
|
||||
label = args.strip()
|
||||
if not label and data is not None:
|
||||
label = i18n.site_string(data, lang, data.contact_label)
|
||||
return str(
|
||||
E.button(
|
||||
E.span("✉️", class_="icon"),
|
||||
label or "Contact us",
|
||||
type="button",
|
||||
class_="contact-trigger",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cards_tag(
|
||||
menu: dict[str, Node],
|
||||
data: Data,
|
||||
@@ -1123,7 +1181,7 @@ def _card(
|
||||
node.modified,
|
||||
# Card heuristics only mine the prose: nested {cards} rows
|
||||
# would just be noise in the description extraction.
|
||||
directives={"cards": lambda _args, _env: ""},
|
||||
directives={"cards": lambda _args, _env: "", "contact": lambda _a, _e: ""},
|
||||
).html
|
||||
image, _ = _media(html)
|
||||
if not image:
|
||||
@@ -1143,7 +1201,7 @@ def _card(
|
||||
path,
|
||||
node.created,
|
||||
node.modified,
|
||||
directives={"cards": lambda _args, _env: ""},
|
||||
directives={"cards": lambda _args, _env: "", "contact": lambda _a, _e: ""},
|
||||
).html
|
||||
if not large and html:
|
||||
description = _description(html, 150)
|
||||
@@ -1403,6 +1461,7 @@ def render_page(
|
||||
alternates,
|
||||
# The page's Markdown counterpart (pages.show_page's .md mode).
|
||||
f"/{path}.md",
|
||||
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
|
||||
)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
@@ -1466,6 +1525,7 @@ def render_category(
|
||||
favicon,
|
||||
lang=lang,
|
||||
alternates=alternates,
|
||||
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
|
||||
)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
@@ -1586,6 +1646,30 @@ def _langselect_assets() -> tuple[list[str], list[str]]:
|
||||
return _asset_cache["langselect"]
|
||||
|
||||
|
||||
def _contact_assets() -> tuple[list[str], list[str]]:
|
||||
"""Script and stylesheet URLs for the on-demand contact panel."""
|
||||
vite_url = env.vite_url
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/contact-main.js"], []
|
||||
if "contact" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
# import() loads no CSS automatically: collect the stylesheets of
|
||||
# the entry and its imported chunks (same walk as _langselect).
|
||||
scripts, stylesheets, seen = [], [], set()
|
||||
queue = ["src/contact-main.js"]
|
||||
for key in queue: # grows with imported chunks
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entry = manifest[key]
|
||||
if entry.get("isEntry"):
|
||||
scripts.append(f"/{entry['file']}")
|
||||
stylesheets += [f"/{css}" for css in entry.get("css", [])]
|
||||
queue += entry.get("imports", [])
|
||||
_asset_cache["contact"] = scripts, stylesheets
|
||||
return _asset_cache["contact"]
|
||||
|
||||
|
||||
def render_analytics(
|
||||
menu: dict[str, Node],
|
||||
brand: str = SITE_NAME,
|
||||
|
||||
@@ -17,6 +17,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"blake3>=1.0.9",
|
||||
"easypow>=0.1.0",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi[standard]>=0.141.1",
|
||||
"html5tagger>=2.0.0",
|
||||
|
||||
Reference in new issue
Block a user