//! Configuration (hub spec §17.1): `hub.toml` in the data directory, every //! key overridable by `OXYHUB_
_`. use serde::Deserialize; use std::net::SocketAddr; use std::path::{Path, PathBuf}; #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Config { /// Set from the command line, never from the file. #[serde(skip)] pub data: PathBuf, pub site: Site, pub server: Server, pub tls: Tls, pub auth: Auth, pub limits: Limits, pub retention: Retention, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Site { pub name: String, pub description: String, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Server { /// Plain HTTP; empty disables it. pub bind: String, /// HTTPS; empty disables it. Needs `tls`. pub tls_bind: String, /// What links and clone URLs start with. pub base_url: String, /// Trust `X-Forwarded-*` from a terminating proxy. pub behind_proxy: bool, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Tls { /// Generate a certificate at `init` (developer edition). pub self_signed: bool, /// PEM files; relative paths are under the data directory. pub cert: String, pub key: String, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Auth { /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty /// means every push is accepted, as `oxy serve` does. pub push_tokens: Vec, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Limits { /// Largest file rendered in the browser, e.g. `2MiB`. pub render_limit: ByteSize, pub asset_limit: ByteSize, pub push_body_limit: ByteSize, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Retention { pub trash_days: u32, pub webhook_log_days: u32, } /// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ByteSize(pub u64); impl ByteSize { pub fn parse(s: &str) -> Option { let s = s.trim(); let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); let unit = s[digits.len()..].trim(); let n: u64 = digits.parse().ok()?; let mult = match unit { "" | "B" => 1, "k" | "kB" | "KB" => 1000, "KiB" | "K" => 1 << 10, "M" | "MB" => 1_000_000, "MiB" => 1 << 20, "G" | "GB" => 1_000_000_000, "GiB" => 1 << 30, _ => return None, }; Some(ByteSize(n.checked_mul(mult)?)) } } impl<'de> Deserialize<'de> for ByteSize { fn deserialize>(d: D) -> Result { #[derive(Deserialize)] #[serde(untagged)] enum Raw { Number(u64), Text(String), } match Raw::deserialize(d)? { Raw::Number(n) => Ok(ByteSize(n)), Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))), } } } impl Default for Site { fn default() -> Self { Site { name: "Oxygenhub".into(), description: "Repositories, proposals and issues for Oxygen".into() } } } impl Default for Server { fn default() -> Self { Server { bind: "127.0.0.1:7300".into(), tls_bind: String::new(), base_url: "http://127.0.0.1:7300".into(), behind_proxy: false } } } impl Default for Tls { fn default() -> Self { Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() } } } impl Default for Limits { fn default() -> Self { Limits { render_limit: ByteSize(2 << 20), asset_limit: ByteSize(512 << 20), push_body_limit: ByteSize(4 << 30) } } } impl Default for Retention { fn default() -> Self { Retention { trash_days: 30, webhook_log_days: 30 } } } impl Default for Config { fn default() -> Self { Config { data: PathBuf::from("data"), site: Site::default(), server: Server::default(), tls: Tls::default(), auth: Auth::default(), limits: Limits::default(), retention: Retention::default(), } } } impl Config { /// Read `/hub.toml` if present, then apply environment overrides. pub fn load(data: &Path) -> anyhow::Result { let path = data.join("hub.toml"); let mut cfg = match std::fs::read_to_string(&path) { Ok(text) => Config::parse(&text)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(), Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())), }; cfg.data = data.to_path_buf(); cfg.apply_env(|k| std::env::var(k).ok()); Ok(cfg) } pub fn parse(text: &str) -> anyhow::Result { Ok(toml::from_str(text)?) } /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file. pub fn apply_env(&mut self, get: impl Fn(&str) -> Option) { if let Some(v) = get("OXYHUB_SITE_NAME") { self.site.name = v; } if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") { self.site.description = v; } if let Some(v) = get("OXYHUB_SERVER_BIND") { self.server.bind = v; } if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") { self.server.tls_bind = v; } if let Some(v) = get("OXYHUB_SERVER_BASE_URL") { self.server.base_url = v; } if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") { self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes"); } if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") { self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes"); } if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") { self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); } if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) { self.limits.render_limit = v; } } pub fn bind_addr(&self) -> anyhow::Result> { parse_addr(&self.server.bind) } pub fn tls_bind_addr(&self) -> anyhow::Result> { parse_addr(&self.server.tls_bind) } pub fn tls_cert(&self) -> PathBuf { self.data.join(&self.tls.cert) } pub fn tls_key(&self) -> PathBuf { self.data.join(&self.tls.key) } pub fn repos_dir(&self) -> PathBuf { self.data.join("repos") } pub fn keys_dir(&self) -> PathBuf { self.data.join("keys") } pub fn db_path(&self) -> PathBuf { self.data.join("hub.db") } /// The base URL without a trailing slash. pub fn base_url(&self) -> &str { self.server.base_url.trim_end_matches('/') } } fn parse_addr(s: &str) -> anyhow::Result> { if s.trim().is_empty() { return Ok(None); } Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?)) } #[cfg(test)] mod tests { use super::*; #[test] fn byte_sizes() { assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20))); assert_eq!(ByteSize::parse("512"), Some(ByteSize(512))); assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30))); assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000))); assert_eq!(ByteSize::parse("lots"), None); } #[test] fn file_then_environment() { let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap(); assert_eq!(cfg.server.bind, "0.0.0.0:7300"); assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20)); assert_eq!(cfg.site.name, "Oxygenhub"); cfg.apply_env(|k| match k { "OXYHUB_SITE_NAME" => Some("Earth".into()), "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()), _ => None, }); assert_eq!(cfg.site.name, "Earth"); assert_eq!(cfg.auth.push_tokens, ["a", "b"]); assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence"); } }