iOS is the one client none of the mail autoconfiguration reaches: Apple Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so adding an account means the device probing for ports - and a probe that meets a filtered port waits out a TCP timeout and reports the account as unverifiable, which the person reads as a wrong password. A profile states the hosts and the ports instead, and carries mail, calendars and contacts in one install. It invents none of them: site.yaml names a PACC document - the standard defined to be fetched from the mail domain itself - and every host, port and collection path comes from there, so the server is described in one place and the profile follows it. The document holds no address, username or password. iOS asks for those at install, which means one profile serves everyone with an account and it can be fetched by anyone: it says no more than PACC already says in public. Identifiers are derived from the site and the payload rather than drawn at random, because iOS replaces a profile whose ids it knows and stacks a duplicate when they differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
305 lines
13 KiB
Rust
305 lines
13 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<Storylines<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(Storylines::one(SiteConfig::default()));
|
|
}
|
|
};
|
|
// The invite mail's words may be several (see `variants.rs`).
|
|
let files = vec![("site.yaml".to_string(), raw)];
|
|
let arity = super::variants::arity(&files)?;
|
|
let mut each = Vec::with_capacity(arity);
|
|
for n in 0..arity {
|
|
let site: SiteConfig = serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1)
|
|
.map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
|
site.validate()?;
|
|
each.push(site);
|
|
}
|
|
Ok(Storylines { each })
|
|
}
|
|
|
|
/// 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<Storylines<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?;
|
|
// A mail's subject and body may be several (see `variants.rs`);
|
|
// the runtime's own buckets are added to every storyline.
|
|
let files = vec![("aggregates.yaml".to_string(), raw)];
|
|
let arity = super::variants::arity(&files)?;
|
|
let each = (0..arity)
|
|
.map(|n| {
|
|
crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1)
|
|
.map(with_builtin_aggregates)
|
|
})
|
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
|
Ok(Storylines { each })
|
|
}
|
|
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn load_questions_from_gitea(
|
|
repo_url: &str,
|
|
branch: &str,
|
|
subdir: &str,
|
|
) -> anyhow::Result<Storylines<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_storylines(&files)
|
|
}
|
|
|
|
/// Every storyline's arity is the site's or one: a mail told in two
|
|
/// voices on a site whose pages are told in three has a hole in it.
|
|
#[cfg(feature = "ssr")]
|
|
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
|
|
let arity = questions.max(aggregates).max(site);
|
|
for (what, n) in [
|
|
("questions", questions),
|
|
("aggregates.yaml", aggregates),
|
|
("site.yaml", site),
|
|
] {
|
|
if n != 1 && n != arity {
|
|
anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
|
|
}
|
|
}
|
|
Ok(arity)
|
|
}
|
|
|
|
/// 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<Storylines<std::collections::HashMap<String, Question>>>,
|
|
>,
|
|
aggregates: std::sync::Arc<
|
|
arc_swap::ArcSwap<
|
|
Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
|
|
>,
|
|
>,
|
|
site: std::sync::Arc<arc_swap::ArcSwap<Storylines<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) => 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).and_then(|_| {
|
|
same_arity(
|
|
loaded.arity(),
|
|
loaded_aggregates.arity(),
|
|
loaded_site.arity(),
|
|
)
|
|
}) {
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
}
|