use serde::{Deserialize, Serialize}; /// One page: a prompt plus the paths on from it. `id` doubles as the URL /// path it's served at ("/" is the landing page). Loaded from a plain /// YAML file per question in a content directory kept in its own git /// repo (see ../portal-content) - editing content is a content-repo /// commit, not a Rust rebuild. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Question { pub id: String, #[serde(default)] pub route: Option, pub name: String, #[serde(default)] pub description: String, /// Kanidm group required to view/submit this question - `None` means /// open to anyone, matching every question today. Content-driven /// on purpose: a gated page like "/review" is just a Question with /// this set, not a bespoke Rust route. #[serde(default)] pub qualifies: Option, #[serde(default)] pub alternatives: Vec, /// Who to contact if a visitor gets stuck - rendered as a small line /// on the page. #[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. #[serde(default)] pub followup: bool, } #[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, #[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, } /// 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, /// `type: gesture` only - 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: the YES canvas piece (`yes`, the /// default), a redoal gesture-drawing canvas (`gesture`), or copy /// only (`plain`). #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HeroConfig { #[serde(default = "default_hero_kind")] pub kind: String, /// `kind: gesture` only - ws(s):// URL of a redoal-relay for /// ambient echoes. Absent means the hero draws offline. #[serde(default)] pub relay: Option, } impl Default for HeroConfig { fn default() -> Self { Self { kind: default_hero_kind(), relay: None } } } fn default_hero_kind() -> String { "yes".to_string() } #[cfg(feature = "ssr")] impl SiteConfig { /// Shared by `load_site_from_gitea` and the `question-lint` binary /// so a typo'd hero kind is a caught rejection, not a silently /// plain hero. pub fn validate(&self) -> anyhow::Result<()> { if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") { anyhow::bail!( "site.yaml: hero.kind {:?} is not one of yes | gesture | plain", self.hero.kind ); } if let Some(relay) = &self.hero.relay { if self.hero.kind != "gesture" { anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture"); } validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?; } Ok(()) } } /// 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")] 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). #[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(); let list_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{subdir}?ref={branch}"); let listing = client .get(&list_url) .send() .await .map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? .error_for_status() .map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? .text() .await .map_err(|e| anyhow::anyhow!("reading directory listing from {list_url}: {e}"))?; let entries: Vec = serde_json::from_str(&listing) .map_err(|e| anyhow::anyhow!("parsing directory listing from {list_url}: {e}"))?; let mut out = std::collections::HashMap::new(); for entry in entries { let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); if !name.ends_with(".yaml") { continue; } let download_url = entry .get("download_url") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("no download_url for {name}"))?; let raw = client .get(download_url) .send() .await .map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? .error_for_status() .map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? .text() .await .map_err(|e| anyhow::anyhow!("reading {name}: {e}"))?; let question: Question = serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {name}: {e}"))?; out.insert(question.id.clone(), question); } Ok(out) } /// 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. #[cfg(feature = "ssr")] pub fn validate_questions( questions: &std::collections::HashMap, aggregates: &std::collections::HashMap, ) -> anyhow::Result<()> { for question in questions.values() { 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 !questions.contains_key(action) { anyhow::bail!( "question {:?} alternative {:?}: action {:?} does not match any declared question id", question.id, alternative.name, action ); } } 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" { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture", 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 ); } } } } } 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) => 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, } /// 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_yes_hero() { let site = SiteConfig::default(); assert_eq!(site.hero.kind, "yes"); 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_rejects_relay_without_gesture_hero() { let site: SiteConfig = serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap(); assert!(site.validate().is_err()); } #[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: gesture\n relay: wss://relay.redoal.com\n", ) .unwrap(); assert!(site.validate().is_ok()); assert_eq!(site.title.as_deref(), Some("redoal")); assert_eq!(site.hero.kind, "gesture"); } }