//! Application configuration loaded from environment variables. //! //! All required variables are validated at startup. The application refuses //! to start with invalid configuration rather than failing at runtime. use std::env; use std::sync::Arc; /// Typed application configuration. #[derive(Debug, Clone)] pub struct AppConfig { // Database pub database_url: String, // Security pub master_encryption_key: Arc, // Application pub app_url: String, pub port: u16, pub static_dir: String, // Email (Resend) pub resend_api_key: String, pub email_from: String, // Captcha (Cloudflare Turnstile) pub turnstile_secret_key: String, pub turnstile_site_key: String, // Generation pub generation_timeout_secs: u64, } impl AppConfig { /// Load configuration from environment variables. /// /// Uses `dotenvy` (called before this) to load `.env` if present. /// Fails fast with a clear error message if a required variable is missing. pub fn from_env() -> Result { let database_url = required_var("DATABASE_URL")?; let master_encryption_key = required_var("MASTER_ENCRYPTION_KEY")?; let app_url = required_var("APP_URL")?; let resend_api_key = required_var("RESEND_API_KEY")?; let email_from = required_var("EMAIL_FROM")?; let turnstile_secret_key = required_var("TURNSTILE_SECRET_KEY")?; let turnstile_site_key = required_var("TURNSTILE_SITE_KEY")?; let port = env::var("PORT") .unwrap_or_else(|_| "8080".to_string()) .parse::() .map_err(|_| "PORT must be a valid u16".to_string())?; let static_dir = env::var("STATIC_DIR") .unwrap_or_else(|_| "../frontend/dist".to_string()); let generation_timeout_secs = env::var("GENERATION_TIMEOUT_MINUTES") .unwrap_or_else(|_| "30".to_string()) .parse::() .map_err(|_| "GENERATION_TIMEOUT_MINUTES must be a valid positive integer".to_string())? * 60; Ok(Self { database_url, master_encryption_key: Arc::new(master_encryption_key), app_url, port, static_dir, resend_api_key, email_from, turnstile_secret_key, turnstile_site_key, generation_timeout_secs, }) } /// Validate that configuration values meet minimum requirements. /// /// Called after loading to ensure secrets are properly formatted /// and the APP_URL has a valid scheme. pub fn validate(&self) -> Result<(), String> { if self.master_encryption_key.len() != 64 || !self.master_encryption_key.chars().all(|c| c.is_ascii_hexdigit()) { return Err( "MASTER_ENCRYPTION_KEY must be exactly 64 hex characters (256-bit key)".into(), ); } if !self.app_url.starts_with("http://") && !self.app_url.starts_with("https://") { return Err("APP_URL must start with http:// or https://".into()); } if self.app_url.ends_with('/') { return Err("APP_URL must not end with a trailing slash".into()); } Ok(()) } /// Returns true if the application is running in a secure (HTTPS) context. pub fn is_secure(&self) -> bool { self.app_url.starts_with("https://") } } /// Read a required environment variable, returning a clear error if missing. fn required_var(name: &str) -> Result { env::var(name).map_err(|_| format!("Missing required environment variable: {name}")) } #[cfg(test)] mod tests { use super::*; #[test] fn test_validate_valid_config() { let config = AppConfig { database_url: "postgres://user:pass@localhost/db".into(), master_encryption_key: Arc::new("ab".repeat(32)), app_url: "https://synth.example.com".into(), port: 8080, static_dir: "./static".into(), resend_api_key: "re_test".into(), email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), generation_timeout_secs: 1800, }; assert!(config.validate().is_ok()); } #[test] fn test_validate_bad_encryption_key_length() { let config = AppConfig { database_url: "postgres://user:pass@localhost/db".into(), master_encryption_key: Arc::new("abcd".into()), // too short app_url: "https://synth.example.com".into(), port: 8080, static_dir: "./static".into(), resend_api_key: "re_test".into(), email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("MASTER_ENCRYPTION_KEY")); } #[test] fn test_validate_bad_encryption_key_not_hex() { let config = AppConfig { database_url: "postgres://user:pass@localhost/db".into(), master_encryption_key: Arc::new("zz".repeat(32)), // not hex app_url: "https://synth.example.com".into(), port: 8080, static_dir: "./static".into(), resend_api_key: "re_test".into(), email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("MASTER_ENCRYPTION_KEY")); } #[test] fn test_validate_bad_app_url_scheme() { let config = AppConfig { database_url: "postgres://user:pass@localhost/db".into(), master_encryption_key: Arc::new("ab".repeat(32)), app_url: "ftp://synth.example.com".into(), port: 8080, static_dir: "./static".into(), resend_api_key: "re_test".into(), email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("APP_URL")); } #[test] fn test_validate_trailing_slash() { let config = AppConfig { database_url: "postgres://user:pass@localhost/db".into(), master_encryption_key: Arc::new("ab".repeat(32)), app_url: "https://synth.example.com/".into(), port: 8080, static_dir: "./static".into(), resend_api_key: "re_test".into(), email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), generation_timeout_secs: 1800, }; let err = config.validate().unwrap_err(); assert!(err.contains("trailing slash")); } #[test] fn test_is_secure() { let config = AppConfig { database_url: "postgres://user:pass@localhost/db".into(), master_encryption_key: Arc::new("ab".repeat(32)), app_url: "https://synth.example.com".into(), port: 8080, static_dir: "./static".into(), resend_api_key: "re_test".into(), email_from: "test@example.com".into(), turnstile_secret_key: "0x4AAA".into(), turnstile_site_key: "0x4BBB".into(), generation_timeout_secs: 1800, }; assert!(config.is_secure()); let config_http = AppConfig { app_url: "http://localhost:3000".into(), ..config }; assert!(!config_http.is_secure()); } }