use std::collections::HashMap; use std::path::PathBuf; use serde::Deserialize; /// Per-user Whisper transcription settings from users.toml. /// All fields optional — missing `[user.whisper]` block yields defaults (None). #[derive(Debug, Default, Clone, Deserialize)] pub struct WhisperUserSettings { pub language: Option, pub hotwords: Option, pub initial_prompt: Option, } /// A single user entry from users.toml. #[derive(Debug, Deserialize)] pub struct User { pub slug: String, pub api_key: String, pub web_password: String, pub role: String, #[serde(default)] pub whisper: WhisperUserSettings, } /// Wrapper for deserializing the [[user]] array from TOML. #[derive(Deserialize)] struct UsersFile { user: Vec, } pub struct Config { // Phase 1 — required pub server_port: u16, pub data_path: PathBuf, pub log_level: String, pub log_path: PathBuf, pub log_max_days: u32, pub users: Vec, pub api_keys: HashMap, // api_key_value → slug // Phase 2+ — optional with defaults pub retention_audio_days: u32, pub retention_transcript_days: u32, pub retention_document_days: u32, pub whisper_url: String, pub whisper_timeout_seconds: u64, pub ollama_url: String, pub ollama_model: String, pub ollama_keep_alive: u32, pub llm_url: String, pub llm_api_key: String, pub llm_model: String, pub llm_temperature: f32, pub llm_timeout_seconds: u64, pub session_timeout_hours: u32, /// Whether to set the `Secure` flag on the session cookie. Default `true`. /// Set `COOKIE_SECURE=false` only for plain-HTTP local development — /// browsers refuse `Secure` cookies on non-HTTPS origins. pub cookie_secure: bool, } impl Config { pub fn from_env() -> Self { let users_file = required_env("USERS_FILE"); let (users, api_keys) = load_users(&users_file); Self { server_port: required_env_parsed("SERVER_PORT"), data_path: PathBuf::from(required_env("DATA_PATH")), log_level: optional_env("LOG_LEVEL", "info"), log_path: PathBuf::from(optional_env("LOG_PATH", "/var/log/recorder")), log_max_days: optional_env_parsed("LOG_MAX_DAYS", 90), users, api_keys, retention_audio_days: optional_env_parsed("RETENTION_AUDIO_DAYS", 30), retention_transcript_days: optional_env_parsed("RETENTION_TRANSCRIPT_DAYS", 30), retention_document_days: optional_env_parsed("RETENTION_DOCUMENT_DAYS", 0), whisper_url: optional_env("WHISPER_URL", "http://localhost:10300"), whisper_timeout_seconds: optional_env_parsed("WHISPER_TIMEOUT_SECONDS", 120), ollama_url: optional_env("OLLAMA_URL", "http://localhost:11434"), ollama_model: optional_env("OLLAMA_MODEL", "gemma3:4b"), ollama_keep_alive: optional_env_parsed("OLLAMA_KEEP_ALIVE", 0), llm_url: optional_env("LLM_URL", ""), llm_api_key: optional_env("LLM_API_KEY", ""), llm_model: optional_env("LLM_MODEL", ""), llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0), llm_timeout_seconds: optional_env_parsed("LLM_TIMEOUT_SECONDS", 180), session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8), cookie_secure: optional_env_parsed("COOKIE_SECURE", true), } } /// True iff all three required fields for the external analysis LLM are /// non-empty. Used to gate the "Fall abschließen" UI and handler — if /// no LLM is configured, the close action must not be reachable. pub fn llm_configured(&self) -> bool { !self.llm_url.is_empty() && !self.llm_api_key.is_empty() && !self.llm_model.is_empty() } /// Sane defaults for integration tests. Not a `Default` impl on purpose: /// production code must go through `from_env()`, and `Default::default()` /// carries an implicit "safe fallback" connotation this value does not /// satisfy (empty users, `/tmp` paths). Override fields via /// struct-update syntax: /// /// ```ignore /// let cfg = Config { /// data_path: tmp_dir, /// users: vec![my_user], /// ..Config::test_default() /// }; /// ``` #[doc(hidden)] pub fn test_default() -> Self { Self { server_port: 3000, data_path: PathBuf::from("/tmp"), log_level: "info".into(), log_path: PathBuf::from("/tmp"), log_max_days: 90, users: Vec::new(), api_keys: HashMap::new(), retention_audio_days: 30, retention_transcript_days: 30, retention_document_days: 0, whisper_url: "http://localhost:10300".into(), whisper_timeout_seconds: 120, ollama_url: "http://localhost:11434".into(), ollama_model: "gemma3:4b".into(), ollama_keep_alive: 0, llm_url: String::new(), llm_api_key: String::new(), llm_model: String::new(), llm_temperature: 0.0, llm_timeout_seconds: 180, session_timeout_hours: 8, cookie_secure: false, } } } /// Load users from a TOML file and build the API key lookup map. fn load_users(path: &str) -> (Vec, HashMap) { let content = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("Failed to read users file {path}: {e}")); let parsed: UsersFile = toml::from_str(&content) .unwrap_or_else(|e| panic!("Failed to parse users file {path}: {e}")); if parsed.user.is_empty() { panic!("No users defined in {path}. At least one is required."); } let api_keys = validate_and_index_users(&parsed.user) .unwrap_or_else(|e| panic!("Invalid users file {path}: {e}")); (parsed.user, api_keys) } /// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on /// data directories) and duplicate api_keys (ambiguous authentication). pub fn validate_and_index_users(users: &[User]) -> Result, String> { let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut api_keys: HashMap = HashMap::new(); for u in users { if !slugs.insert(u.slug.as_str()) { return Err(format!("duplicate slug: {}", u.slug)); } if let Some(existing) = api_keys.insert(u.api_key.clone(), u.slug.clone()) { return Err(format!( "duplicate api_key shared by users {existing} and {}", u.slug )); } } Ok(api_keys) } /// Read a required environment variable. Panics with a clear message if missing. fn required_env(name: &str) -> String { std::env::var(name) .unwrap_or_else(|_| panic!("Missing required environment variable: {name}")) } /// Read a required environment variable and parse it. Panics if missing or unparseable. fn required_env_parsed(name: &str) -> T where T::Err: std::fmt::Display, { let raw = required_env(name); raw.parse() .unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")) } /// Read an optional environment variable, falling back to a default. fn optional_env(name: &str, default: &str) -> String { std::env::var(name).unwrap_or_else(|_| default.to_owned()) } /// Read an optional environment variable and parse it, falling back to a default. fn optional_env_parsed(name: &str, default: T) -> T where T::Err: std::fmt::Display, { match std::env::var(name) { Ok(raw) => raw .parse() .unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")), Err(_) => default, } } #[cfg(test)] mod tests { use super::*; fn u(slug: &str, api_key: &str) -> User { User { slug: slug.into(), api_key: api_key.into(), web_password: String::new(), role: "doctor".into(), whisper: WhisperUserSettings::default(), } } #[test] fn parses_user_without_whisper_block() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let u = &parsed.user[0]; assert!(u.whisper.language.is_none()); assert!(u.whisper.hotwords.is_none()); assert!(u.whisper.initial_prompt.is_none()); } #[test] fn parses_user_with_full_whisper_block() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" [user.whisper] language = "de" hotwords = "HOCM Valsalva" initial_prompt = "Kardiologie" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let w = &parsed.user[0].whisper; assert_eq!(w.language.as_deref(), Some("de")); assert_eq!(w.hotwords.as_deref(), Some("HOCM Valsalva")); assert_eq!(w.initial_prompt.as_deref(), Some("Kardiologie")); } #[test] fn accepts_admin_role() { let toml_str = r#" [[user]] slug = "root" api_key = "k1" web_password = "" role = "admin" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.user[0].role, "admin"); } #[test] fn accepts_distinct_users() { let users = vec![u("a", "k1"), u("b", "k2")]; let map = validate_and_index_users(&users).unwrap(); assert_eq!(map.len(), 2); assert_eq!(map.get("k1").unwrap(), "a"); } #[test] fn rejects_duplicate_api_keys() { let users = vec![u("a", "same"), u("b", "same")]; let err = validate_and_index_users(&users).unwrap_err(); assert!(err.contains("duplicate api_key"), "got: {err}"); } #[test] fn rejects_duplicate_slugs() { let users = vec![u("same", "k1"), u("same", "k2")]; let err = validate_and_index_users(&users).unwrap_err(); assert!(err.contains("duplicate slug"), "got: {err}"); } }