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, } /// 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, #[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, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Feature { pub name: String, #[serde(default)] pub description: String, #[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 bucket: String, /// A single item; omit to list the whole bucket. #[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. #[serde(default)] pub transitions: Vec, } #[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` only - accept multiple files. #[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, } 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()) } } /// 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. Runs once at /// startup, over Gitea's public contents API (no auth - the content /// repo is public); no hot-reload yet - restart the process (or add /// polling later) to pick up content changes. #[cfg(feature = "ssr")] pub async fn load_questions_from_gitea( repo_url: &str, branch: &str, subdir: &str, ) -> anyhow::Result> { let parsed = url::Url::parse(repo_url) .map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?; let api_base = format!( "{}://{}", parsed.scheme(), parsed .host_str() .ok_or_else(|| anyhow::anyhow!("no host in content repo url {repo_url}"))? ); let 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}"))?; let repo = segments .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {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) }