use std::path::PathBuf; const MAX_TTL_MS: i64 = 48 * 60 * 60 * 1000; // 48 hours hard maximum #[derive(Clone, Debug)] pub struct Config { pub host: String, pub port: u16, pub storage_dir: PathBuf, pub static_dir: PathBuf, pub max_file_size: u64, pub file_ttl_ms: i64, pub max_storage_usage: f64, pub key_length: usize, pub public_base_url: Option, pub rate_limit_enabled: bool, pub rate_limit_max: usize, pub rate_limit_window_ms: u64, pub trust_proxy: bool, } pub fn parse_size(value: &str) -> Result { let s = value.trim(); let split = s.find(|c: char| !c.is_ascii_digit() && c != '.'); let (num_part, unit_part) = match split { Some(i) => (&s[..i], &s[i..]), None => (s, ""), }; let num: f64 = num_part .trim() .parse() .map_err(|_| format!("Invalid size value: \"{value}\""))?; let unit = unit_part.trim().to_uppercase(); let factor: f64 = match unit.as_str() { "B" | "" => 1.0, "K" | "KB" | "KIB" => 1024.0, "M" | "MB" | "MIB" => 1024.0f64.powi(2), "G" | "GB" | "GIB" => 1024.0f64.powi(3), "T" | "TB" | "TIB" => 1024.0f64.powi(4), _ => return Err(format!("Invalid size unit in: \"{value}\"")), }; Ok((num * factor) as u64) } fn env(name: &str) -> Option { std::env::var(name).ok().filter(|v| !v.is_empty()) } fn bool_from_env(value: Option<&str>, fallback: bool) -> bool { match value { Some(v) => !["0", "false", "no", "off"].contains(&v.trim().to_lowercase().as_str()), None => fallback, } } pub fn load() -> Result { let port = env("PORT") .unwrap_or_else(|| "3000".to_string()) .parse::() .map_err(|_| format!("Invalid PORT: {:?}", env("PORT")))?; if port == 0 { return Err("Invalid PORT".to_string()); } let host = env("HOST").unwrap_or_else(|| "0.0.0.0".to_string()); let storage_dir = PathBuf::from(env("STORAGE_DIR").unwrap_or_else(|| "./storage".to_string())); let static_dir = PathBuf::from(env("STATIC_DIR").unwrap_or_else(|| "./frontend/dist".to_string())); let max_file_size = parse_size( &env("MAX_FILE_SIZE").unwrap_or_else(|| "10GB".to_string()), )?; if max_file_size < 1024 { return Err("MAX_FILE_SIZE must be at least 1KB".to_string()); } let file_ttl_ms_raw = env("FILE_TTL_MS") .unwrap_or_else(|| MAX_TTL_MS.to_string()) .parse::() .map_err(|_| "Invalid FILE_TTL_MS".to_string())?; if file_ttl_ms_raw < 1 { return Err("Invalid FILE_TTL_MS".to_string()); } let file_ttl_ms = file_ttl_ms_raw.min(MAX_TTL_MS); let max_storage_usage = env("MAX_STORAGE_USAGE") .unwrap_or_else(|| "0.5".to_string()) .parse::() .map_err(|_| "Invalid MAX_STORAGE_USAGE".to_string())?; if !(0.0..=1.0).contains(&max_storage_usage) { return Err("Invalid MAX_STORAGE_USAGE".to_string()); } let key_length = env("KEY_LENGTH") .unwrap_or_else(|| "8".to_string()) .parse::() .map_err(|_| "Invalid KEY_LENGTH".to_string())?; if !(4..=64).contains(&key_length) { return Err("Invalid KEY_LENGTH".to_string()); } let rate_limit_max = env("RATE_LIMIT_MAX") .unwrap_or_else(|| "300".to_string()) .parse::() .map_err(|_| "Invalid RATE_LIMIT_MAX".to_string())?; if rate_limit_max < 1 { return Err("Invalid RATE_LIMIT_MAX".to_string()); } let rate_limit_window_ms = env("RATE_LIMIT_WINDOW_MS") .unwrap_or_else(|| "60000".to_string()) .parse::() .map_err(|_| "Invalid RATE_LIMIT_WINDOW_MS".to_string())?; if rate_limit_window_ms < 1000 { return Err("Invalid RATE_LIMIT_WINDOW_MS".to_string()); } let trust_proxy = bool_from_env(env("TRUST_PROXY").as_deref(), false); let rate_limit_enabled = bool_from_env(env("RATE_LIMIT_ENABLED").as_deref(), true); let public_base_url = match env("PUBLIC_BASE_URL") { Some(raw) => { let normalized = raw.trim_end_matches('/').to_string(); let valid = (normalized.starts_with("http://") || normalized.starts_with("https://")) && !normalized[normalized.find("://").map(|i| i + 3).unwrap_or(0)..] .contains('/') && !normalized.chars().any(char::is_whitespace); if !valid { return Err(format!("Invalid PUBLIC_BASE_URL: \"{raw}\"")); } Some(normalized) } None => None, }; Ok(Config { host, port, storage_dir, static_dir, max_file_size, file_ttl_ms, max_storage_usage, key_length, public_base_url, rate_limit_enabled, rate_limit_max, rate_limit_window_ms, trust_proxy, }) }