use serde::{Deserialize, Serialize}; /// One page: a prompt plus the paths on from it. The `questions/` /// directory tree IS the URL tree (`index.yaml` names its directory, /// `develop/proposal.yaml` serves `/develop/proposal`), so `id` is /// derived from the file's path - declaring it explicitly still works /// (and wins, with a logged warning when it disagrees) but is only /// needed by legacy content. Loaded from plain YAML files in a content /// directory kept in its own git repo - editing content is a /// content-repo commit, not a Rust rebuild. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Question { /// The URL path this page is served at. Derived from the file path /// when absent. A `[name]` segment (from a `[name].yaml` file) /// makes this a dynamic page: `/review/[record]` serves any /// `/review/`, with the value substituted into `{record}` /// placeholders in the page's resource keys (see /// `resolve_question`). #[serde(default)] pub id: String, pub name: String, #[serde(default)] pub description: String, /// Kanidm group required to view/submit this question - `None` means /// open to anyone. Content-driven on purpose: a gated page like /// "/review" is just a Question with this set, not a bespoke Rust /// route. Inherited from the nearest ancestor `_section.yaml` when /// not set on the question itself. #[serde(default)] pub qualifies: Option, /// Question id (relative refs resolve against this file's /// directory) the visitor must have answered - their `?chain=` /// lineage's tip - to see this page. The provenance counterpart to /// `qualifies`' identity check. Inherited from `_section.yaml` /// like `qualifies`. #[serde(default)] pub requires_chain: Option, #[serde(default)] pub alternatives: Vec, /// Who to contact if a visitor gets stuck - rendered as a small line /// on the page. Inherited from `_section.yaml` when not set. #[serde(default)] pub responsible: Option, /// A page that only makes sense after answering something (the /// post-submission pages) - kept out of the question nav unless the /// visitor's context carries an answer chain. Unset means inferred /// from the file's place in the tree: files nested in a /// subdirectory are followups unless they're the directory's /// `index.yaml`; top-level files keep the historical default /// (not a followup). #[serde(default)] pub followup: Option, /// This page announces something with a time window. While the /// window is open the page is announced in the header (see /// `app::Announcements`) instead of listed in the footer nav; once /// it closes the page behaves like a `followup` (visible only to /// visitors carrying an answer chain) and a "post what happened" /// task lands in the `portal_events` desk (see `announce.rs`). #[serde(default)] pub event: Option, } /// A page's announcement window. `starts` is RFC 3339 with an offset /// (`2026-09-12T18:00:00+02:00`); `duration` is a plain span like /// `3h`, `90m`, `2d`, `1d 6h`. Optional `place` is shown verbatim. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct EventConfig { pub starts: String, pub duration: String, #[serde(default)] pub place: Option, } /// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only; /// the smallest is a minute. pub fn parse_duration_ms(text: &str) -> Result { let mut total: i64 = 0; let mut seen = false; for token in text.split_whitespace() { let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len()); let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?; let per = match unit { "m" => 60_000, "h" => 3_600_000, "d" => 86_400_000, _ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")), }; total += n * per; seen = true; } if !seen || total <= 0 { return Err(format!("bad duration {text:?}: must be positive")); } Ok(total) } #[cfg(feature = "ssr")] impl EventConfig { /// `(starts_ms, ends_ms)` as Unix milliseconds. pub fn window_ms(&self) -> Result<(i64, i64), String> { let starts = chrono::DateTime::parse_from_rfc3339(&self.starts) .map_err(|e| format!("bad starts {:?}: {e} (RFC 3339 with offset)", self.starts))? .timestamp_millis(); Ok((starts, starts + parse_duration_ms(&self.duration)?)) } } impl Question { pub fn is_followup(&self) -> bool { self.followup.unwrap_or(false) } /// The announcement window is still open at `now_ms` (Unix ms). #[cfg(feature = "ssr")] pub fn event_live_at(&self, now_ms: i64) -> bool { self.event .as_ref() .and_then(|e| e.window_ms().ok()) .is_some_and(|(_, ends)| now_ms < ends) } /// The announcement window has closed at `now_ms`. #[cfg(feature = "ssr")] pub fn event_ended_at(&self, now_ms: i64) -> bool { self.event .as_ref() .and_then(|e| e.window_ms().ok()) .is_some_and(|(_, ends)| now_ms >= ends) } /// A dynamic page - one whose id still contains a `[name]` /// segment. Served per-value via `resolve_question`, never listed /// in nav, never a valid `action` target. pub fn is_dynamic(&self) -> bool { self.id.contains('[') } } /// An alternative's `action` may carry `{field}` / `{field.sub}` /// placeholders filled from the submitted responses - `/shape/{curve.key}` /// lands on the dynamic page `/shape/[key]` for the key the visitor's /// stroke was given. Returns `None` when a placeholder has no value /// (or a non-scalar one) in `responses`. pub fn template_action(action: &str, responses: &serde_json::Value) -> Option { let mut out = String::new(); let mut rest = action; while let Some(start) = rest.find('{') { out.push_str(&rest[..start]); let end = rest[start..].find('}')? + start; let path = &rest[start + 1..end]; let mut value = responses; for seg in path.split('.') { value = value.get(seg)?; } let scalar = match value { serde_json::Value::String(s) => s.clone(), serde_json::Value::Number(n) => n.to_string(), serde_json::Value::Bool(b) => b.to_string(), _ => return None, }; if scalar.is_empty() { return None; } // A URL segment: keep it to what a segment can carry. let safe: String = scalar .chars() .map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' }) .collect(); out.push_str(&safe); rest = &rest[end + 1..]; } out.push_str(rest); Some(out) } /// 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 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('/')) => { in_link += 1; None } Event::End(TagEnd::Link) if in_link > 0 => { in_link -= 1; None } other => Some(other), }); let mut out = String::new(); html::push_html(&mut out, filtered); out.trim().to_string() } /// Directory-scoped defaults: a `_section.yaml` file applies to every /// question at or below its directory (nearest ancestor wins per /// field, and a question's own declaration always overrides). This is /// what makes a URL prefix a trust boundary - "everything under /// /review is owner-only" is one line in `review/_section.yaml` /// instead of a flag per file. Underscore-prefixed files that aren't /// `_section.yaml` are skipped entirely (drafts). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct SectionConfig { #[serde(default)] pub qualifies: Option, #[serde(default)] pub requires_chain: Option, #[serde(default)] pub responsible: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Responsible { pub name: String, /// A mailto address or plain email - deliberately just a string, /// same as everywhere else content keeps contact info simple. pub contact: String, } /// Whether `user` may view/submit `question`. `true` when the question /// has no `qualifies` requirement. Mirrors `chat::is_authorized_for_room` /// in cnats - same synchronous, I/O-free shape, same staleness tradeoff /// (group membership is fixed at login, not re-checked live). pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool { match &question.qualifies { None => true, Some(group) => user.is_some_and(|u| u.groups.iter().any(|g| g == group)), } } /// One path through a question: a short pitch, an optional next question /// to advance to on submit, and the form (via `features`) that collects /// what's needed to get there. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Alternative { pub name: String, #[serde(default)] pub description: String, #[serde(default)] pub action: Option, /// Shown but not takeable yet - the button renders disabled and the /// server refuses the submission. For advertising a path before it /// works (lysbue had the same flag). #[serde(default)] pub disabled: bool, #[serde(default)] pub consequence: Vec, #[serde(default)] pub encouragements: Vec, /// Banner image urls, rendered above the description - one renders /// as a plain image, several as a swipeable card deck (Swiper /// Element, vendored in `public/`). Purely decorative, no /// upload/hosting mechanism of their own, just already-hosted urls /// the browser fetches directly (unlike `ResourceSource::Url`, /// never fetched server-side, so none of that variant's SSRF /// concern). #[serde(default)] pub images: Vec, #[serde(default)] pub features: Vec, /// NATS KV bucket to durably store this submission into - just a /// bucket name, never a keyword the runtime special-cases. #[serde(default)] pub record_as: Option, /// A transition fireable by anyone holding one specific item's own /// reference (`?chain=` link) plus a matching `email` - the /// anonymous, single-item counterpart to `ResourceSpec.transitions`' /// group-gated bucket browsing. The email is a second factor /// checked against the stored item, not the lookup key. #[serde(default)] pub self_transition: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SelfTransition { pub bucket: String, pub to: String, pub label: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Feature { pub name: String, #[serde(default)] pub description: String, /// Any valid CSS color - set as the feature's `--feature-accent` /// custom property (never interpolated into a stylesheet, so a bad /// value fails to apply instead of injecting CSS). Unset means no /// accent border at all. #[serde(default)] pub color: Option, /// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`), /// rendered via Iconify's public SVG API - no icon library bundled. #[serde(default)] pub icon: Option, #[serde(default)] pub requirements: Vec, /// Live data this feature pulls in. Read-only unless `transitions` /// is non-empty, in which case listed answers get one action button /// per transition (see `src/resource.rs`, `src/answers.rs`). #[serde(default)] pub resource: Option, } /// A live-data read declared in content. The bucket/key are only ever /// resolved server-side from trusted content - a client names a /// question + feature, never a bucket directly, so a visitor can't /// probe arbitrary buckets. No render-mode tag: what a resource /// displays as follows from its data's shape. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ResourceSpec { pub source: ResourceSource, /// A single item; omit to list the whole bucket. Only meaningful /// for a `Kv` source. #[serde(default)] pub key: Option, /// Kanidm group required to read this resource. #[serde(default)] pub requires_group: Option, /// Must be explicitly set for an anonymous-readable resource - a /// spec with neither this nor `requires_group` is unreachable /// (fail closed). Reads only: mutations always require /// `requires_group` regardless of this flag. #[serde(default)] pub public: bool, /// The moves a listed answer may make, one button each - empty /// means read-only. Server calls are checked against this /// allow-list, so a client can never fire a transition content /// didn't declare. `Kv` sources only. #[serde(default)] pub transitions: Vec, /// A jq filter (evaluated via `jaq`, no shell-out) reshaping the /// fetched value before it reaches the frontend - e.g. /// `.[] | {name, url: .html_url}`. `None` returns it as-is. #[serde(default)] pub jq: Option, /// What to say when the resource yields nothing - an empty list, /// or `null` (a `.[] | ...` jq over an empty list produces no /// output at all). Defaults to "Nothing here yet."; content sets /// it where the silence needs a voice ("Nothing released yet"). #[serde(default)] pub empty: Option, } /// Where a resource's live data comes from. `Kv` (a NATS KV bucket /// this server owns) is the only mutable source; the rest are /// read-only live pulls. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ResourceSource { Kv { bucket: String }, GiteaStarred { username: String }, GiteaOrgRepos { org: String }, /// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works /// for private repos too (shape the link with jq: a private /// release's html_url 404s for anonymous visitors, so map it to /// null unless the repo is public). GiteaReleases { owner: String, repo: String }, /// Any other HTTPS JSON endpoint. Scheme-restricted and checked /// against loopback/private/link-local addresses at fetch time /// (`resource::fetch_url_resource`) - a server-side fetch of a /// content-supplied URL is SSRF surface, so it fails closed. Url { url: String }, } impl ResourceSpec { /// The KV bucket this resource reads/writes - `None` for a live /// external pull. pub fn bucket(&self) -> Option<&str> { match &self.source { ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::GiteaStarred { .. } | ResourceSource::GiteaOrgRepos { .. } | ResourceSource::GiteaReleases { .. } | ResourceSource::Url { .. } => None, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Transition { /// The state a row must be in for this transition's button to /// render and its server call to be accepted. Defaults to "open". #[serde(default = "default_transition_from")] pub from: String, pub to: String, pub label: String, } fn default_transition_from() -> String { crate::answers::OPEN_STATE.to_string() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Requirement { pub name: String, #[serde(default)] pub label: Option, #[serde(default)] pub placeholder: Option, #[serde(default = "default_requirement_type", rename = "type")] pub kind: String, #[serde(default)] pub optional: bool, /// `type: file` - accept multiple files. `type: select` - pick more /// than one option (checkbox-style toggle) instead of exactly one /// (radio-style); the submitted value is a JSON array of ids /// instead of a single id string. #[serde(default)] pub multiple: bool, /// `type: file` only - HTML `accept` hint (UX only, not a security /// boundary - the upload handler re-checks content-type itself). #[serde(default)] pub accept: Option, /// `type: select` only - where the options come from; the same /// `ResourceSpec` mechanism a `Feature.resource` uses. #[serde(default)] pub resource: Option, /// `type: select` only - which field in each item is the option's /// stable id. Defaults to trying `_id` then `id`. #[serde(default)] pub id_field: Option, /// Load this field's value from a resource whenever a sibling /// field changes - e.g. a file select populating a textarea with /// the selected file's current content. #[serde(default)] pub bind: Option, /// A preset value the field starts with. On a dynamic page the /// URL segment substitutes into `{name}` placeholders here, like /// resource keys - `value: "{key}"` on a hidden field carries the /// page's key into the answer. #[serde(default)] pub value: Option, /// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance /// the drawing widget connects to for live echoes of similar /// strokes. Absent means the widget works offline: the drawn path /// still submits, it just never gets a network-computed key or /// echoes. #[serde(default)] pub relay: Option, } /// A field's live data source, parameterized by a sibling field's /// value. When the sibling changes, `resource` is fetched with the /// value as a parameter and the result replaces this field's value /// (an empty sibling never fetches, and never clears an edit). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Bind { /// The sibling requirement (same alternative) to watch. pub field: String, /// The parameter name the sibling's value is sent as - `{name}` /// templates into a Url source's path, otherwise it's appended as /// a query pair. Defaults to `field`. #[serde(default)] pub param: Option, pub resource: ResourceSpec, } impl Bind { pub fn param_name(&self) -> &str { self.param.as_deref().unwrap_or(&self.field) } } fn default_requirement_type() -> String { "text".to_string() } impl Requirement { pub fn display_label(&self) -> String { self.label.clone().unwrap_or_else(|| self.name.clone()) } } /// Site-wide branding declared by the content repo - `site.yaml` at /// the repo root, sibling of `aggregates.yaml`. An absent file means /// all defaults, which is exactly the historical uhhm look: existing /// deployments change nothing without a content edit. Everything the /// browser needs, so it travels through a server fn (`get_site`). #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct SiteConfig { /// Browser-tab title and wordmark alt text. `None` falls back to /// the compile-time `SITE_NAME`. #[serde(default)] pub title: Option, /// Wordmark image URL (absolute or a path this instance serves). /// `None` falls back to `/wordmark.svg`. #[serde(default)] pub wordmark: Option, #[serde(default)] pub hero: HeroConfig, } /// What the landing page's hero is. `plain` is just the copy; `module` /// hands the hero to a JavaScript module the content repo itself /// ships (`module: hero.js`, a repo-relative path portal serves /// same-origin at `/site/`, since Gitea's raw endpoint sends no /// CORS headers). The module exports `mount(container) -> handle` and /// the handle has `stop()`; portal starts it at HTML parse time and /// adopts it on hydration. The piece's look - canvases, SVG, CSS - is /// entirely the module's: portal only reserves the box. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HeroConfig { #[serde(default = "default_hero_kind")] pub kind: String, #[serde(default)] pub module: Option, } impl Default for HeroConfig { fn default() -> Self { Self { kind: default_hero_kind(), module: None } } } fn default_hero_kind() -> String { "plain".to_string() } #[cfg(feature = "ssr")] impl SiteConfig { /// Same policy as `validate_questions`: content declares, the /// runtime refuses what it doesn't understand at load time, so a /// typo'd hero kind is a caught rejection, not a silently plain /// hero. pub fn validate(&self) -> anyhow::Result<()> { match self.hero.kind.as_str() { "plain" => { if self.hero.module.is_some() { anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module"); } } "module" => { let Some(module) = &self.hero.module else { anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)"); }; if !is_safe_site_path(module) { anyhow::bail!( "site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)" ); } } other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"), } Ok(()) } } /// 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 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 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 /// Gitea host, owner and repo name are all read from it. Called once at /// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see /// `watch_for_reload`), over Gitea's public contents API (no auth - the /// content repo is public). /// The URL a file at `rel` (path relative to the questions dir, forward /// slashes) serves: `index.yaml` names its directory, everything else /// appends its stem. pub fn route_from_path(rel: &str) -> String { let stem = rel.strip_suffix(".yaml").unwrap_or(rel); let mut segments: Vec<&str> = stem.split('/').collect(); if segments.last() == Some(&"index") { segments.pop(); } if segments.is_empty() { "/".to_string() } else { format!("/{}", segments.join("/")) } } /// The directory (as a URL prefix) of the file at `rel` - the base /// relative references resolve against. pub fn dir_from_path(rel: &str) -> String { match rel.rsplit_once('/') { Some((dir, _)) => format!("/{dir}"), None => "/".to_string(), } } /// Resolves a possibly-relative question reference (`action:`, /// `requires_chain:`) against the referencing file's directory: /// `/x` is absolute, `proposed` names a sibling, `../x` climbs. pub fn resolve_ref(base_dir: &str, reference: &str) -> String { if reference.starts_with('/') { return reference.to_string(); } let mut segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect(); for part in reference.split('/') { match part { "" | "." => {} ".." => { segments.pop(); } s => segments.push(s), } } if segments.is_empty() { "/".to_string() } else { format!("/{}", segments.join("/")) } } /// Whether `path` matches `pattern`, capturing `[name]` segments. /// Returns the captured (name, value) pairs on a match - empty for an /// exact literal match. pub fn path_matches(pattern: &str, path: &str) -> Option> { let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect(); let got: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if pat.len() != got.len() { return None; } let mut captures = Vec::new(); for (p, g) in pat.iter().zip(got.iter()) { if let Some(name) = p.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { if g.is_empty() { return None; } captures.push((name.to_string(), (*g).to_string())); } else if p != g { return None; } } Some(captures) } /// Resolves a URL path to its question: an exact id first, else the /// one dynamic page whose pattern matches, with each captured segment /// substituted into `{name}` placeholders in the clone's resource keys /// and its id set to the concrete path (so every follow-up server call /// - resources, submissions - re-resolves the same way). The /// substituted key is still looked up in the content-declared bucket, /// so a visitor varies the key, never the bucket - the same trust /// shape as a `?chain=` link, where knowing a record's key is holding /// it. pub fn resolve_question( questions: &std::collections::HashMap, path: &str, ) -> Option { if let Some(q) = questions.get(path) { return Some(q.clone()); } for q in questions.values() { if !q.is_dynamic() { continue; } if let Some(captures) = path_matches(&q.id, path) { let mut resolved = q.clone(); resolved.id = path.to_string(); let substitute = |key: &mut Option| { if let Some(k) = key { for (name, value) in &captures { *k = k.replace(&format!("{{{name}}}"), value); } } }; // A url source's address takes the segment too: // `https://relay.redoal.com/place/{key}`. let substitute_url = |src: &mut ResourceSource| { if let ResourceSource::Url { url } = src { for (name, value) in &captures { *url = url.replace(&format!("{{{name}}}"), value); } } }; for alt in &mut resolved.alternatives { for feature in &mut alt.features { if let Some(res) = &mut feature.resource { substitute(&mut res.key); substitute_url(&mut res.source); } for req in &mut feature.requirements { if let Some(res) = &mut req.resource { substitute(&mut res.key); } substitute(&mut req.value); } } } return Some(resolved); } } None } /// Builds the question map from raw (path, yaml) files - the shared /// back half of both loaders (Gitea tree and `question_lint --path`). /// Applies the whole filesystem-routing contract: derived ids, /// relative-reference resolution, followup inference, `_section.yaml` /// inheritance, and the tree-shape rules (no id collisions, at most /// one dynamic page per directory). #[cfg(feature = "ssr")] pub fn build_questions( files: &[(String, String)], ) -> anyhow::Result> { // Sections first: (directory prefix, config), shallowest first so a // later (deeper) section overrides an outer one field-by-field. let mut sections: Vec<(String, SectionConfig)> = Vec::new(); for (rel, raw) in files { let name = rel.rsplit('/').next().unwrap_or(rel); if name != "_section.yaml" { continue; } let mut section: SectionConfig = serde_yaml::from_str(raw) .map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?; let dir = dir_from_path(rel); if let Some(rc) = §ion.requires_chain { section.requires_chain = Some(resolve_ref(&dir, rc)); } sections.push((dir, section)); } sections.sort_by_key(|(dir, _)| dir.len()); let mut out = std::collections::HashMap::new(); let mut dynamic_dirs = std::collections::HashSet::new(); for (rel, raw) in files { let name = rel.rsplit('/').next().unwrap_or(rel); // Underscore files are sections or drafts, never pages. if name.starts_with('_') { continue; } let mut question: Question = serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?; let derived = route_from_path(rel); let dir = dir_from_path(rel); if question.id.is_empty() { question.id = derived.clone(); } else if question.id != derived { tracing::warn!( "{rel}: declared id {:?} disagrees with its path ({derived}) - the declared id wins, but consider moving the file", question.id ); } for alt in &mut question.alternatives { if let Some(action) = &alt.action { alt.action = Some(resolve_ref(&dir, action)); } } if let Some(rc) = &question.requires_chain { question.requires_chain = Some(resolve_ref(&dir, rc)); } if question.followup.is_none() { // Nested non-index files are flow steps and outcomes - // followups by construction. Top-level files keep the // historical flat-repo default. let nested = rel.contains('/'); let is_index = name == "index.yaml"; question.followup = Some(nested && !is_index); } // Nearest-ancestor section fills whatever the question left // unset (walk deepest-last, so later matches override earlier // section values but never the question's own). for (sdir, section) in §ions { let applies = *sdir == "/" || dir == *sdir || dir.starts_with(&format!("{sdir}/")); if !applies { continue; } if question.qualifies.is_none() { question.qualifies = section.qualifies.clone(); } if question.requires_chain.is_none() { question.requires_chain = section.requires_chain.clone(); } if question.responsible.is_none() { question.responsible = section.responsible.clone(); } } if question.is_dynamic() && !dynamic_dirs.insert(dir.clone()) { anyhow::bail!( "{rel}: more than one dynamic ([name].yaml) page in {dir} - matching would be ambiguous" ); } if let Some(previous) = out.insert(question.id.clone(), question) { anyhow::bail!( "{rel}: id {:?} is already declared by another file", previous.id ); } } 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 resolve_question(questions, &filled) { Some(target) if target.is_dynamic() => 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() { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares no resource to select from", 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("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::*; fn schema_fixture() -> std::collections::HashMap { crate::aggregates::parse_aggregates_yaml( r#" aggregates: - bucket: things initial: open states: open: { event: opened } middle: { event: advanced } done: { event: finished } transitions: open: [middle] middle: [done] "#, ) .unwrap() } fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /t name: T alternatives: - name: A features: - name: "" resource: source: {{ kind: kv, bucket: things }} requires_group: owners transitions: {transitions_yaml} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn declared_edge_passes() { let questions = question_with_transitions( " - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }", ); assert!(validate_questions(&questions, &schema_fixture()).is_ok()); } #[test] fn from_defaults_to_open() { let questions = question_with_transitions(" - { to: middle, label: Advance }"); assert!(validate_questions(&questions, &schema_fixture()).is_ok()); } #[test] fn undeclared_from_state_is_rejected() { let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("transition.from")); } #[test] fn undeclared_edge_is_rejected() { // Both states exist, but open -> done skips a step the graph // never declared. let questions = question_with_transitions(" - { from: open, to: done, label: Skip }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("not a declared edge")); } #[test] fn dangling_action_is_rejected() { let question: Question = serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n") .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("does not match any declared question id")); } #[test] fn self_referencing_action_passes() { let question: Question = serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /a\n") .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); assert!(validate_questions(&questions, &Default::default()).is_ok()); } fn question_with_bind(bind_field: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /b name: B alternatives: - name: A features: - name: "" requirements: - name: picker type: select resource: public: true source: {{ kind: url, url: "https://x.example/list" }} - name: body type: textarea bind: field: {bind_field} resource: public: true source: {{ kind: url, url: "https://x.example/item/{{picker}}" }} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn bind_to_sibling_passes() { assert!(validate_questions(&question_with_bind("picker"), &Default::default()).is_ok()); } #[test] fn bind_to_missing_sibling_is_rejected() { let err = validate_questions(&question_with_bind("bogus"), &Default::default()).unwrap_err(); assert!(err.to_string().contains("not another requirement")); } #[test] fn bind_to_itself_is_rejected() { let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err(); assert!(err.to_string().contains("not another requirement")); } fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( "id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n" )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn gesture_with_wss_relay_passes() { let questions = question_with_requirement( " - { name: curve, type: gesture, relay: \"wss://relay.redoal.com\", optional: true }", ); assert!(validate_questions(&questions, &Default::default()).is_ok()); } #[test] fn gesture_without_relay_passes_offline() { let questions = question_with_requirement(" - { name: curve, type: gesture, optional: true }"); assert!(validate_questions(&questions, &Default::default()).is_ok()); } #[test] fn relay_on_non_gesture_kind_is_rejected() { let questions = question_with_requirement( " - { name: email, type: email, relay: \"wss://relay.redoal.com\" }", ); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("relay only makes sense on type: gesture")); } #[test] fn https_relay_is_rejected() { let questions = question_with_requirement( " - { name: curve, type: gesture, relay: \"https://relay.redoal.com\" }", ); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("ws:// or wss://")); } #[test] fn site_config_defaults_to_the_plain_hero() { let site = SiteConfig::default(); assert_eq!(site.hero.kind, "plain"); assert!(site.validate().is_ok()); // And an empty file parses to the same thing. let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap(); assert_eq!(parsed, site); } #[test] fn site_config_rejects_unknown_hero_kind() { let site: SiteConfig = serde_yaml::from_str("hero: { kind: fireworks }").unwrap(); let err = site.validate().unwrap_err(); assert!(err.to_string().contains("fireworks")); } #[test] fn site_config_module_hero_needs_a_safe_path() { let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap(); assert!(missing.validate().is_err()); let traversal: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap(); assert!(traversal.validate().is_err()); let absolute: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap(); assert!(absolute.validate().is_err()); let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap(); assert!(ok.validate().is_ok()); } #[test] fn redoal_site_yaml_shape_parses() { let site: SiteConfig = serde_yaml::from_str( "title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n", ) .unwrap(); assert!(site.validate().is_ok()); assert_eq!(site.title.as_deref(), Some("redoal")); assert_eq!(site.hero.module.as_deref(), Some("hero.js")); } #[test] fn routes_derive_from_paths() { assert_eq!(route_from_path("index.yaml"), "/"); assert_eq!(route_from_path("applied.yaml"), "/applied"); assert_eq!(route_from_path("develop/index.yaml"), "/develop"); assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal"); assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]"); } #[test] fn relative_refs_resolve_against_the_file_dir() { assert_eq!(resolve_ref("/develop", "proposed"), "/develop/proposed"); assert_eq!(resolve_ref("/develop", "/subscribed"), "/subscribed"); assert_eq!(resolve_ref("/develop", "../applied"), "/applied"); assert_eq!(resolve_ref("/", "applied"), "/applied"); assert_eq!(resolve_ref("/", ".."), "/"); } #[test] fn dynamic_patterns_capture_segments() { assert_eq!(path_matches("/review/[record]", "/review/abc"), Some(vec![("record".into(), "abc".into())])); assert_eq!(path_matches("/review/[record]", "/review"), None); assert_eq!(path_matches("/review/[record]", "/other/abc"), None); assert_eq!(path_matches("/review", "/review"), Some(vec![])); } #[test] fn tree_becomes_router_with_inference_and_sections() { let files = vec![ ("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()), ("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()), ("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()), ("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()), ("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()), ("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()), ("review/index.yaml".to_string(), "name: Review\n".to_string()), ("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()), ]; let questions = build_questions(&files).unwrap(); // Derived ids and resolved relative actions. assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied")); assert_eq!( questions["/develop"].alternatives[0].action.as_deref(), Some("/develop/proposal") ); assert_eq!( questions["/develop/proposal"].alternatives[0].action.as_deref(), Some("/develop/proposed") ); // Followup inference: nested non-index files are followups, // index files and top-level files are not (explicit wins). assert!(!questions["/"].is_followup()); assert!(questions["/applied"].is_followup()); assert!(!questions["/develop"].is_followup()); assert!(questions["/develop/proposal"].is_followup()); // Section inheritance gates the directory; drafts are skipped. assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners")); assert_eq!(questions["/"].qualifies, None); assert!(!questions.contains_key("/review/_draft")); } #[test] fn declared_id_wins_over_path() { let files = vec![( "legacy.yaml".to_string(), "id: /somewhere-else\nname: Legacy\n".to_string(), )]; let questions = build_questions(&files).unwrap(); assert!(questions.contains_key("/somewhere-else")); } #[test] fn colliding_ids_and_ambiguous_dynamics_are_rejected() { let collision = vec![ ("a.yaml".to_string(), "name: A\n".to_string()), ("b.yaml".to_string(), "id: /a\nname: B\n".to_string()), ]; assert!(build_questions(&collision).is_err()); let ambiguous = vec![ ("review/[a].yaml".to_string(), "name: A\n".to_string()), ("review/[b].yaml".to_string(), "name: B\n".to_string()), ]; assert!(build_questions(&ambiguous).is_err()); } #[test] fn dynamic_pages_resolve_with_key_substitution() { let files = vec![( "review/[record].yaml".to_string(), "name: Record\nalternatives:\n - name: view\n features:\n - name: detail\n resource:\n public: true\n key: \"{record}\"\n source:\n kind: kv\n bucket: applicants\n".to_string(), )]; let questions = build_questions(&files).unwrap(); let page = resolve_question(&questions, "/review/abc123").unwrap(); assert_eq!(page.id, "/review/abc123"); assert_eq!( page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(), Some("abc123") ); assert!(resolve_question(&questions, "/review").is_none()); // The pattern itself still resolves exactly (it IS an id). assert!(resolve_question(&questions, "/review/[record]").is_some()); } #[test] fn requires_chain_resolves_and_validates() { let files = vec![ ("shape.yaml".to_string(), "name: Shape\n".to_string()), ( "shaped.yaml".to_string(), "name: Shaped\nrequires_chain: shape\n".to_string(), ), ]; let questions = build_questions(&files).unwrap(); assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape")); assert!(validate_questions(&questions, &Default::default()).is_ok()); let dangling = vec![( "shaped.yaml".to_string(), "name: Shaped\nrequires_chain: /nowhere\n".to_string(), )]; let questions = build_questions(&dangling).unwrap(); assert!(validate_questions(&questions, &Default::default()).is_err()); } #[test] fn recorded_buckets_must_be_attended() { let write_only = vec![( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(), )]; let questions = build_questions(&write_only).unwrap(); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("unattended"), "got: {err}"); // A desk (any Kv resource over the bucket, anywhere in the // repo) attends it. let with_desk = vec![ ( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(), ), ( "review/index.yaml".to_string(), "name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(), ), ]; let questions = build_questions(&with_desk).unwrap(); assert!(validate_questions(&questions, &Default::default()).is_ok()); // ..or an explicit attended_by annotation in aggregates.yaml. let aggregates = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n", ) .unwrap(); let questions = build_questions(&write_only).unwrap(); assert!(validate_questions(&questions, &aggregates).is_ok()); } #[test] fn actions_may_not_target_dynamic_pages() { let files = vec![ ( "index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(), ), ("review/[record].yaml".to_string(), "name: Record\n".to_string()), ]; let questions = build_questions(&files).unwrap(); assert!(validate_questions(&questions, &Default::default()).is_err()); } // ── events (announced pages) ───────────────────────────────────────── #[test] fn duration_parses_whole_units() { assert_eq!(parse_duration_ms("3h").unwrap(), 3 * 3_600_000); assert_eq!(parse_duration_ms("90m").unwrap(), 90 * 60_000); assert_eq!(parse_duration_ms("1d 6h").unwrap(), 30 * 3_600_000); assert!(parse_duration_ms("0h").is_err()); assert!(parse_duration_ms("3 hours").is_err()); assert!(parse_duration_ms("").is_err()); } #[test] fn event_window_and_liveness() { let q: Question = serde_yaml::from_str( "id: /opening\nname: Will you be there?\nevent:\n starts: 2026-09-12T18:00:00+02:00\n duration: 3h\n", ) .unwrap(); let (starts, ends) = q.event.as_ref().unwrap().window_ms().unwrap(); assert_eq!(ends - starts, 3 * 3_600_000); assert!(q.event_live_at(starts - 1)); assert!(q.event_live_at(ends - 1)); assert!(!q.event_live_at(ends)); assert!(q.event_ended_at(ends)); assert!(!q.event_ended_at(starts)); let plain: Question = serde_yaml::from_str("id: /x\nname: X\n").unwrap(); assert!(!plain.event_live_at(0) && !plain.event_ended_at(i64::MAX)); } #[test] fn event_validation() { let bad: Question = serde_yaml::from_str( "id: /e\nname: E\nevent:\n starts: next friday\n duration: 3h\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(bad.id.clone(), bad); let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); assert!(err.contains("bad starts"), "{err}"); let dynamic: Question = serde_yaml::from_str( "id: /e/[slot]\nname: E\nevent:\n starts: 2026-09-12T18:00:00Z\n duration: 3h\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(dynamic.id.clone(), dynamic); let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); assert!(err.contains("dynamic page can't carry an event"), "{err}"); } #[test] fn builtin_events_schema_yields_to_content() { let merged = with_builtin_aggregates(Default::default()); let schema = &merged[EVENTS_BUCKET]; assert_eq!(schema.initial, "announced"); assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]); assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]); let content = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n", ) .unwrap(); assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine"); } #[test] fn bucket_is_read_sees_kv_resources() { let qs = question_with_transitions(" - { to: middle, label: Go }"); assert!(bucket_is_read(&qs, "things")); assert!(!bucket_is_read(&qs, EVENTS_BUCKET)); } // ── inline markdown ────────────────────────────────────────────────── #[test] fn markdown_links_and_emphasis() { assert_eq!( render_inline_markdown("See [the programme](https://attac.no/x) *soon*."), "See the programme soon." ); assert_eq!(render_inline_markdown("plain text"), "plain text"); assert_eq!(render_inline_markdown("a\nb"), "a b"); } #[test] fn markdown_drops_html_and_unsafe_links() { assert_eq!(render_inline_markdown("x z"), "x y z"); assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad"); assert_eq!(render_inline_markdown("[ok](/shape)"), "ok"); assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "mail"); } #[test] fn action_templates_from_responses() { let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3}); assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98")); assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y")); assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape")); assert!(template_action("/shape/{curve.missing}", &r).is_none()); assert!(template_action("/shape/{curve.points}", &r).is_none()); let odd = serde_json::json!({"k": "a/b c"}); assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c")); } #[test] fn dynamic_page_fills_requirement_values() { let q: Question = serde_yaml::from_str( "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(q.id.clone(), q); let page = resolve_question(&qs, "/shape/20aa98").unwrap(); assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98")); let q: Question = serde_yaml::from_str( "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n", ) .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(q.id.clone(), q); let page = resolve_question(&qs, "/shape/20aa98").unwrap(); match &page.alternatives[0].features[0].resource.as_ref().unwrap().source { ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"), _ => panic!(), } } }