use std::path::PathBuf; use anyhow::{Context, Result, anyhow}; /// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production /// we refuse to start with this value; otherwise we warn loudly. const DEV_JWT_SECRET_SENTINEL: &str = "dev_secret_do_not_use_in_production_32byteslong_aaaa"; /// A secret is "placeholder-ish" if it's the shipped dev sentinel or still carries /// the tell-tale scaffolding substrings from `.env.example`. Length alone is not /// enough — the shipped `change_me_...` placeholder is >32 chars. fn looks_placeholder(s: &str) -> bool { let lower = s.to_ascii_lowercase(); s == DEV_JWT_SECRET_SENTINEL || lower.contains("change_me") || lower.contains("dev_secret") || lower.contains("placeholder") } /// Enforce secret hygiene. In production every guard is hard-fail: a booting app /// with a publicly-known signing key is worse than one that refuses to start. /// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless. fn validate_secrets(is_prod: bool, jwt_secret: &str, admin_password_hash: &str) -> Result<()> { if is_prod { if looks_placeholder(jwt_secret) { return Err(anyhow!( "Refusing to start in production with a placeholder JWT_SECRET — \ rotate it (openssl rand -hex 64)." )); } if jwt_secret.len() < 32 { return Err(anyhow!("JWT_SECRET must be at least 32 characters.")); } if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) { return Err(anyhow!( "Refusing to start in production without a real ADMIN_PASSWORD_HASH — \ generate one (htpasswd -bnBC 12 '' | tr -d ':\\n')." )); } } else if jwt_secret == DEV_JWT_SECRET_SENTINEL { tracing::warn!( "JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this." ); } else if jwt_secret.len() < 32 { return Err(anyhow!("JWT_SECRET must be at least 32 characters.")); } Ok(()) } #[derive(Clone, Debug)] pub struct AppConfig { pub database_url: String, pub jwt_secret: String, pub session_expiry_days: i64, pub admin_password_hash: String, pub event_name: String, pub event_slug: String, pub media_path: PathBuf, /// Where export archives are written. MUST be outside `media_path` — that /// directory is served by a public `ServeDir`, so a predictable archive name /// under it would leak the whole gallery to anonymous visitors. pub export_path: PathBuf, pub app_port: u16, /// Number of concurrent media compression workers (read once at boot). pub compression_concurrency: usize, /// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true). /// When false the backend rejects new comments and the frontend hides the whole /// comment UI. Existing comments stay in the DB (hidden), so flipping it back /// restores them. Boot-time immutable, like `compression_concurrency`. pub comments_enabled: bool, /// Default colour theme, used as the fallback when the DB config keys are unset. /// Runtime overrides live in the `config` table (admin UI); these env vars only /// seed the initial default. `preset` is an id the frontend knows (e.g. /// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand + /// accent colours the whole palette is derived from. pub default_theme_preset: String, pub default_theme_primary: String, pub default_theme_accent: String, } /// The shipped default brand/accent seed (champagne gold — matches the hand-tuned /// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look. const DEFAULT_THEME_SEED: &str = "#8a6a2b"; impl AppConfig { pub fn from_env() -> Result { let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); let is_prod = app_env.eq_ignore_ascii_case("production"); let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?; let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default(); validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?; Ok(Self { database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?, jwt_secret, session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS") .unwrap_or_else(|_| "30".to_string()) .parse() .context("SESSION_EXPIRY_DAYS must be a number")?, admin_password_hash, event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()), event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?, media_path: PathBuf::from( std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()), ), export_path: PathBuf::from( std::env::var("EXPORT_PATH").unwrap_or_else(|_| "/exports".to_string()), ), app_port: std::env::var("APP_PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() .context("APP_PORT must be a number")?, compression_concurrency: std::env::var("COMPRESSION_WORKER_CONCURRENCY") .ok() .and_then(|v| v.parse().ok()) .filter(|&n| n >= 1) .unwrap_or(2), comments_enabled: std::env::var("COMMENTS_ENABLED") .map(|v| { !matches!( v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off" ) }) .unwrap_or(true), default_theme_preset: std::env::var("THEME_PRESET") .unwrap_or_else(|_| "champagne-gold".to_string()), default_theme_primary: std::env::var("THEME_PRIMARY") .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()), default_theme_accent: std::env::var("THEME_ACCENT") .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()), }) } } #[cfg(test)] mod tests { use super::*; const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2"; const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012"; #[test] fn prod_rejects_shipped_placeholder_secret() { // The exact string shipped in `.env` — >32 chars, so it must be caught by // the substring guard, not the length check. let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH); assert!( err.is_err(), "placeholder JWT_SECRET must be rejected in prod" ); } #[test] fn prod_rejects_dev_sentinel_and_short_secret() { assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH).is_err()); assert!(validate_secrets(true, "tooshort", REAL_HASH).is_err()); } #[test] fn prod_rejects_missing_or_placeholder_admin_hash() { assert!(validate_secrets(true, REAL_SECRET, "").is_err()); assert!(validate_secrets(true, REAL_SECRET, "$2y$12$placeholder_replace_me").is_err()); } #[test] fn prod_accepts_real_secrets() { assert!(validate_secrets(true, REAL_SECRET, REAL_HASH).is_ok()); } #[test] fn non_prod_tolerates_dev_sentinel() { assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "").is_ok()); } #[test] fn non_prod_still_rejects_short_non_sentinel_secret() { assert!(validate_secrets(false, "tooshort", "").is_err()); } #[test] fn placeholder_detection_is_case_insensitive() { // looks_placeholder lowercases before matching — an upper/mixed-case // placeholder must still be rejected in prod. assert!( validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err() ); assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err()); } #[test] fn prod_len_boundary_at_32() { // Exactly 32 non-placeholder chars is the minimum accepted; 31 is rejected. const LEN_32: &str = "abcdefghijklmnopqrstuvwxyz012345"; const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234"; assert_eq!(LEN_32.len(), 32); assert_eq!(LEN_31.len(), 31); assert!(validate_secrets(true, LEN_32, REAL_HASH).is_ok()); assert!(validate_secrets(true, LEN_31, REAL_HASH).is_err()); } }