//! Loading a content repo from Gitea, and the NATS-signalled reload //! that hot-swaps it (last-good content is kept on any failure). Every //! fetch goes through `crate::http`: bounded in time and size. use super::*; /// Fetches and parses `site.yaml` from the content repo root. A fetch /// failure (typically 404 - the file is optional) yields the default /// config; a file that exists but doesn't parse or validate is a real /// error, surfaced at boot rather than papered over. #[cfg(feature = "ssr")] pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result { let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await { Ok(raw) => raw, Err(e) => { tracing::info!("no site.yaml in content repo ({e}), using default branding"); return Ok(SiteConfig::default()); } }; let site: SiteConfig = serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?; site.validate()?; Ok(site) } /// Extracts `scheme://host` from a repo's normal browser URL - the /// Gitea API base every helper in this module builds requests against. #[cfg(feature = "ssr")] /// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base /// `site_asset_handler` proxies content-shipped files from. #[cfg(feature = "ssr")] pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result { let (owner, repo) = parse_owner_repo(repo_url)?; Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?)) } #[cfg(feature = "ssr")] pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { let parsed = url::Url::parse(repo_url) .map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?; Ok(format!( "{}://{}", parsed.scheme(), parsed .host_str() .ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))? )) } /// Splits a repo's normal browser URL into `(owner, repo)` - shared by /// every loader in this module that needs to build a Gitea contents API /// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`). #[cfg(feature = "ssr")] pub(crate) fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> { let parsed = url::Url::parse(repo_url) .map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?; let mut segments = parsed .path_segments() .ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?; let owner = segments .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))? .to_string(); let repo = segments .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))? .to_string(); Ok((owner, repo)) } /// Fetches one file from a Gitea repo's contents API and returns its raw /// text - the single-file counterpart to `load_questions_from_gitea`'s /// directory-listing loop, used by `load_aggregates_from_gitea` for the /// one `aggregates.yaml` file at the repo root. #[cfg(feature = "ssr")] pub(crate) async fn fetch_gitea_file( api_base: &str, owner: &str, repo: &str, branch: &str, path: &str, ) -> anyhow::Result { let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}"); let meta_text = crate::http::get_text(&meta_url, crate::http::GITEA_CAP) .await .map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))?; let meta: serde_json::Value = serde_json::from_str(&meta_text) .map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?; let download_url = meta .get("download_url") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?; crate::http::get_text(download_url, crate::http::GITEA_CAP) .await .map_err(|e| anyhow::anyhow!("fetching {path}: {e}")) } /// Fetches and parses `aggregates.yaml` from the repo root (sibling to /// the pages `subdir`, so the "every yaml under subdir is a page" /// convention needs no exclusion). A malformed file fails here, before /// ever reaching `AppState`. #[cfg(feature = "ssr")] pub async fn load_aggregates_from_gitea( repo_url: &str, branch: &str, ) -> anyhow::Result> { let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?; crate::aggregates::parse_aggregates_yaml(&raw) } #[cfg(feature = "ssr")] pub async fn load_questions_from_gitea( repo_url: &str, branch: &str, subdir: &str, ) -> anyhow::Result> { let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; // One recursive git-trees call for the whole repo instead of a // contents listing per directory - the tree IS the router now, so // nested files matter. let tree_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"); let listing = crate::http::get_text(&tree_url, crate::http::GITEA_CAP) .await .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?; let tree: serde_json::Value = serde_json::from_str(&listing) .map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?; if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) { anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large"); } let prefix = format!("{subdir}/"); let mut files = Vec::new(); for entry in tree .get("tree") .and_then(|v| v.as_array()) .map(|v| v.as_slice()) .unwrap_or_default() { let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or(""); if entry.get("type").and_then(|v| v.as_str()) != Some("blob") || !path.starts_with(&prefix) || !path.ends_with(".yaml") { continue; } // Brackets (dynamic pages like `[record].yaml`) must be // percent-encoded in the raw URL's path. let encoded = path.replace('[', "%5B").replace(']', "%5D"); let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}"); let raw = crate::http::get_text(&raw_url, crate::http::GITEA_CAP) .await .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?; files.push((path[prefix.len()..].to_string(), raw)); } build_questions(&files) } /// Published by the content repo's own CI (after it lints a push) to /// tell every running instance to pick up the change - a plain fire /// and forget NATS publish, no payload, matching `events.rs`'s /// `ANSWERS_SUBJECT` pattern. #[cfg(feature = "ssr")] pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload"; /// Runs for the life of the process: re-fetches `repo_url`/`branch` and /// atomically swaps both `questions` and `aggregates` on every /// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together, /// after both have loaded and validated successfully, so a reader never /// sees pages that reference a half-updated state graph. A fetch/parse /// failure logs and keeps serving the last-good content rather than /// clearing it - a bad push to the content repo (which should already /// have been caught by its own lint step) doesn't take the site down. #[cfg(feature = "ssr")] pub async fn watch_for_reload( nats: async_nats::Client, repo_url: String, branch: String, subdir: String, questions: std::sync::Arc>>, aggregates: std::sync::Arc< arc_swap::ArcSwap>, >, site: std::sync::Arc>, access: 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(); // The policy is compiled from the content it gates, so // it swaps in the same breath. access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates))); 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"); } } } }