Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
Deploy / deploy (push) Successful in 29s

- 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/<owner>/<repo> 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.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-05 13:45:56 +02:00
parent 18025bf870
commit 44c8c9d410
6 changed files with 405 additions and 9 deletions
+14 -1
View File
@@ -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
+169
View File
@@ -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 <input> 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/<owner>/<repo>, 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 - <link> 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()
}
+59 -1
View File
@@ -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 `<input>` 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(
</span>
};
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<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = 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! {
<label class="field" for=label_for>
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="prosekit-editor" node_ref=container_ref></div>
</label>
}
.into_any();
}
view! {
<label class="field" for=label_for>
{label_text}
+154 -7
View File
@@ -143,6 +143,24 @@ impl Requirement {
}
}
/// Extracts `scheme://host` from a repo's normal browser URL (e.g.
/// `https://project.uhhm.no/uhhm/questions` -> `https://project.uhhm.no`)
/// - the Gitea API base every helper in this module builds requests
/// against, including ones (like `resolve_gitea_repo`) that have
/// nothing to do with content loading, just the same Gitea instance.
#[cfg(feature = "ssr")]
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?;
Ok(format!(
"{}://{}",
parsed.scheme(),
parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))?
))
}
/// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a
/// `Question`, keyed by its own `id`. `repo_url` is the repo's normal
/// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the
@@ -158,13 +176,7 @@ pub async fn load_questions_from_gitea(
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?;
let api_base = format!(
"{}://{}",
parsed.scheme(),
parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("no host in content repo url {repo_url}"))?
);
let api_base = gitea_api_base(repo_url)?;
let mut segments = parsed
.path_segments()
.ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?;
@@ -261,3 +273,138 @@ pub async fn watch_for_reload(
}
}
}
/// A public Gitea repo's basic info - what `resolve_gitea_repo` returns
/// for the prosekit editor's repo-embed node to render as a static
/// card, baked in once at embed time rather than re-fetched by every
/// reader (an emailed newsletter can't run JS to do that anyway).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GiteaRepoInfo {
pub owner: String,
pub repo: String,
pub description: String,
pub url: String,
}
#[derive(Deserialize)]
#[cfg(feature = "ssr")]
pub struct GiteaRepoQuery {
pub owner: String,
pub repo: String,
}
/// Looks up `owner/repo` on the same Gitea instance content is loaded
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
/// caller here is the prosekit editor's own paste-to-embed rule (see
/// `prosekit-editor.js`) doing a plain `fetch`, the same reason
/// `/upload` (`src/upload.rs`) is a raw handler rather than a `#[server]`
/// fn. Keeping this server-resolved (rather than having the browser
/// call Gitea's API directly) is consistent with every other backing
/// store in this app, and sidesteps needing a CORS allowance on Gitea's
/// side just for this. No auth, same as content loading - resolves
/// only what's already public.
#[cfg(feature = "ssr")]
pub async fn gitea_repo_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Query(query): axum::extract::Query<GiteaRepoQuery>,
) -> Result<axum::Json<GiteaRepoInfo>, (axum::http::StatusCode, String)> {
let is_safe_segment = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
};
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
}
let GiteaRepoQuery { owner, repo } = query;
let client = openidconnect::reqwest::Client::new();
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
let body = client
.get(&api_url)
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?
.text()
.await
.map_err(|e| {
(
axum::http::StatusCode::BAD_GATEWAY,
format!("reading repo info from {api_url}: {e}"),
)
})?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
(
axum::http::StatusCode::BAD_GATEWAY,
format!("parsing repo info from {api_url}: {e}"),
)
})?;
Ok(axum::Json(GiteaRepoInfo {
owner: owner.clone(),
repo: repo.clone(),
description: json
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
url: json
.get("html_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}/{owner}/{repo}", state.gitea_base)),
}))
}
/// Lists every entry in a NATS KV bucket as JSON - a raw Axum handler
/// (mounted at `/automation/kv/{bucket}` in `main.rs`), for backing
/// automations (e.g. an n8n workflow reading `portal_subscribers` to
/// send a newsletter) that aren't a signed-in browser session and so
/// can't go through `resource::get_resource`'s Kanidm-group check.
/// Gated by a single shared bearer token (`AUTOMATION_READ_TOKEN`) -
/// deliberately not per-caller/per-bucket scoped, since every current
/// caller is a trusted internal automation, not a third party. Read
/// only, matching `get_resource`'s own "reads can be public/shared,
/// mutations always need real identity" split - nothing here writes.
#[cfg(feature = "ssr")]
pub async fn automation_kv_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(bucket): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, String)> {
let expected = std::env::var("AUTOMATION_READ_TOKEN").unwrap_or_default();
let presented = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if expected.is_empty() || presented != expected {
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
}
let store = state
.jetstream
.get_key_value(&bucket)
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
use futures::TryStreamExt;
let keys: Vec<String> = store
.keys()
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?
.try_collect()
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?;
let mut items = Vec::new();
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
items.push(value);
}
}
}
Ok(axum::Json(serde_json::Value::Array(items)))
}
+4
View File
@@ -28,6 +28,7 @@ async fn main() -> anyhow::Result<()> {
let content_repo = std::env::var("CONTENT_REPO")
.unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string());
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
let gitea_base = content::gitea_api_base(&content_repo)?;
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
@@ -70,6 +71,7 @@ async fn main() -> anyhow::Result<()> {
nats,
jetstream,
questions,
gitea_base,
oidc: oidc_state,
garage,
};
@@ -122,6 +124,8 @@ async fn main() -> anyhow::Result<()> {
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
.route("/upload", post(upload::upload))
.route("/gitea-repo", get(content::gitea_repo_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
.nest_service("/pkg", ServeDir::new(pkg_dir))
.nest_service("/fonts", ServeDir::new(fonts_dir))
.route_service("/favicon.svg", ServeFile::new(favicon_path))
+5
View File
@@ -22,6 +22,11 @@ pub struct AppState {
/// a lock, just an atomic pointer load, so a reload never blocks or
/// is blocked by an in-flight request.
pub questions: Arc<ArcSwap<HashMap<String, Question>>>,
/// `scheme://host` of the Gitea instance content is loaded from
/// (see `content::gitea_api_base`) - kept alongside `questions`
/// rather than re-derived per call, since `resolve_gitea_repo` needs
/// it too and has no other reason to see `CONTENT_REPO` itself.
pub gitea_base: String,
pub oidc: Arc<oidc::Oidc>,
/// `None` when `GARAGE_*` env vars aren't set - uploads are the one
/// optional feature, everything else works without Garage.