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 or finds this question /// unhelpful - rendered as a small line on the page, and the person /// a "report this question" action names in its own event payload /// (see `events::store`, `question_report` aggregate-less events). #[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, #[serde(default)] pub consequence: Vec, #[serde(default)] pub encouragements: Vec, /// A banner image url, rendered above the description - purely /// decorative, no upload/hosting mechanism of its own, just an /// already-hosted url the browser fetches directly (unlike /// `ResourceSource::Url`, this is never fetched server-side, so it /// carries none of that variant's SSRF concern). #[serde(default)] pub image: Option, #[serde(default)] pub features: Vec, /// Names a NATS KV bucket to also durably store this submission /// into, beyond the ephemeral NATS event every submission already /// gets published as - just a bucket name, not a keyword the /// runtime special-cases (see `src/answers.rs`). Content-driven /// rather than matching on the alternative's name/copy, which would /// silently break if the wording changes later. #[serde(default)] pub record_as: Option, /// A transition anyone holding the *right item's own reference* can /// fire - not gated by `requires_group` like `ResourceSpec.transitions`, /// which is for signed-in owners browsing a whole bucket. This is /// the opposite shape: an anonymous visitor who already holds one /// specific chain hash (from a `?chain=` link - see `chain.rs`, /// the same reference `/subscribed?chain=...` already carries) may /// transition *that one item*, and only that one, without ever /// being able to enumerate or touch anyone else's. `email` is a /// second factor checked against the stored item's own `email` /// response field - not the lookup key, just cheap defense in depth /// against a leaked/guessed chain hash alone being sufficient. #[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 (hex, named, rgb()/oklch()/...) - set as this /// feature's own `--feature-accent` custom property rather than /// interpolated into a stylesheet, so a bad value just fails to /// apply instead of being live CSS content could inject arbitrary /// rules into. Unset means no accent border at all (see /// style/main.css's `.feature`), not a silent fallback to the /// global `--accent` - a feature that never asked for a color /// shouldn't suddenly gain a visible border. #[serde(default)] pub color: Option, /// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`), /// rendered via Iconify's public SVG API /// (`https://api.iconify.design/{icon}.svg`) - no icon library /// bundled here, matching this app's zero-JS-dependency content /// otherwise keeps to. #[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, } /// Declares a NATS KV-backed live-data read. The bucket/key here are /// only ever read server-side, from content the server itself loaded at /// startup - `get_resource` (`src/resource.rs`) takes a question id + /// feature name from the client, never a bucket/key directly, so a /// visitor can never probe an arbitrary bucket by naming it. There is /// no render-mode/"kind" tag here on purpose - what a resource displays /// as follows from its data's own shape (a plain value, or a list of /// `answers::Answer`s), not from a Rust-side special case per content /// item. #[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 by /// design (fail closed, not fail open). Reads only - mutations /// (`transitions` below) always require `requires_group` regardless /// of this flag. #[serde(default)] pub public: bool, /// States a listed answer may move to, and the button label for /// each - empty means the resource is read-only. The allow-list a /// `transition_answer` call is checked against, so a client can /// never move an answer to a state content didn't declare. Only /// meaningful for a `Kv` source - a live external pull is always /// read-only. #[serde(default)] pub transitions: Vec, /// A jq filter reshaping the fetched value before it reaches the /// frontend - e.g. `.[] | {name, url: .html_url}` to pick just the /// fields a showcase card needs out of a raw Gitea API response. /// Evaluated via the `jaq` crate (`resource::apply_jq`), no shell /// -out. `None` (or a `Kv` source) returns the fetched value as-is. #[serde(default)] pub jq: Option, } /// Where a resource's live data actually comes from. `Kv` is the /// original (and still only mutable) behavior - a NATS KV bucket this /// server itself owns. The `Gitea*` variants are live pulls from the /// same Gitea instance content is loaded from (`AppState.gitea_base`), /// for surfacing things like a portfolio of starred repos without /// hand-curating a KV bucket for it. Not mutually exclusive with /// owner-curated resources - a hand-picked feature list is just a `Kv` /// resource an owner writes into; a `GiteaStarred`/`GiteaOrgRepos` pull /// is the automatic catalog to choose from. Both can appear as /// different features on the same page. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ResourceSource { Kv { bucket: String }, GiteaStarred { username: String }, GiteaOrgRepos { org: String }, /// Any other HTTPS JSON endpoint - the general escape hatch for a /// live resource that isn't this Gitea instance. Deliberately /// scheme-restricted and checked against loopback/private/link /// -local addresses at fetch time (`resource::fetch_url_resource`) /// - content is trusted today (same org owns both repos), but a /// server-side fetch of a content-supplied URL is real SSRF surface /// the moment that stops being true, so it fails closed rather than /// trusting every host by default. Url { url: String }, } impl ResourceSpec { /// The KV bucket this resource reads/writes, if it's `Kv`-sourced - /// `None` for a live external pull, which has no bucket and (see /// `transitions` above) can't be mutated through this mechanism. pub fn bucket(&self) -> Option<&str> { match &self.source { ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::GiteaStarred { .. } | ResourceSource::GiteaOrgRepos { .. } | ResourceSource::Url { .. } => None, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Transition { pub to: String, pub label: 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 selectable options come from. /// Reuses the exact same `ResourceSpec`/`ResourceSource`/`jq` /// mechanism a `Feature.resource` uses (`resource::get_requirement_options`) /// - a resource is a resource regardless of whether it's displayed /// read-only or offered as choices to pick from. #[serde(default)] pub resource: Option, /// `type: select` only - which field in each resource item is that /// option's stable identifier, submitted as the requirement's value /// (or one entry of it, if `multiple`). Defaults to trying `_id` /// then `id` if unset. #[serde(default)] pub id_field: Option, } 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()) } } /// Extracts `scheme://host` from a repo's normal browser URL (e.g. /// `https://project.uhhm.no/uhhm/questions` -> `https://project.uhhm.no`) /// - the Gitea API base every helper in this module builds requests /// against, including ones (like `resolve_gitea_repo`) that have /// nothing to do with content loading, just the same Gitea instance. #[cfg(feature = "ssr")] pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { 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 a Gitea repo's root - /// sibling to the pages `subdir`, not inside it, so /// `load_questions_from_gitea`'s "every `*.yaml` under `subdir` is a /// page" directory-listing convention needs no special-case exclusion. /// Validation (well-formed states/transitions, no duplicate buckets or /// event-type strings) happens inside `aggregates::parse_aggregates_yaml` /// itself - 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 { 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 ); } } } 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 ); } } 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.to) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}", question.id, alternative.name, feature.name, 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>, >, ) { 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; } }; 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)); 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))) }