//! Server configuration from the environment (SPEC §10.4). The variable list is exhaustive. use std::net::SocketAddr; use std::path::PathBuf; use crate::error::AppError; /// Default of `KVITT_DATA_DIR`. pub const DEFAULT_DATA_DIR: &str = "/data"; /// Default of `KVITT_BIND`. pub const DEFAULT_BIND: &str = "0.0.0.0:8080"; /// Default of `KVITT_FCM_CREDENTIALS`. pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json"; /// Default of `KVITT_FCM_PROJECT_ID`. pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337"; /// Default of `KVITT_TRUSTED_PROXY`: no peer is trusted to set `X-Forwarded-For` (the rate limiter never resolves /// this value). `KVITT_PUBLIC_URL` has no default: the Kvitt host name is owner-chosen (SPEC §10.5), so the server /// refuses to start without it. pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY; /// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space). pub const NO_TRUSTED_PROXY: &str = "none"; /// Default of `KVITT_WEB_DIR`: the web client's files in the image (D-132). pub const DEFAULT_WEB_DIR: &str = "/app"; /// Default of `RUST_LOG`. pub const DEFAULT_LOG_FILTER: &str = "info"; /// File name of the database inside the data directory. pub const DATABASE_FILE: &str = "kvitt.sqlite"; /// The server's configuration. `RUST_LOG` is read by the tracing set-up in `main`, not stored here. /// /// Nothing here touches the network or the file system: a trusted proxy host that does not resolve and a missing /// FCM key file are both normal (no proxy: the peer address is the client; no key: push disabled). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { /// `KVITT_DATA_DIR`: holds `kvitt.sqlite`, `pictures/`, `apk/`. pub data_dir: PathBuf, /// `KVITT_BIND`: listen address. pub bind: SocketAddr, /// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Required (no default). Stored without a /// trailing slash. pub public_url: String, /// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing. pub fcm_credentials: PathBuf, /// `KVITT_FCM_PROJECT_ID`. pub fcm_project_id: String, /// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the /// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy". pub trusted_proxy: String, /// `KVITT_WEB_DIR`: the built web client, served under `/app/` (`api::web`); a missing directory means no web /// client (every `/app` path is 404). pub web_dir: PathBuf, } impl Config { /// Reads the process environment. pub fn from_env() -> Result { Self::from_lookup(|key| std::env::var(key).ok()) } /// Reads the variables through `lookup` (testable without touching the process environment). Empty values count /// as unset. A missing `KVITT_PUBLIC_URL` is an error. pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { let set = |key: &str| lookup(key).filter(|v| !v.trim().is_empty()); let get = |key: &str, default: &str| set(key).unwrap_or_else(|| default.to_owned()); let bind_text = get("KVITT_BIND", DEFAULT_BIND); let bind: SocketAddr = bind_text.parse().map_err(|e| { AppError::new( crate::error::ErrorCode::Internal, format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"), ) })?; let public_url = set("KVITT_PUBLIC_URL").ok_or_else(|| { AppError::new( crate::error::ErrorCode::Internal, "KVITT_PUBLIC_URL is not set: it must be the Kvitt base URL, \ https:// (SPEC §10.4)", ) })?; Ok(Self { data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)), bind, public_url: public_url.trim().trim_end_matches('/').to_owned(), fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)), fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID), trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY), web_dir: PathBuf::from(get("KVITT_WEB_DIR", DEFAULT_WEB_DIR)), }) } /// `/kvitt.sqlite`. pub fn database_path(&self) -> PathBuf { self.data_dir.join(DATABASE_FILE) } /// `/pictures`. pub fn pictures_dir(&self) -> PathBuf { self.data_dir.join("pictures") } /// `/apk`. pub fn apk_dir(&self) -> PathBuf { self.data_dir.join("apk") } } #[cfg(test)] mod tests { use super::*; const PUBLIC_URL: &str = "https://kvitt.example.invalid"; #[test] fn defaults_when_unset_or_empty() { let config = Config::from_lookup(|k| match k { "KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()), "KVITT_BIND" | "KVITT_TRUSTED_PROXY" => Some(String::new()), _ => None, }) .unwrap(); assert_eq!(config.data_dir, PathBuf::from("/data")); assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap()); assert_eq!(config.public_url, PUBLIC_URL); assert_eq!( config.fcm_credentials, PathBuf::from("/secrets/fcm-service-account.json") ); assert_eq!(config.fcm_project_id, "kvitt-1337"); assert_eq!(config.trusted_proxy, "none"); assert_eq!(config.web_dir, PathBuf::from("/app")); assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite")); } #[test] fn overrides() { let config = Config::from_lookup(|k| match k { "KVITT_DATA_DIR" => Some("/tmp/kvitt".into()), "KVITT_BIND" => Some("127.0.0.1:9000".into()), "KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()), "KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()), "KVITT_WEB_DIR" => Some("/srv/kvitt-web".into()), _ => None, }) .unwrap(); assert_eq!( config.database_path(), PathBuf::from("/tmp/kvitt/kvitt.sqlite") ); assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap()); assert_eq!(config.public_url, "http://192.168.1.10:8080"); assert_eq!(config.trusted_proxy, "192.168.1.20"); assert_eq!(config.web_dir, PathBuf::from("/srv/kvitt-web")); } #[test] fn public_url_is_required() { for value in [None, Some(""), Some(" ")] { let error = Config::from_lookup(|k| { (k == "KVITT_PUBLIC_URL") .then_some(value) .flatten() .map(str::to_owned) }) .unwrap_err(); assert!( error.message.contains("KVITT_PUBLIC_URL"), "{}", error.message ); } } #[test] fn bad_bind_is_an_error() { let config = Config::from_lookup(|k| match k { "KVITT_PUBLIC_URL" => Some(PUBLIC_URL.into()), "KVITT_BIND" => Some("nope".into()), _ => None, }); assert!(config.is_err()); } }