//! Environment-driven configuration for the tavolo application. //! //! Mirrors `tavolo.config` from the Python implementation: read environment //! variables into a plain struct, with the same defaults and validation. use std::collections::BTreeSet; use regex::Regex; use tavolo_platform::oidc::OidcProviderConfig; const OIDC_VARIABLES: [&str; 7] = [ "PROVIDER", "ISSUER", "CLIENT_ID", "CLIENT_SECRET", "REDIRECT_URI", "POST_LOGIN_REDIRECT", "POST_LOGOUT_REDIRECT", ]; /// The pre-0.0.6 unindexed names, detected only to point the migration out. const LEGACY_OIDC_VARIABLES: [&str; 6] = [ "OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_POST_LOGIN_REDIRECT", "OIDC_POST_LOGOUT_REDIRECT", ]; const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo"; const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo"; fn env(name: &str) -> Option { std::env::var(name).ok().filter(|value| !value.is_empty()) } fn env_or(name: &str, default: &str) -> String { env(name).unwrap_or_else(|| default.to_string()) } fn env_list(name: &str) -> Option> { let value = std::env::var(name).ok()?; if value.trim().is_empty() { return None; } Some( value .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(str::to_string) .collect(), ) } fn env_bool(name: &str, default: bool) -> bool { match std::env::var(name) { Ok(value) if !value.is_empty() => { matches!( value.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on" ) } _ => default, } } fn parse_oidc_providers() -> Result, String> { let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES .iter() .copied() .filter(|name| env(name).is_some()) .collect(); if !legacy.is_empty() { let renamed: Vec = legacy .iter() .map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..])) .collect(); return Err(format!( "the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)", renamed.join(", ") )); } let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex"); let mut indices: BTreeSet = BTreeSet::new(); for (name, _) in std::env::vars() { let Some(captures) = pattern.captures(&name) else { continue; }; let variable = captures.get(2).expect("group 2").as_str(); if !OIDC_VARIABLES.contains(&variable) { return Err(format!( "unknown OIDC environment variable {name:?}; expected one of {}", OIDC_VARIABLES .iter() .map(|variable| format!("OIDC__{variable}")) .collect::>() .join(", ") )); } indices.insert( captures .get(1) .expect("group 1") .as_str() .parse() .map_err(|_| format!("invalid OIDC provider index in {name:?}"))?, ); } if indices.is_empty() { return Ok(vec![OidcProviderConfig { provider: "default".into(), issuer: DEFAULT_OIDC_ISSUER.into(), client_id: DEFAULT_OIDC_CLIENT_ID.into(), client_secret: None, redirect_uri: "http://localhost:8080/auth/default/callback".into(), post_login_redirect: "/".into(), post_logout_redirect: "/".into(), }]); } let max = *indices.iter().next_back().expect("non-empty"); let missing: Vec = (0..=max).filter(|index| !indices.contains(index)).collect(); if !missing.is_empty() { return Err(format!( "OIDC provider indices must be contiguous from 0; missing {}", missing .iter() .map(|index| format!("OIDC_{index}_*")) .collect::>() .join(", ") )); } let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex"); let mut seen = std::collections::HashSet::new(); let mut providers = Vec::new(); for index in indices { let prefix = format!("OIDC_{index}_"); let required = |variable: &str| -> Result { env(&format!("{prefix}{variable}")) .ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}")) }; let provider = required("PROVIDER")?; if !provider_name.is_match(&provider) { return Err(format!( "{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})" )); } if !seen.insert(provider.clone()) { return Err(format!("duplicate OIDC provider name {provider:?}")); } providers.push(OidcProviderConfig { issuer: required("ISSUER")?, client_id: required("CLIENT_ID")?, client_secret: env(&format!("{prefix}CLIENT_SECRET")), redirect_uri: env_or( &format!("{prefix}REDIRECT_URI"), &format!("http://localhost:8080/auth/{provider}/callback"), ), post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"), post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"), provider, }); } Ok(providers) } /// Percent-encode a DSN component like Python's `quote(value, safe="")`, /// which leaves the RFC 3986 unreserved characters alone. fn quote(value: &str) -> String { const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC .remove(b'_') .remove(b'-') .remove(b'.') .remove(b'~'); percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string() } fn database_url_from_parts( engine: &str, user: &str, password: Option<&str>, host: &str, port: &str, name: &str, options: &str, ) -> String { let mut netloc = quote(user); if let Some(password) = password { if !password.is_empty() { netloc.push(':'); netloc.push_str("e(password)); } } netloc.push('@'); netloc.push_str(host); if !port.is_empty() { netloc.push(':'); netloc.push_str(port); } let mut url = format!("{engine}://{netloc}/{name}"); let options = options.trim_start_matches('?'); if !options.is_empty() { url.push('?'); url.push_str(options); } url } #[derive(Debug, Clone)] pub struct Settings { pub database_url: String, pub oidc_providers: Vec, pub app_host: String, pub app_port: u16, pub redis_url: Option, pub game_ttl_seconds: i64, pub static_dir: String, pub hand_ack_timeout_seconds: i64, pub turn_timeout_seconds: i64, pub deadline_heartbeat_ms: u64, /// Accepted for compatibility; Rust logging is configured via RUST_LOG. pub logging_config: Option, pub cors_allow_origins: Option>, pub cors_allow_origin_regex: Option, pub cors_allow_methods: Option>, pub cors_allow_headers: Option>, pub cors_allow_credentials: bool, pub cors_expose_headers: Option>, pub cors_max_age: i64, pub otel_enabled: bool, pub otel_service_name: String, pub otel_exporter_endpoint: Option, pub otel_exporter_headers: Option>, pub otel_excluded_paths: Vec, pub otel_metric_export_interval_millis: u64, pub otel_metrics_poll_interval_seconds: u64, } impl Settings { pub fn from_env() -> Result { let database_url = env("DATABASE_URL").unwrap_or_else(|| { database_url_from_parts( &env_or("DATABASE_ENGINE", "postgres"), &env_or("DATABASE_USER", "tavolo"), Some(&env_or("DATABASE_PASSWORD", "password")), &env_or("DATABASE_HOST", "localhost"), &std::env::var("DATABASE_PORT").unwrap_or_default(), &env_or("DATABASE_NAME", "tavolo"), &std::env::var("DATABASE_OPTIONS").unwrap_or_default(), ) }); let app_port = env_or("APP_PORT", "8080") .parse::() .map_err(|_| "APP_PORT must be a valid port number".to_string())?; let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS"); Ok(Self { database_url, oidc_providers: parse_oidc_providers()?, app_host: env_or("APP_HOST", "0.0.0.0"), app_port, redis_url: env("REDIS_URL"), game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400") .parse() .map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?, static_dir: env_or("STATIC_DIR", "web/dist"), hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30") .parse() .map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?, turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30") .parse() .map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?, deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000") .parse() .map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?, logging_config: env("LOGGING_CONFIG"), cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"), cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"), cors_allow_methods: env_list("CORS_ALLOW_METHODS"), cors_allow_headers: env_list("CORS_ALLOW_HEADERS"), cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false), cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"), cors_max_age: env_or("CORS_MAX_AGE", "600") .parse() .map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?, otel_enabled: env_bool("OTEL_ENABLED", false), otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"), otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"), otel_exporter_headers: otel_headers, otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS") .unwrap_or_else(|| vec!["/api/health".to_string()]), otel_metric_export_interval_millis: env_or( "OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000", ) .parse() .map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?, otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15") .parse() .map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?, }) } }