use std::env; use std::net::SocketAddr; use crate::db::DatabaseBackend; #[derive(Clone, Debug)] pub struct Config { pub host: String, pub port: u16, pub database_url: String, pub database_backend: DatabaseBackend, pub public_url: String, pub session_secret: String, pub jetstream_url: String, pub relay_url: String, pub plc_url: String, pub static_dir: String, pub base_path: Option, pub event_log_retention_days: u32, pub app_name: Option, pub logo_uri: Option, pub tos_uri: Option, pub policy_uri: Option, pub token_encryption_key: Option<[u8; 32]>, pub default_rate_limit_capacity: u32, pub default_rate_limit_refill_rate: f64, } impl Config { pub fn from_env() -> Self { let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_backend = env::var("DATABASE_BACKEND") .ok() .and_then(|s| DatabaseBackend::from_str(&s)) .unwrap_or_else(|| DatabaseBackend::from_url(&database_url)); Self { host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), port: env::var("PORT") .ok() .and_then(|p| p.parse().ok()) .unwrap_or(3000), database_url, database_backend, public_url: env::var("PUBLIC_URL").expect("PUBLIC_URL must be set"), session_secret: env::var("SESSION_SECRET") .unwrap_or_else(|_| "change-me-in-production-not-secure".into()), jetstream_url: env::var("JETSTREAM_URL") .unwrap_or_else(|_| "wss://jetstream1.us-east.bsky.network".into()), relay_url: env::var("RELAY_URL").unwrap_or_else(|_| "https://bsky.network".into()), plc_url: env::var("PLC_URL").unwrap_or_else(|_| "https://plc.directory".into()), static_dir: env::var("STATIC_DIR").unwrap_or_else(|_| "./web/out".into()), base_path: env::var("BASE_PATH").ok().and_then(|s| { let s = s.trim_end_matches('/').to_string(); if s.is_empty() { None } else if !s.starts_with('/') { panic!("BASE_PATH must start with '/' (got: {s})"); } else { Some(s) } }), event_log_retention_days: std::env::var("EVENT_LOG_RETENTION_DAYS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(30), app_name: env::var("APP_NAME").ok(), logo_uri: env::var("LOGO_URI").ok(), tos_uri: env::var("TOS_URI").ok(), policy_uri: env::var("POLICY_URI").ok(), token_encryption_key: env::var("TOKEN_ENCRYPTION_KEY").ok().and_then(|s| { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(&s) .ok() .and_then(|bytes| bytes.try_into().ok()) }), default_rate_limit_capacity: env::var("DEFAULT_RATE_LIMIT_CAPACITY") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(100), default_rate_limit_refill_rate: env::var("DEFAULT_RATE_LIMIT_REFILL_RATE") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(2.0), } } pub fn listen_addr(&self) -> SocketAddr { format!("{}:{}", self.host, self.port) .parse() .expect("invalid HOST/PORT") } pub fn effective_public_url(&self) -> String { match &self.base_path { Some(bp) => format!("{}{}", self.public_url.trim_end_matches('/'), bp), None => self.public_url.clone(), } } pub fn url_with_base_path(&self, domain_url: &str) -> String { match &self.base_path { Some(bp) => format!("{}{}", domain_url.trim_end_matches('/'), bp), None => domain_url.to_string(), } } } #[cfg(test)] mod tests { use super::*; use serial_test::serial; unsafe fn clear_env() { for key in [ "HOST", "PORT", "DATABASE_URL", "DATABASE_BACKEND", "PUBLIC_URL", "SESSION_SECRET", "JETSTREAM_URL", "RELAY_URL", "PLC_URL", "EVENT_LOG_RETENTION_DAYS", "APP_NAME", "LOGO_URI", "TOS_URI", "POLICY_URI", "BASE_PATH", ] { unsafe { env::remove_var(key); } } } unsafe fn set_required_env() { unsafe { env::set_var("DATABASE_URL", "postgres://localhost/test"); env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } } #[test] fn listen_addr_combines_host_and_port() { let config = Config { host: "127.0.0.1".into(), port: 8080, database_url: String::new(), database_backend: DatabaseBackend::Postgres, public_url: String::new(), session_secret: String::new(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; assert_eq!( config.listen_addr(), "127.0.0.1:8080".parse::().unwrap() ); } #[test] #[serial] fn from_env_reads_required_vars() { unsafe { clear_env(); set_required_env(); } let config = Config::from_env(); assert_eq!(config.database_url, "postgres://localhost/test"); assert_eq!(config.public_url, "http://127.0.0.1:3000"); } #[test] #[serial] fn from_env_applies_defaults() { unsafe { clear_env(); set_required_env(); } let config = Config::from_env(); assert_eq!(config.host, "0.0.0.0"); assert_eq!(config.port, 3000); assert_eq!( config.jetstream_url, "wss://jetstream1.us-east.bsky.network" ); assert_eq!(config.relay_url, "https://bsky.network"); assert_eq!(config.plc_url, "https://plc.directory"); } #[test] #[serial] fn from_env_reads_optional_overrides() { unsafe { clear_env(); set_required_env(); env::set_var("HOST", "10.0.0.1"); env::set_var("PORT", "9090"); env::set_var("RELAY_URL", "https://relay.example.com"); env::set_var("PLC_URL", "https://plc.example.com"); } let config = Config::from_env(); assert_eq!(config.host, "10.0.0.1"); assert_eq!(config.port, 9090); assert_eq!(config.relay_url, "https://relay.example.com"); assert_eq!(config.plc_url, "https://plc.example.com"); } #[test] #[serial] #[should_panic(expected = "DATABASE_URL must be set")] fn from_env_panics_without_database_url() { unsafe { clear_env(); env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } Config::from_env(); } #[test] #[serial] #[should_panic(expected = "PUBLIC_URL must be set")] fn from_env_panics_without_public_url() { unsafe { clear_env(); env::set_var("DATABASE_URL", "postgres://localhost/test"); } Config::from_env(); } #[test] #[serial] fn default_event_log_retention_days() { unsafe { clear_env(); set_required_env(); } let config = Config::from_env(); assert_eq!(config.event_log_retention_days, 30); } #[test] #[serial] fn custom_event_log_retention_days() { unsafe { clear_env(); set_required_env(); env::set_var("EVENT_LOG_RETENTION_DAYS", "7"); } let config = Config::from_env(); assert_eq!(config.event_log_retention_days, 7); } #[test] #[serial] fn zero_event_log_retention_days_disables_cleanup() { unsafe { clear_env(); set_required_env(); env::set_var("EVENT_LOG_RETENTION_DAYS", "0"); } let config = Config::from_env(); assert_eq!(config.event_log_retention_days, 0); } #[test] #[serial] fn database_backend_detected_from_url() { unsafe { clear_env(); env::set_var("DATABASE_URL", "postgres://localhost/test"); env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Postgres); } #[test] #[serial] fn database_backend_sqlite_detected_from_url() { unsafe { clear_env(); env::set_var("DATABASE_URL", "sqlite://data/happyview.db?mode=rwc"); env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); } #[test] #[serial] fn database_backend_override_from_env() { unsafe { clear_env(); env::set_var("DATABASE_URL", "postgres://localhost/test"); env::set_var("DATABASE_BACKEND", "sqlite"); env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); } #[test] #[serial] fn from_env_base_path_none_by_default() { unsafe { clear_env(); set_required_env(); } let config = Config::from_env(); assert!(config.base_path.is_none()); } #[test] #[serial] fn from_env_base_path_read_from_env() { unsafe { clear_env(); set_required_env(); env::set_var("BASE_PATH", "/hv"); } let config = Config::from_env(); assert_eq!(config.base_path.as_deref(), Some("/hv")); } #[test] #[serial] fn from_env_base_path_strips_trailing_slash() { unsafe { clear_env(); set_required_env(); env::set_var("BASE_PATH", "/hv/"); } let config = Config::from_env(); assert_eq!(config.base_path.as_deref(), Some("/hv")); } #[test] #[serial] fn from_env_base_path_empty_becomes_none() { unsafe { clear_env(); set_required_env(); env::set_var("BASE_PATH", ""); } let config = Config::from_env(); assert!(config.base_path.is_none()); } #[test] #[serial] fn from_env_base_path_slash_only_becomes_none() { unsafe { clear_env(); set_required_env(); env::set_var("BASE_PATH", "/"); } let config = Config::from_env(); assert!(config.base_path.is_none()); } #[test] #[serial] #[should_panic(expected = "BASE_PATH must start with '/'")] fn from_env_base_path_without_leading_slash_panics() { unsafe { clear_env(); set_required_env(); env::set_var("BASE_PATH", "hv"); } Config::from_env(); } #[test] fn effective_public_url_without_base_path() { let config = Config { host: String::new(), port: 3000, database_url: String::new(), database_backend: DatabaseBackend::Postgres, public_url: "https://example.com".into(), session_secret: String::new(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; assert_eq!(config.effective_public_url(), "https://example.com"); } #[test] fn effective_public_url_with_base_path() { let config = Config { host: String::new(), port: 3000, database_url: String::new(), database_backend: DatabaseBackend::Postgres, public_url: "https://example.com".into(), session_secret: String::new(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: Some("/hv".into()), event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; assert_eq!(config.effective_public_url(), "https://example.com/hv"); } #[test] fn effective_public_url_trims_trailing_slash() { let config = Config { host: String::new(), port: 3000, database_url: String::new(), database_backend: DatabaseBackend::Postgres, public_url: "https://example.com/".into(), session_secret: String::new(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: Some("/hv".into()), event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; assert_eq!(config.effective_public_url(), "https://example.com/hv"); } #[test] fn url_with_base_path_appends() { let config = Config { host: String::new(), port: 3000, database_url: String::new(), database_backend: DatabaseBackend::Postgres, public_url: String::new(), session_secret: String::new(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: Some("/hv".into()), event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; assert_eq!( config.url_with_base_path("https://otherdomain.com"), "https://otherdomain.com/hv" ); } #[test] fn url_with_base_path_without_base_path() { let config = Config { host: String::new(), port: 3000, database_url: String::new(), database_backend: DatabaseBackend::Postgres, public_url: String::new(), session_secret: String::new(), jetstream_url: String::new(), relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, tos_uri: None, policy_uri: None, token_encryption_key: None, default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, }; assert_eq!( config.url_with_base_path("https://otherdomain.com"), "https://otherdomain.com" ); } }