Files
portal/src/content/gitea.rs
T
Bendik Aagaard LynghaugandClaude Fable 5.1 ef4789f9b2
Test / test (pull_request) Successful in 56s
Invites and suggestions are cases; the responsible follow the content
Five things the first cut of accounts-from-content left open.

The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.

The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.

Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.

An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.

A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.

Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:00:10 +02:00

252 lines
11 KiB
Rust

//! 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<SiteConfig> {
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<String> {
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<String> {
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<String> {
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<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
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<std::collections::HashMap<String, Question>> {
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";
/// Published by `watch_for_reload` once new content is in place, for
/// whatever acts on what the content says rather than serving it: the
/// accounts it mandates (`seed.rs`). No payload; the content is read
/// from where it always is.
#[cfg(feature = "ssr")]
pub const CONTENT_RELOADED_SUBJECT: &str = "portal.content.reloaded";
/// 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<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
) {
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");
let _ = nats.publish(CONTENT_RELOADED_SUBJECT, "".into()).await;
}
Err(e) => {
tracing::error!(error = %e, "content reload failed, keeping last-good content");
}
}
}
}