From 44c8c9d410fed56c2ccb485ab5bbbe22f855c3f4 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 5 Aug 2026 13:45:56 +0200 Subject: [PATCH] Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback - type: prosekit requirement kind, backed by public/prosekit-editor.js (ProseMirror via prosekit, loaded from esm.sh, no bundler) - mirrors its HTML into a paired hidden input so it reuses the existing RwSignal/on:input wiring. - Pasting a project.uhhm.no// URL in the editor embeds a repo card, resolved server-side via a new /gitea-repo handler (content::gitea_repo_handler) so the browser never needs Gitea API CORS. - New /automation/kv/{bucket} handler, bearer-token gated (AUTOMATION_READ_TOKEN), for backing automations (n8n) to read a NATS KV bucket without a browser session. - Fix: a login started on one of apex/www set its session cookie there, but Kanidm's redirect_uri is fixed to PUBLIC_URL - landing the callback on a different, empty session ("no login in progress"). Caddy now redirects www -> apex so every visit stays on one canonical host. --- .gitea/workflows/deploy.yml | 15 +++- prosekit-editor.js | 169 ++++++++++++++++++++++++++++++++++++ src/app.rs | 60 ++++++++++++- src/content.rs | 161 ++++++++++++++++++++++++++++++++-- src/main.rs | 4 + src/server/mod.rs | 5 ++ 6 files changed, 405 insertions(+), 9 deletions(-) create mode 100644 prosekit-editor.js diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 9bdb898..52a026a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -80,6 +80,7 @@ jobs: # (no target/ prefix) - override so the running binary looks # in the right place for /pkg/*. LEPTOS_SITE_ROOT=site + AUTOMATION_READ_TOKEN=${{ secrets.PORTAL_AUTOMATION_READ_TOKEN }} EOF # No sudo: the runner's own unit sets NoNewPrivileges=yes, which @@ -93,10 +94,22 @@ jobs: # No sudo here either - the runner is already in the `docker` group, # so it can talk to the Docker socket directly. + # Apex and www are separate cookie scopes (no shared Domain + # attribute on the session cookie), but Kanidm's redirect_uri is + # fixed to PUBLIC_URL - a login started on the other host set its + # session cookie there, then landed on PUBLIC_URL's callback with + # an empty session ("no login in progress"). Redirecting www to + # the naked domain keeps every visit on one canonical host + # instead - PUBLIC_URL (repo variable) is set to https://{$DOMAIN} + # to match. - name: Update Caddy routing run: | cat > /etc/caddy/services.d/uhhm-portal.caddy <<'EOF' - www.{$DOMAIN}, {$DOMAIN} { + www.{$DOMAIN} { + redir https://{$DOMAIN}{uri} permanent + } + + {$DOMAIN} { reverse_proxy host.docker.internal:3010 log { output file /var/log/caddy/www.log diff --git a/prosekit-editor.js b/prosekit-editor.js new file mode 100644 index 0000000..60d2561 --- /dev/null +++ b/prosekit-editor.js @@ -0,0 +1,169 @@ +// Glue for `type: prosekit` requirement fields (see AlternativeCard in +// src/app.rs) - mounts a headless ProseKit (ProseMirror) rich-text +// editor into a container element, then mirrors its rendered HTML into +// a paired hidden on every change, firing a real `input` event +// on it. That lets the Rust side reuse the exact same RwSignal/on:input +// wiring every other requirement kind already goes through, instead of +// needing its own submit-time collection path. +// +// Loaded straight from a CDN as an ES module (no bundler in this +// project, same pattern as yes.js) - prosekit ships pre-bundled ESM, +// and re-exports the ProseMirror packages it's built on under +// `prosekit/pm/*`, so importing primitives (InputRule, etc.) from there +// instead of a separate prosemirror-* package guarantees they're the +// exact same module instance the editor itself uses. +// +// Least-verified part of this file (worth an actual hwatu pass after +// deploy, not just trusting the docs): the exact shape `createEditor` +// wants for `defaultContent` when seeding from an HTML string, and the +// precise `definePasteHandler` callback signature. Everything else here +// mirrors prosekit's own documented examples closely. + +import 'https://esm.sh/prosekit/basic/style.css' +import 'https://esm.sh/prosekit/basic/typography.css' +import { defineBasicExtension } from 'https://esm.sh/prosekit/basic' +import { + createEditor, + defineNodeSpec, + definePasteHandler, + defineUpdateHandler, + union, +} from 'https://esm.sh/prosekit/core' + +// project.uhhm.no//, no trailing path - a bare repo link, +// not a file/issue/PR link within one (those aren't "the repo", and +// trying to card-ify them too isn't what was asked for). +const GITEA_REPO_URL_RE = /^https:\/\/project\.uhhm\.no\/([\w.-]+)\/([\w.-]+)\/?$/ + +// Loads each stylesheet once regardless of how many editors this page +// mounts - tags, not a JS import, since plain `import 'x.css'` +// isn't valid outside a bundler and esm.sh's CSS-as-JS support isn't +// worth depending on here. +function ensureStylesheet(href) { + if (document.querySelector(`link[href="${href}"]`)) return + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = href + document.head.appendChild(link) +} + +function defineGiteaRepoEmbed() { + return defineNodeSpec({ + name: 'giteaRepoEmbed', + group: 'block', + atom: true, + selectable: true, + attrs: { + owner: { default: '' }, + repo: { default: '' }, + description: { default: '' }, + url: { default: '' }, + }, + parseDOM: [ + { + tag: 'div[data-gitea-repo]', + getAttrs: (dom) => ({ + owner: dom.getAttribute('data-owner') || '', + repo: dom.getAttribute('data-repo') || '', + description: dom.getAttribute('data-description') || '', + url: dom.getAttribute('data-url') || '', + }), + }, + ], + // Plain inline styles, not a class - this same markup is what ends + // up as the newsletter's HTML body, read by an email client with no + // access to this app's stylesheet. + toDOM: (node) => { + const { owner, repo, description, url } = node.attrs + return [ + 'div', + { + 'data-gitea-repo': 'true', + 'data-owner': owner, + 'data-repo': repo, + 'data-description': description, + 'data-url': url, + style: + 'display:block;border:1px solid #ddd;border-radius:8px;' + + 'padding:12px 16px;margin:8px 0;font-family:inherit;', + }, + [ + 'a', + { href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' }, + `${owner}/${repo}`, + ], + ['div', { style: 'opacity:0.7;font-size:0.9em;margin-top:4px;' }, description], + ] + }, + }) +} + +// Resolves owner/repo via portal's own /gitea-repo handler +// (content::gitea_repo_handler in src/content.rs), not Gitea's API +// directly - keeps this editor from needing a CORS allowance on +// Gitea's side, and matches every other backing store in this app +// being server-resolved only. +async function resolveGiteaRepo(owner, repo) { + const res = await fetch( + `/gitea-repo?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`, + ) + if (!res.ok) { + throw new Error(`resolve_gitea_repo failed with status ${res.status}`) + } + return await res.json() +} + +function defineGiteaRepoEmbedPasteRule() { + return definePasteHandler((view, event) => { + const text = event.clipboardData?.getData('text/plain')?.trim() + const match = text && text.match(GITEA_REPO_URL_RE) + if (!match) return false + + event.preventDefault() + const [, owner, repo] = match + const pos = view.state.selection.from + + resolveGiteaRepo(owner, repo) + .then((info) => { + const nodeType = view.state.schema.nodes.giteaRepoEmbed + if (!nodeType) return + const node = nodeType.create(info) + const tr = view.state.tr.replaceRangeWith(pos, pos, node) + view.dispatch(tr) + }) + .catch((err) => { + console.error('gitea repo embed failed', err) + }) + return true + }) +} + +export function mountEditor(container, hiddenInput, initial) { + ensureStylesheet('https://esm.sh/prosekit/basic/style.css') + ensureStylesheet('https://esm.sh/prosekit/basic/typography.css') + + const extension = union( + defineBasicExtension(), + defineGiteaRepoEmbed(), + defineGiteaRepoEmbedPasteRule(), + ) + + const editor = createEditor({ + extension, + // Seeds from the hidden input's current value (only ever non-empty + // when re-mounting content that already went through this same + // editor, since that's the only place this HTML shape comes from). + defaultContent: initial || undefined, + }) + + const sync = () => { + const html = editor.view.dom.innerHTML + if (hiddenInput.value === html) return + hiddenInput.value = html + hiddenInput.dispatchEvent(new Event('input', { bubbles: true })) + } + + editor.use(defineUpdateHandler(sync)) + editor.mount(container) + sync() +} diff --git a/src/app.rs b/src/app.rs index 37d4c3d..293534e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -198,6 +198,31 @@ mod yes { } } +// Same idiomatic-typed-module approach as `yes` above, for the +// prosekit-backed rich-text requirement kind (`prosekit-editor.js`, +// project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative +// to the crate root, not the `public/` static-asset dir, same as +// `yes.js` below). `mount_editor` hands the editor a container node +// plus the paired +// hidden `` it mirrors its HTML into and fires real `input` +// events on - see `mount_editor`'s call site in `AlternativeCard`, and +// the JS module itself for why a hidden-input bridge rather than a +// bespoke Rust<->JS value channel. +#[cfg(feature = "hydrate")] +mod prosekit { + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(module = "/prosekit-editor.js")] + extern "C" { + #[wasm_bindgen(js_name = mountEditor)] + pub fn mount_editor( + container: &web_sys::HtmlDivElement, + hidden: &web_sys::HtmlInputElement, + initial: &str, + ); + } +} + #[component] fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { // Only the landing page gets the full interactive piece - it's the @@ -427,6 +452,8 @@ fn AlternativeCard( }; + let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new())); + if req.kind == "file" { let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new); return view! { @@ -445,7 +472,38 @@ fn AlternativeCard( .into_any(); } - let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new())); + if req.kind == "prosekit" { + let container_ref: NodeRef = NodeRef::new(); + let hidden_ref: NodeRef = NodeRef::new(); + + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + let (Some(container), Some(hidden)) = + (container_ref.get(), hidden_ref.get()) + else { + return; + }; + prosekit::mount_editor(&container, &hidden, &sig.get_untracked()); + }); + } + + return view! { + + } + .into_any(); + } + view! {