diff --git a/src/content/gitea.rs b/src/content/gitea.rs new file mode 100644 index 0000000..06c438b --- /dev/null +++ b/src/content/gitea.rs @@ -0,0 +1,252 @@ +//! Loading a content repo from Gitea, and the NATS-signalled reload +//! that hot-swaps it (last-good content is kept on any failure). Every +//! fetch goes through `crate::http`: bounded in time and size. + +use super::*; + +/// Fetches `needs.yaml` from the content repo root - the business's +/// needs spec `question_lint` checks the pages against (see +/// `crate::needs`). Optional: a fetch failure means the repo declares +/// none. The runtime never reads it. +#[cfg(feature = "ssr")] +pub async fn load_needs_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result> { + let (owner, repo) = parse_owner_repo(repo_url)?; + let api_base = gitea_api_base(repo_url)?; + Ok(fetch_gitea_file(&api_base, &owner, &repo, branch, "needs.yaml") + .await + .ok()) +} + +/// Fetches and parses `site.yaml` from the content repo root. A fetch +/// failure (typically 404 - the file is optional) yields the default +/// config; a file that exists but doesn't parse or validate is a real +/// error, surfaced at boot rather than papered over. +#[cfg(feature = "ssr")] +pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result { + let (owner, repo) = parse_owner_repo(repo_url)?; + let api_base = gitea_api_base(repo_url)?; + let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await { + Ok(raw) => raw, + Err(e) => { + tracing::info!("no site.yaml in content repo ({e}), using default branding"); + return Ok(SiteConfig::default()); + } + }; + let site: SiteConfig = + serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?; + site.validate()?; + Ok(site) +} + +/// Extracts `scheme://host` from a repo's normal browser URL - the +/// Gitea API base every helper in this module builds requests against. +#[cfg(feature = "ssr")] +/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base +/// `site_asset_handler` proxies content-shipped files from. +#[cfg(feature = "ssr")] +pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result { + let (owner, repo) = parse_owner_repo(repo_url)?; + Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?)) +} + +#[cfg(feature = "ssr")] +pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { + 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}"))? + )) +} + +/// Splits a repo's normal browser URL into `(owner, repo)` - shared by +/// every loader in this module that needs to build a Gitea contents API +/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`). +#[cfg(feature = "ssr")] +pub(crate) fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> { + let parsed = url::Url::parse(repo_url) + .map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?; + let mut segments = parsed + .path_segments() + .ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?; + let owner = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))? + .to_string(); + let repo = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))? + .to_string(); + Ok((owner, repo)) +} + +/// Fetches one file from a Gitea repo's contents API and returns its raw +/// text - the single-file counterpart to `load_questions_from_gitea`'s +/// directory-listing loop, used by `load_aggregates_from_gitea` for the +/// one `aggregates.yaml` file at the repo root. +#[cfg(feature = "ssr")] +pub(crate) async fn fetch_gitea_file( + api_base: &str, + owner: &str, + repo: &str, + branch: &str, + path: &str, +) -> anyhow::Result { + let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}"); + let meta_text = crate::http::get_text(&meta_url, crate::http::GITEA_CAP) + .await + .map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))?; + let meta: serde_json::Value = serde_json::from_str(&meta_text) + .map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?; + let download_url = meta + .get("download_url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?; + crate::http::get_text(download_url, crate::http::GITEA_CAP) + .await + .map_err(|e| anyhow::anyhow!("fetching {path}: {e}")) +} + +/// Fetches and parses `aggregates.yaml` from the repo root (sibling to +/// the pages `subdir`, so the "every yaml under subdir is a page" +/// convention needs no exclusion). A malformed file fails here, before +/// ever reaching `AppState`. +#[cfg(feature = "ssr")] +pub async fn load_aggregates_from_gitea( + repo_url: &str, + branch: &str, +) -> anyhow::Result> { + let (owner, repo) = parse_owner_repo(repo_url)?; + let api_base = gitea_api_base(repo_url)?; + let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?; + crate::aggregates::parse_aggregates_yaml(&raw) +} + +#[cfg(feature = "ssr")] +pub async fn load_questions_from_gitea( + repo_url: &str, + branch: &str, + subdir: &str, +) -> anyhow::Result> { + let (owner, repo) = parse_owner_repo(repo_url)?; + let api_base = gitea_api_base(repo_url)?; + + // One recursive git-trees call for the whole repo instead of a + // contents listing per directory - the tree IS the router now, so + // nested files matter. + let tree_url = + format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"); + let listing = crate::http::get_text(&tree_url, crate::http::GITEA_CAP) + .await + .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?; + let tree: serde_json::Value = serde_json::from_str(&listing) + .map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?; + if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) { + anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large"); + } + let prefix = format!("{subdir}/"); + + let mut files = Vec::new(); + for entry in tree + .get("tree") + .and_then(|v| v.as_array()) + .map(|v| v.as_slice()) + .unwrap_or_default() + { + let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or(""); + if entry.get("type").and_then(|v| v.as_str()) != Some("blob") + || !path.starts_with(&prefix) + || !path.ends_with(".yaml") + { + continue; + } + // Brackets (dynamic pages like `[record].yaml`) must be + // percent-encoded in the raw URL's path. + let encoded = path.replace('[', "%5B").replace(']', "%5D"); + let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}"); + let raw = crate::http::get_text(&raw_url, crate::http::GITEA_CAP) + .await + .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?; + files.push((path[prefix.len()..].to_string(), raw)); + } + build_questions(&files) +} + +/// Published by the content repo's own CI (after it lints a push) to +/// tell every running instance to pick up the change - a plain fire +/// and forget NATS publish, no payload, matching `events.rs`'s +/// `ANSWERS_SUBJECT` pattern. +#[cfg(feature = "ssr")] +pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload"; + +/// Runs for the life of the process: re-fetches `repo_url`/`branch` and +/// atomically swaps both `questions` and `aggregates` on every +/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together, +/// after both have loaded and validated successfully, so a reader never +/// sees pages that reference a half-updated state graph. A fetch/parse +/// failure logs and keeps serving the last-good content rather than +/// clearing it - a bad push to the content repo (which should already +/// have been caught by its own lint step) doesn't take the site down. +#[cfg(feature = "ssr")] +pub async fn watch_for_reload( + nats: async_nats::Client, + repo_url: String, + branch: String, + subdir: String, + questions: std::sync::Arc>>, + aggregates: std::sync::Arc< + arc_swap::ArcSwap>, + >, + site: std::sync::Arc>, +) { + let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await { + Ok(sub) => sub, + Err(e) => { + tracing::error!(error = %e, "failed to subscribe to content reload subject"); + return; + } + }; + use futures::StreamExt; + while sub.next().await.is_some() { + let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await { + Ok(loaded) => with_builtin_aggregates(loaded), + Err(e) => { + tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content"); + continue; + } + }; + // Branding swaps with the same all-or-nothing rule: a + // present-but-broken site.yaml keeps last-good everything + // (load_site distinguishes "absent" - a normal default - from + // "present but invalid"). + let loaded_site = match load_site_from_gitea(&repo_url, &branch).await { + Ok(loaded) => loaded, + Err(e) => { + tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content"); + continue; + } + }; + match load_questions_from_gitea(&repo_url, &branch, &subdir).await { + Ok(loaded) => { + if let Err(e) = validate_questions(&loaded, &loaded_aggregates) { + tracing::error!(error = %e, "content reload failed validation, keeping last-good content"); + continue; + } + let count = loaded.len(); + questions.store(std::sync::Arc::new(loaded)); + aggregates.store(std::sync::Arc::new(loaded_aggregates)); + site.store(std::sync::Arc::new(loaded_site)); + tracing::info!(count, "reloaded content"); + } + Err(e) => { + tracing::error!(error = %e, "content reload failed, keeping last-good content"); + } + } + } +} + diff --git a/src/content/handlers.rs b/src/content/handlers.rs new file mode 100644 index 0000000..b006571 --- /dev/null +++ b/src/content/handlers.rs @@ -0,0 +1,193 @@ +//! The Axum routes that expose content and buckets outside the Leptos +//! app: content-shipped assets proxied same-origin, repo info for the +//! showcase, and the automation read API over KV buckets. + +use super::*; + +/// A repo-relative asset path portal is willing to proxy from the +/// content repo: plain segments only - no traversal, no leading slash, +/// no scheme - so `/site/` can only ever reach the content repo. +pub fn is_safe_site_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && path.split('/').all(|seg| { + !seg.is_empty() + && seg != ".." + && seg + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + }) +} + +/// 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, +} + +/// `GET /site/{*path}` - serves a file from the content repo's default +/// branch same-origin, so content-shipped assets (the hero module, +/// its stylesheet) are importable by the page: Gitea's raw endpoint +/// sends no CORS headers, and an ES module import across origins is +/// refused without them. Path is validated to plain segments +/// (`is_safe_site_path`), the response revalidates like /pkg +/// (Cache-Control: no-cache) so a content push shows up on reload. +#[cfg(feature = "ssr")] +pub async fn site_asset_handler( + axum::extract::State(state): axum::extract::State, + axum::extract::Path(path): axum::extract::Path, +) -> axum::response::Response { + use axum::http::{header, StatusCode}; + use axum::response::IntoResponse; + + if !is_safe_site_path(&path) { + return (StatusCode::BAD_REQUEST, "invalid asset path").into_response(); + } + let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch); + let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) { + Ok(r) => r, + Err(e) => { + tracing::warn!("site asset {path}: {e}"); + return (StatusCode::NOT_FOUND, "no such site asset").into_response(); + } + }; + let bytes = match crate::http::bytes(resp, crate::http::ASSET_CAP).await { + Ok(b) => axum::body::Bytes::from(b), + Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + }; + let mime = match path.rsplit('.').next() { + Some("js") | Some("mjs") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("json") => "application/json", + Some("png") => "image/png", + Some("webp") => "image/webp", + Some("avif") => "image/avif", + Some("ico") => "image/x-icon", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + }; + ( + [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")], + bytes, + ) + .into_response() +} + +/// 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, + axum::extract::Query(query): axum::extract::Query, +) -> Result, (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 api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base); + let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP) + .await + .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {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, + axum::extract::Path(bucket): axum::extract::Path, + headers: axum::http::HeaderMap, +) -> Result, (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 = 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::(&bytes) { + items.push(value); + } + } + } + Ok(axum::Json(serde_json::Value::Array(items))) +} + diff --git a/src/content/markdown.rs b/src/content/markdown.rs new file mode 100644 index 0000000..9738c55 --- /dev/null +++ b/src/content/markdown.rs @@ -0,0 +1,120 @@ +//! Inline markdown for descriptions: links on a short allow-list of +//! schemes, images gated and framed, with layout hints read from the +//! image title. Never raw HTML. + +/// Descriptions are inline markdown: `[text](https://…)` links, +/// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is +/// flattened (a description is one paragraph) and raw HTML in the +/// source is dropped, so content can link out without being able to +/// inject markup. Link targets are limited to https, mailto and +/// site-relative paths; anything else renders as plain text. +pub fn render_inline_markdown(text: &str) -> String { + use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd}; + let parser = Parser::new_ext(text, Options::empty()); + let mut in_link = 0usize; + let mut in_dropped_image = 0usize; + let filtered = parser.filter_map(|event| match event { + Event::Html(_) | Event::InlineHtml(_) => None, + Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None, + Event::SoftBreak => Some(Event::Text(" ".into())), + Event::Start(Tag::Link { dest_url, .. }) + if !(dest_url.starts_with("https://") + || dest_url.starts_with("mailto:") + || dest_url.starts_with("tel:") + || dest_url.starts_with('/')) => + { + in_link += 1; + None + } + Event::End(TagEnd::Link) if in_link > 0 => { + in_link -= 1; + None + } + // Images take the same gate as links: https or same-origin + // only - no data:, no plain http. Unlike a dropped link (whose + // label is real text worth keeping), a dropped image's alt + // text is a caption for something that isn't there - swallow it. + Event::Start(Tag::Image { dest_url, .. }) + if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) => + { + in_dropped_image += 1; + None + } + Event::End(TagEnd::Image) if in_dropped_image > 0 => { + in_dropped_image -= 1; + None + } + Event::Text(_) if in_dropped_image > 0 => None, + other => Some(other), + }); + let mut out = String::new(); + html::push_html(&mut out, filtered); + apply_image_hints(out.trim()) +} + +/// Translate a markdown image's title (`![alt](url "right 9rem")`) +/// into layout: `left`/`right` float via a class, a bare number +/// (optionally with `rem`) into a validated `max-width`. Unknown +/// tokens are ignored; the title attribute is dropped either way. +/// Only touches `` tags in our own render output, and only ever +/// emits a numeric max-width - no arbitrary CSS reaches the page. +pub(crate) fn apply_image_hints(html: &str) -> String { + let mut out = String::new(); + let mut rest = html; + while let Some(pos) = rest.find("').map(|e| e + 1).unwrap_or(after.len()); + out.push_str(&rewrite_img_tag(&after[..end])); + rest = &after[end..]; + } + out.push_str(rest); + out +} + +pub(crate) fn rewrite_img_tag(tag: &str) -> String { + // Pull the title value, if any. + let title = tag + .find("title=\"") + .map(|i| &tag[i + 7..]) + .and_then(|r| r.find('"').map(|e| &r[..e])) + .unwrap_or(""); + + let mut class = String::new(); + let mut max_rem: Option = None; + for tok in title.split_whitespace() { + match tok { + "left" => class = "md-float-left".into(), + "right" => class = "md-float-right".into(), + other => { + if let Ok(n) = other.trim_end_matches("rem").parse::() { + if n > 0.0 && n <= 60.0 { + max_rem = Some(n); + } + } + } + } + } + + // Strip the title attribute from the tag. + let mut cleaned = tag.to_string(); + if let Some(i) = cleaned.find(" title=\"") { + if let Some(e) = cleaned[i + 8..].find('"') { + cleaned.replace_range(i..i + 8 + e + 1, ""); + } + } + + // Inject class/style right after ` Option String { - use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd}; - let parser = Parser::new_ext(text, Options::empty()); - let mut in_link = 0usize; - let mut in_dropped_image = 0usize; - let filtered = parser.filter_map(|event| match event { - Event::Html(_) | Event::InlineHtml(_) => None, - Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None, - Event::SoftBreak => Some(Event::Text(" ".into())), - Event::Start(Tag::Link { dest_url, .. }) - if !(dest_url.starts_with("https://") - || dest_url.starts_with("mailto:") - || dest_url.starts_with("tel:") - || dest_url.starts_with('/')) => - { - in_link += 1; - None - } - Event::End(TagEnd::Link) if in_link > 0 => { - in_link -= 1; - None - } - // Images take the same gate as links: https or same-origin - // only - no data:, no plain http. Unlike a dropped link (whose - // label is real text worth keeping), a dropped image's alt - // text is a caption for something that isn't there - swallow it. - Event::Start(Tag::Image { dest_url, .. }) - if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) => - { - in_dropped_image += 1; - None - } - Event::End(TagEnd::Image) if in_dropped_image > 0 => { - in_dropped_image -= 1; - None - } - Event::Text(_) if in_dropped_image > 0 => None, - other => Some(other), - }); - let mut out = String::new(); - html::push_html(&mut out, filtered); - apply_image_hints(out.trim()) -} - -/// Translate a markdown image's title (`![alt](url "right 9rem")`) -/// into layout: `left`/`right` float via a class, a bare number -/// (optionally with `rem`) into a validated `max-width`. Unknown -/// tokens are ignored; the title attribute is dropped either way. -/// Only touches `` tags in our own render output, and only ever -/// emits a numeric max-width - no arbitrary CSS reaches the page. -fn apply_image_hints(html: &str) -> String { - let mut out = String::new(); - let mut rest = html; - while let Some(pos) = rest.find("').map(|e| e + 1).unwrap_or(after.len()); - out.push_str(&rewrite_img_tag(&after[..end])); - rest = &after[end..]; - } - out.push_str(rest); - out -} - -fn rewrite_img_tag(tag: &str) -> String { - // Pull the title value, if any. - let title = tag - .find("title=\"") - .map(|i| &tag[i + 7..]) - .and_then(|r| r.find('"').map(|e| &r[..e])) - .unwrap_or(""); - - let mut class = String::new(); - let mut max_rem: Option = None; - for tok in title.split_whitespace() { - match tok { - "left" => class = "md-float-left".into(), - "right" => class = "md-float-right".into(), - other => { - if let Ok(n) = other.trim_end_matches("rem").parse::() { - if n > 0.0 && n <= 60.0 { - max_rem = Some(n); - } - } - } - } - } - - // Strip the title attribute from the tag. - let mut cleaned = tag.to_string(); - if let Some(i) = cleaned.find(" title=\"") { - if let Some(e) = cleaned[i + 8..].find('"') { - cleaned.replace_range(i..i + 8 + e + 1, ""); - } - } - - // Inject class/style right after `` can only ever reach the content repo. -pub fn is_safe_site_path(path: &str) -> bool { - !path.is_empty() - && !path.starts_with('/') - && path.split('/').all(|seg| { - !seg.is_empty() - && seg != ".." - && seg - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') - }) -} - -/// A relay must be a ws:// or wss:// URL - shared between site.yaml's -/// hero and `Requirement.relay` validation. -#[cfg(feature = "ssr")] -pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> { - let parsed = url::Url::parse(relay) - .map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?; - if !matches!(parsed.scheme(), "ws" | "wss") { - anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme"); - } - Ok(()) -} - -/// Fetches `needs.yaml` from the content repo root - the business's -/// needs spec `question_lint` checks the pages against (see -/// `crate::needs`). Optional: a fetch failure means the repo declares -/// none. The runtime never reads it. -#[cfg(feature = "ssr")] -pub async fn load_needs_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result> { - let (owner, repo) = parse_owner_repo(repo_url)?; - let api_base = gitea_api_base(repo_url)?; - let client = openidconnect::reqwest::Client::new(); - Ok(fetch_gitea_file(&client, &api_base, &owner, &repo, branch, "needs.yaml") - .await - .ok()) -} - -/// Fetches and parses `site.yaml` from the content repo root. A fetch -/// failure (typically 404 - the file is optional) yields the default -/// config; a file that exists but doesn't parse or validate is a real -/// error, surfaced at boot rather than papered over. -#[cfg(feature = "ssr")] -pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result { - let (owner, repo) = parse_owner_repo(repo_url)?; - let api_base = gitea_api_base(repo_url)?; - let client = openidconnect::reqwest::Client::new(); - let raw = match fetch_gitea_file(&client, &api_base, &owner, &repo, branch, "site.yaml").await { - Ok(raw) => raw, - Err(e) => { - tracing::info!("no site.yaml in content repo ({e}), using default branding"); - return Ok(SiteConfig::default()); - } - }; - let site: SiteConfig = - serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?; - site.validate()?; - Ok(site) -} - -/// Extracts `scheme://host` from a repo's normal browser URL - the -/// Gitea API base every helper in this module builds requests against. -#[cfg(feature = "ssr")] -/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base -/// `site_asset_handler` proxies content-shipped files from. -#[cfg(feature = "ssr")] -pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result { - let (owner, repo) = parse_owner_repo(repo_url)?; - Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?)) -} - -#[cfg(feature = "ssr")] -pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { - 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}"))? - )) -} - -/// Splits a repo's normal browser URL into `(owner, repo)` - shared by -/// every loader in this module that needs to build a Gitea contents API -/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`). -#[cfg(feature = "ssr")] -fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> { - let parsed = url::Url::parse(repo_url) - .map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?; - let mut segments = parsed - .path_segments() - .ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?; - let owner = segments - .next() - .filter(|s| !s.is_empty()) - .ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))? - .to_string(); - let repo = segments - .next() - .filter(|s| !s.is_empty()) - .ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))? - .to_string(); - Ok((owner, repo)) -} - -/// Fetches one file from a Gitea repo's contents API and returns its raw -/// text - the single-file counterpart to `load_questions_from_gitea`'s -/// directory-listing loop, used by `load_aggregates_from_gitea` for the -/// one `aggregates.yaml` file at the repo root. -#[cfg(feature = "ssr")] -async fn fetch_gitea_file( - client: &openidconnect::reqwest::Client, - api_base: &str, - owner: &str, - repo: &str, - branch: &str, - path: &str, -) -> anyhow::Result { - let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}"); - let meta_text = client - .get(&meta_url) - .send() - .await - .map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))? - .error_for_status() - .map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))? - .text() - .await - .map_err(|e| anyhow::anyhow!("reading contents response from {meta_url}: {e}"))?; - let meta: serde_json::Value = serde_json::from_str(&meta_text) - .map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?; - let download_url = meta - .get("download_url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?; - client - .get(download_url) - .send() - .await - .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? - .error_for_status() - .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? - .text() - .await - .map_err(|e| anyhow::anyhow!("reading {path}: {e}")) -} - -/// Fetches and parses `aggregates.yaml` from the repo root (sibling to -/// the pages `subdir`, so the "every yaml under subdir is a page" -/// convention needs no exclusion). A malformed file fails here, before -/// ever reaching `AppState`. -#[cfg(feature = "ssr")] -pub async fn load_aggregates_from_gitea( - repo_url: &str, - branch: &str, -) -> anyhow::Result> { - let (owner, repo) = parse_owner_repo(repo_url)?; - let api_base = gitea_api_base(repo_url)?; - let client = openidconnect::reqwest::Client::new(); - let raw = fetch_gitea_file(&client, &api_base, &owner, &repo, branch, "aggregates.yaml").await?; - crate::aggregates::parse_aggregates_yaml(&raw) -} - /// 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 @@ -1168,599 +907,6 @@ pub fn build_questions( Ok(out) } -#[cfg(feature = "ssr")] -pub async fn load_questions_from_gitea( - repo_url: &str, - branch: &str, - subdir: &str, -) -> anyhow::Result> { - let (owner, repo) = parse_owner_repo(repo_url)?; - let api_base = gitea_api_base(repo_url)?; - - let client = openidconnect::reqwest::Client::new(); - // One recursive git-trees call for the whole repo instead of a - // contents listing per directory - the tree IS the router now, so - // nested files matter. - let tree_url = - format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"); - let listing = client - .get(&tree_url) - .send() - .await - .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))? - .error_for_status() - .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))? - .text() - .await - .map_err(|e| anyhow::anyhow!("reading tree from {tree_url}: {e}"))?; - let tree: serde_json::Value = serde_json::from_str(&listing) - .map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?; - if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) { - anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large"); - } - let prefix = format!("{subdir}/"); - - let mut files = Vec::new(); - for entry in tree - .get("tree") - .and_then(|v| v.as_array()) - .map(|v| v.as_slice()) - .unwrap_or_default() - { - let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or(""); - if entry.get("type").and_then(|v| v.as_str()) != Some("blob") - || !path.starts_with(&prefix) - || !path.ends_with(".yaml") - { - continue; - } - // Brackets (dynamic pages like `[record].yaml`) must be - // percent-encoded in the raw URL's path. - let encoded = path.replace('[', "%5B").replace(']', "%5D"); - let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}"); - let raw = client - .get(&raw_url) - .send() - .await - .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? - .error_for_status() - .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? - .text() - .await - .map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; - files.push((path[prefix.len()..].to_string(), raw)); - } - build_questions(&files) -} - -/// Validates every declared transition target (`SelfTransition.to`, -/// `ResourceSpec.transitions[].to`) against `aggregates` - the real -/// state graph loaded from `aggregates.yaml` - for every bucket that -/// has one declared. A bucket with no entry in `aggregates` is left -/// alone entirely (no validation applied): not every bucket needs to -/// be event-sourced to keep working. Called on every content -/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the -/// standalone `question-lint` binary, so a YAML typo becomes a caught, -/// logged rejection instead of a silently-accepted, later-broken -/// string. -/// Bucket the runtime keeps for announced pages (`Question.event`): -/// one record per event question, `announced` while the window is -/// open, `awaiting_summary` once it closes - the "post what happened" -/// task a desk reading this bucket picks up - and `summarized` when an -/// owner marks it done. Declared here, not in content, because the -/// runtime's sweeper (`announce.rs`) is what moves the records. -pub const EVENTS_BUCKET: &str = "portal_events"; - -#[cfg(feature = "ssr")] -const BUILTIN_AGGREGATES_YAML: &str = "\ -aggregates: - - bucket: portal_events - initial: announced - states: - announced: { event: announced } - awaiting_summary: { event: ended } - summarized: { event: summarized } - transitions: - announced: [awaiting_summary, summarized] - awaiting_summary: [summarized] -"; - -/// Adds the runtime's own state graphs to a content repo's. Content -/// wins if it declares the same bucket (say, to add states). -#[cfg(feature = "ssr")] -pub fn with_builtin_aggregates( - mut aggregates: std::collections::HashMap, -) -> std::collections::HashMap { - let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML) - .expect("builtin aggregates yaml is valid"); - for (bucket, schema) in builtin { - aggregates.entry(bucket).or_insert(schema); - } - aggregates -} - -/// True when some kv resource in the content reads `bucket` - the -/// attended-bucket check, exposed so the lint can warn about the -/// runtime's own `portal_events` (a warning, not a failure: a repo -/// with no event pages has nothing to attend). -#[cfg(feature = "ssr")] -pub fn bucket_is_read(questions: &std::collections::HashMap, bucket: &str) -> bool { - questions.values().any(|q| { - q.alternatives.iter().any(|a| { - a.features - .iter() - .any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket)) - }) - }) -} - -#[cfg(feature = "ssr")] -pub fn validate_questions( - questions: &std::collections::HashMap, - aggregates: &std::collections::HashMap, -) -> anyhow::Result<()> { - for question in questions.values() { - if let Some(event) = &question.event { - if question.is_dynamic() { - anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id); - } - if let Err(e) = event.window_ms() { - anyhow::bail!("question {:?}: event: {e}", question.id); - } - } - if let Some(target) = &question.requires_chain { - if !questions.contains_key(target) { - anyhow::bail!( - "question {:?}: requires_chain {:?} does not match any declared question id", - question.id, - target - ); - } - } - for alternative in &question.alternatives { - // A dangling action is a literal dead end: the submit - // button navigates to "Nothing here". - if let Some(action) = &alternative.action { - if action.contains('{') { - // Placeholders are filled at submit time; the - // shape must land on a dynamic page once filled. - let probe = template_action(action, &serde_json::json!({})); - let filled = { - let mut a = action.clone(); - while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) { - a.replace_range(i..=j, "x"); - } - a - }; - let _ = probe; - // Match the pattern itself: resolve_question would - // hand back a clone whose id is already concrete. - let lands = questions - .values() - .any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some()); - if lands { - continue; - } - anyhow::bail!( - "question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)", - question.id, alternative.name, action - ); - } - match questions.get(action) { - None => anyhow::bail!( - "question {:?} alternative {:?}: action {:?} does not match any declared question id", - question.id, alternative.name, action - ), - // A dynamic page needs a concrete segment value to - // be a URL - a submit button can't supply one. - Some(target) if target.is_dynamic() => anyhow::bail!( - "question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question", - question.id, alternative.name, action - ), - Some(_) => {} - } - } - if let Some(st) = &alternative.self_transition { - if let Some(schema) = aggregates.get(&st.bucket) { - if !schema.has_state(&st.to) { - anyhow::bail!( - "question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}", - question.id, alternative.name, st.to, st.bucket - ); - } - } - } - // Bind targets resolve across the whole alternative, same - // scope as the signal maps the renderer builds. - let sibling_names: std::collections::HashSet<&str> = alternative - .features - .iter() - .flat_map(|f| f.requirements.iter()) - .map(|r| r.name.as_str()) - .collect(); - for feature in &alternative.features { - for requirement in &feature.requirements { - if requirement.kind == "select" - && requirement.resource.is_none() - && requirement.options.is_empty() - { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options", - question.id, alternative.name, feature.name, requirement.name - ); - } - if let Some(relay) = &requirement.relay { - if requirement.kind != "gesture" && requirement.kind != "voice" { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice", - question.id, alternative.name, feature.name, requirement.name, requirement.kind - ); - } - validate_relay_url(relay).map_err(|e| anyhow::anyhow!( - "question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}", - question.id, alternative.name, feature.name, requirement.name - ))?; - } - if requirement.kind == "gesture" - && (requirement.resource.is_some() || requirement.bind.is_some()) - { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind", - question.id, alternative.name, feature.name, requirement.name - ); - } - if let Some(bind) = &requirement.bind { - if bind.field == requirement.name - || !sibling_names.contains(bind.field.as_str()) - { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative", - question.id, alternative.name, feature.name, requirement.name, bind.field - ); - } - } - } - let Some(resource) = &feature.resource else { - continue; - }; - let Some(bucket) = resource.bucket() else { - continue; - }; - let Some(schema) = aggregates.get(bucket) else { - continue; - }; - for transition in &resource.transitions { - if !schema.has_state(&transition.from) { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}", - question.id, alternative.name, feature.name, transition.from, bucket - ); - } - if !schema.has_state(&transition.to) { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}", - question.id, alternative.name, feature.name, transition.to, bucket - ); - } - if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) { - anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}", - question.id, alternative.name, feature.name, transition.from, transition.to, bucket - ); - } - } - } - } - } - - // Attended buckets: no publicly collected answer may land somewhere - // nothing reads. Every `record_as` bucket must either be read back - // by some Kv resource in this same content repo (a desk or listing) - // or carry an explicit `attended_by:` annotation in aggregates.yaml - // naming the automation that consumes it. This is a contract, not a - // convention, because convention already failed once: a question - // was dropped and its bucket - answers included - silently fell out - // of every reader's view. - let mut read_buckets = std::collections::HashSet::new(); - let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet| { - if let ResourceSource::Kv { bucket } = &spec.source { - set.insert(bucket.clone()); - } - }; - for question in questions.values() { - for alternative in &question.alternatives { - for feature in &alternative.features { - if let Some(spec) = &feature.resource { - note_resource(spec, &mut read_buckets); - } - for requirement in &feature.requirements { - if let Some(spec) = &requirement.resource { - note_resource(spec, &mut read_buckets); - } - if let Some(bind) = &requirement.bind { - note_resource(&bind.resource, &mut read_buckets); - } - } - } - } - } - for question in questions.values() { - for alternative in &question.alternatives { - if let Some(bucket) = &alternative.record_as { - let attended = read_buckets.contains(bucket) - || aggregates - .get(bucket) - .is_some_and(|schema| schema.attended_by.is_some()); - if !attended { - anyhow::bail!( - "question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.", - question.id, alternative.name - ); - } - } - } - } - Ok(()) -} - -/// Published by the content repo's own CI (after it lints a push) to -/// tell every running instance to pick up the change - a plain fire -/// and forget NATS publish, no payload, matching `events.rs`'s -/// `ANSWERS_SUBJECT` pattern. -#[cfg(feature = "ssr")] -pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload"; - -/// Runs for the life of the process: re-fetches `repo_url`/`branch` and -/// atomically swaps both `questions` and `aggregates` on every -/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together, -/// after both have loaded and validated successfully, so a reader never -/// sees pages that reference a half-updated state graph. A fetch/parse -/// failure logs and keeps serving the last-good content rather than -/// clearing it - a bad push to the content repo (which should already -/// have been caught by its own lint step) doesn't take the site down. -#[cfg(feature = "ssr")] -pub async fn watch_for_reload( - nats: async_nats::Client, - repo_url: String, - branch: String, - subdir: String, - questions: std::sync::Arc>>, - aggregates: std::sync::Arc< - arc_swap::ArcSwap>, - >, - site: std::sync::Arc>, -) { - let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await { - Ok(sub) => sub, - Err(e) => { - tracing::error!(error = %e, "failed to subscribe to content reload subject"); - return; - } - }; - use futures::StreamExt; - while sub.next().await.is_some() { - let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await { - Ok(loaded) => with_builtin_aggregates(loaded), - Err(e) => { - tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content"); - continue; - } - }; - // Branding swaps with the same all-or-nothing rule: a - // present-but-broken site.yaml keeps last-good everything - // (load_site distinguishes "absent" - a normal default - from - // "present but invalid"). - let loaded_site = match load_site_from_gitea(&repo_url, &branch).await { - Ok(loaded) => loaded, - Err(e) => { - tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content"); - continue; - } - }; - match load_questions_from_gitea(&repo_url, &branch, &subdir).await { - Ok(loaded) => { - if let Err(e) = validate_questions(&loaded, &loaded_aggregates) { - tracing::error!(error = %e, "content reload failed validation, keeping last-good content"); - continue; - } - let count = loaded.len(); - questions.store(std::sync::Arc::new(loaded)); - aggregates.store(std::sync::Arc::new(loaded_aggregates)); - site.store(std::sync::Arc::new(loaded_site)); - tracing::info!(count, "reloaded content"); - } - Err(e) => { - tracing::error!(error = %e, "content reload failed, keeping last-good content"); - } - } - } -} - -/// 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, -} - -/// `GET /site/{*path}` - serves a file from the content repo's default -/// branch same-origin, so content-shipped assets (the hero module, -/// its stylesheet) are importable by the page: Gitea's raw endpoint -/// sends no CORS headers, and an ES module import across origins is -/// refused without them. Path is validated to plain segments -/// (`is_safe_site_path`), the response revalidates like /pkg -/// (Cache-Control: no-cache) so a content push shows up on reload. -#[cfg(feature = "ssr")] -pub async fn site_asset_handler( - axum::extract::State(state): axum::extract::State, - axum::extract::Path(path): axum::extract::Path, -) -> axum::response::Response { - use axum::http::{header, StatusCode}; - use axum::response::IntoResponse; - - if !is_safe_site_path(&path) { - return (StatusCode::BAD_REQUEST, "invalid asset path").into_response(); - } - let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch); - let client = openidconnect::reqwest::Client::new(); - let resp = match client.get(&url).send().await.and_then(|r| r.error_for_status()) { - Ok(r) => r, - Err(e) => { - tracing::warn!("site asset {path}: {e}"); - return (StatusCode::NOT_FOUND, "no such site asset").into_response(); - } - }; - let bytes = match resp.bytes().await { - Ok(b) => b, - Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(), - }; - let mime = match path.rsplit('.').next() { - Some("js") | Some("mjs") => "text/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("svg") => "image/svg+xml", - Some("json") => "application/json", - Some("png") => "image/png", - Some("webp") => "image/webp", - Some("avif") => "image/avif", - Some("ico") => "image/x-icon", - Some("woff2") => "font/woff2", - _ => "application/octet-stream", - }; - ( - [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")], - bytes, - ) - .into_response() -} - -/// 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, - axum::extract::Query(query): axum::extract::Query, -) -> Result, (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, - axum::extract::Path(bucket): axum::extract::Path, - headers: axum::http::HeaderMap, -) -> Result, (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 = 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::(&bytes) { - items.push(value); - } - } - } - Ok(axum::Json(serde_json::Value::Array(items))) -} - #[cfg(all(test, feature = "ssr"))] mod tests { use super::*; diff --git a/src/content/validate.rs b/src/content/validate.rs new file mode 100644 index 0000000..c1ab78c --- /dev/null +++ b/src/content/validate.rs @@ -0,0 +1,290 @@ +//! What a content repo must satisfy before it serves: every action +//! lands on a page, every transition is one the state machine allows, +//! every recorded bucket is read back somewhere, and the built-in +//! aggregates the runtime owns are always present. + +#[allow(unused_imports)] // the browser build sees fewer items +use super::*; + +/// A relay must be a ws:// or wss:// URL - shared between site.yaml's +/// hero and `Requirement.relay` validation. +#[cfg(feature = "ssr")] +pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> { + let parsed = url::Url::parse(relay) + .map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?; + if !matches!(parsed.scheme(), "ws" | "wss") { + anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme"); + } + Ok(()) +} + +/// Validates every declared transition target (`SelfTransition.to`, +/// `ResourceSpec.transitions[].to`) against `aggregates` - the real +/// state graph loaded from `aggregates.yaml` - for every bucket that +/// has one declared. A bucket with no entry in `aggregates` is left +/// alone entirely (no validation applied): not every bucket needs to +/// be event-sourced to keep working. Called on every content +/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the +/// standalone `question-lint` binary, so a YAML typo becomes a caught, +/// logged rejection instead of a silently-accepted, later-broken +/// string. +/// Bucket the runtime keeps for announced pages (`Question.event`): +/// one record per event question, `announced` while the window is +/// open, `awaiting_summary` once it closes - the "post what happened" +/// task a desk reading this bucket picks up - and `summarized` when an +/// owner marks it done. Declared here, not in content, because the +/// runtime's sweeper (`announce.rs`) is what moves the records. +pub const EVENTS_BUCKET: &str = "portal_events"; + +#[cfg(feature = "ssr")] +const BUILTIN_AGGREGATES_YAML: &str = "\ +aggregates: + - bucket: portal_events + initial: announced + states: + announced: { event: announced } + awaiting_summary: { event: ended } + summarized: { event: summarized } + transitions: + announced: [awaiting_summary, summarized] + awaiting_summary: [summarized] +"; + +/// Adds the runtime's own state graphs to a content repo's. Content +/// wins if it declares the same bucket (say, to add states). +#[cfg(feature = "ssr")] +pub fn with_builtin_aggregates( + mut aggregates: std::collections::HashMap, +) -> std::collections::HashMap { + let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML) + .expect("builtin aggregates yaml is valid"); + for (bucket, schema) in builtin { + aggregates.entry(bucket).or_insert(schema); + } + aggregates +} + +/// True when some kv resource in the content reads `bucket` - the +/// attended-bucket check, exposed so the lint can warn about the +/// runtime's own `portal_events` (a warning, not a failure: a repo +/// with no event pages has nothing to attend). +#[cfg(feature = "ssr")] +pub fn bucket_is_read(questions: &std::collections::HashMap, bucket: &str) -> bool { + questions.values().any(|q| { + q.alternatives.iter().any(|a| { + a.features + .iter() + .any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket)) + }) + }) +} + +#[cfg(feature = "ssr")] +pub fn validate_questions( + questions: &std::collections::HashMap, + aggregates: &std::collections::HashMap, +) -> anyhow::Result<()> { + for question in questions.values() { + if let Some(event) = &question.event { + if question.is_dynamic() { + anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id); + } + if let Err(e) = event.window_ms() { + anyhow::bail!("question {:?}: event: {e}", question.id); + } + } + if let Some(target) = &question.requires_chain { + if !questions.contains_key(target) { + anyhow::bail!( + "question {:?}: requires_chain {:?} does not match any declared question id", + question.id, + target + ); + } + } + for alternative in &question.alternatives { + // A dangling action is a literal dead end: the submit + // button navigates to "Nothing here". + if let Some(action) = &alternative.action { + if action.contains('{') { + // Placeholders are filled at submit time; the + // shape must land on a dynamic page once filled. + let probe = template_action(action, &serde_json::json!({})); + let filled = { + let mut a = action.clone(); + while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) { + a.replace_range(i..=j, "x"); + } + a + }; + let _ = probe; + // Match the pattern itself: resolve_question would + // hand back a clone whose id is already concrete. + let lands = questions + .values() + .any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some()); + if lands { + continue; + } + anyhow::bail!( + "question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)", + question.id, alternative.name, action + ); + } + match questions.get(action) { + None => anyhow::bail!( + "question {:?} alternative {:?}: action {:?} does not match any declared question id", + question.id, alternative.name, action + ), + // A dynamic page needs a concrete segment value to + // be a URL - a submit button can't supply one. + Some(target) if target.is_dynamic() => anyhow::bail!( + "question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question", + question.id, alternative.name, action + ), + Some(_) => {} + } + } + if let Some(st) = &alternative.self_transition { + if let Some(schema) = aggregates.get(&st.bucket) { + if !schema.has_state(&st.to) { + anyhow::bail!( + "question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}", + question.id, alternative.name, st.to, st.bucket + ); + } + } + } + // Bind targets resolve across the whole alternative, same + // scope as the signal maps the renderer builds. + let sibling_names: std::collections::HashSet<&str> = alternative + .features + .iter() + .flat_map(|f| f.requirements.iter()) + .map(|r| r.name.as_str()) + .collect(); + for feature in &alternative.features { + for requirement in &feature.requirements { + if requirement.kind == "select" + && requirement.resource.is_none() + && requirement.options.is_empty() + { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options", + question.id, alternative.name, feature.name, requirement.name + ); + } + if let Some(relay) = &requirement.relay { + if requirement.kind != "gesture" && requirement.kind != "voice" { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice", + question.id, alternative.name, feature.name, requirement.name, requirement.kind + ); + } + validate_relay_url(relay).map_err(|e| anyhow::anyhow!( + "question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}", + question.id, alternative.name, feature.name, requirement.name + ))?; + } + if requirement.kind == "gesture" + && (requirement.resource.is_some() || requirement.bind.is_some()) + { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind", + question.id, alternative.name, feature.name, requirement.name + ); + } + if let Some(bind) = &requirement.bind { + if bind.field == requirement.name + || !sibling_names.contains(bind.field.as_str()) + { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative", + question.id, alternative.name, feature.name, requirement.name, bind.field + ); + } + } + } + let Some(resource) = &feature.resource else { + continue; + }; + let Some(bucket) = resource.bucket() else { + continue; + }; + let Some(schema) = aggregates.get(bucket) else { + continue; + }; + for transition in &resource.transitions { + if !schema.has_state(&transition.from) { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}", + question.id, alternative.name, feature.name, transition.from, bucket + ); + } + if !schema.has_state(&transition.to) { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}", + question.id, alternative.name, feature.name, transition.to, bucket + ); + } + if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}", + question.id, alternative.name, feature.name, transition.from, transition.to, bucket + ); + } + } + } + } + } + + // Attended buckets: no publicly collected answer may land somewhere + // nothing reads. Every `record_as` bucket must either be read back + // by some Kv resource in this same content repo (a desk or listing) + // or carry an explicit `attended_by:` annotation in aggregates.yaml + // naming the automation that consumes it. This is a contract, not a + // convention, because convention already failed once: a question + // was dropped and its bucket - answers included - silently fell out + // of every reader's view. + let mut read_buckets = std::collections::HashSet::new(); + let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet| { + if let ResourceSource::Kv { bucket } = &spec.source { + set.insert(bucket.clone()); + } + }; + for question in questions.values() { + for alternative in &question.alternatives { + for feature in &alternative.features { + if let Some(spec) = &feature.resource { + note_resource(spec, &mut read_buckets); + } + for requirement in &feature.requirements { + if let Some(spec) = &requirement.resource { + note_resource(spec, &mut read_buckets); + } + if let Some(bind) = &requirement.bind { + note_resource(&bind.resource, &mut read_buckets); + } + } + } + } + } + for question in questions.values() { + for alternative in &question.alternatives { + if let Some(bucket) = &alternative.record_as { + let attended = read_buckets.contains(bucket) + || aggregates + .get(bucket) + .is_some_and(|schema| schema.attended_by.is_some()); + if !attended { + anyhow::bail!( + "question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.", + question.id, alternative.name + ); + } + } + } + } + Ok(()) +} +