use lettre::message::Mailbox; use std::{collections::HashMap, env, net::IpAddr, path::Path}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthorizationType { Local, Ldap, Ad, } impl AuthorizationType { fn from_values(values: &ConfigValues) -> Result> { match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() { "local" => Ok(Self::Local), "ldap" => Ok(Self::Ldap), "ad" => Ok(Self::Ad), _ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()), } } pub fn as_str(self) -> &'static str { match self { Self::Local => "local", Self::Ldap => "ldap", Self::Ad => "ad", } } } #[derive(Debug, Clone)] pub struct Config { pub host: IpAddr, pub port: u16, pub database_url: String, pub database_max_connections: u32, pub static_dir: String, pub files_dir: String, pub storage: crate::storage::StorageConfig, pub upload_max_size_bytes: usize, pub asset_version: String, pub asset_cache_max_age_seconds: u64, pub file_cache_max_age_seconds: u64, pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, pub share_confirmation_required: bool, pub frontend_log_level: String, pub anonymous_access_token_ttl_days: i64, pub user_session_ttl_days: i64, pub unconfirmed_account_ttl_days: i64, pub authorization_type: AuthorizationType, pub ldap: Option, } impl Config { pub fn load(path: Option<&Path>) -> Result> { let values = ConfigValues::load(path)?; let host = values.get("APP_HOST", "127.0.0.1").parse()?; let port = values.get("APP_PORT", "3000").parse()?; let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?; let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?; let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?; let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; let files_dir = values.get("FILES_DIR", "data/files"); let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() { "local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() }, "s3" => crate::storage::StorageConfig::S3 { endpoint: values.optional("S3_ENDPOINT"), region: values.get("S3_REGION", "us-east-1"), bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?, access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?, secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?, force_path_style: values.bool("S3_FORCE_PATH_STYLE", false)?, }, _ => return Err("STORAGE_DRIVER must be local or s3".into()), }; if upload_max_size_mb == 0 { return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); } let authorization_type = AuthorizationType::from_values(&values)?; let ldap = match authorization_type { AuthorizationType::Local => None, AuthorizationType::Ldap | AuthorizationType::Ad => { let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str()); let (default_filter, default_username_attribute) = match authorization_type { AuthorizationType::Ldap => ("(uid={username})", "uid"), AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"), AuthorizationType::Local => unreachable!(), }; Some(crate::auth::ldap::LdapConfig { url: values.required("LDAP_URL", &context)?, starttls: values.bool("LDAP_STARTTLS", false)?, bind_dn: values.get("LDAP_BIND_DN", ""), bind_password: values.get("LDAP_BIND_PASSWORD", ""), base_dn: values.required("LDAP_BASE_DN", &context)?, user_filter: values.get("LDAP_USER_FILTER", default_filter), username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute), email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"), display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"), external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type { AuthorizationType::Ldap => "entryUUID", AuthorizationType::Ad => "objectGUID", AuthorizationType::Local => unreachable!(), }), organization: values.get("LDAP_ORGANIZATION", "organization"), provider: authorization_type.as_str().to_owned(), email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?, link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?, tls_verify: values.bool("LDAP_TLS_VERIFY", true)?, connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?, }) } }; let smtp = if let Some(host) = values.optional("SMTP_HOST") { Some(crate::state::SmtpConfig { host, port: values.get("SMTP_PORT", "587").parse()?, username: values.get("SMTP_USERNAME", ""), password: values.get("SMTP_PASSWORD", ""), from: normalize_smtp_from( values.required("SMTP_FROM", "SMTP_HOST is set")?, )?, public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?, }) } else { None }; let config = Self { host, port, database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), database_max_connections, static_dir: values.get("STATIC_DIR", "static"), files_dir, storage, upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?, asset_version: env!("CARGO_PKG_VERSION").to_owned(), asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, smtp, registration_enabled: values.bool("REGISTRATION_ENABLED", false)?, account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?, frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?, anonymous_access_token_ttl_days, user_session_ttl_days, unconfirmed_account_ttl_days, authorization_type, ldap, }; config.validate()?; Ok(config) } fn validate(&self) -> Result<(), Box> { if !(self.database_url.starts_with("sqlite:") || self.database_url.starts_with("postgres:") || self.database_url.starts_with("postgresql:") || self.database_url.starts_with("mysql:")) { return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into()); } if self.database_max_connections == 0 { return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into()); } if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() { return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); } if let Some(smtp) = &self.smtp { smtp.from .parse::() .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) { return Err("PUBLIC_URL must start with http:// or https://".into()); } } Ok(()) } } fn normalize_smtp_from(value: String) -> Result> { let trimmed = value.trim(); if trimmed.is_empty() { return Err("SMTP_FROM cannot be empty".into()); } if trimmed.parse::().is_ok() { return Ok(trimmed.to_owned()); } let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) { (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => { trimmed[1..trimmed.len() - 1].trim() } _ => { return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into()); } }; if unquoted.is_empty() { return Err("SMTP_FROM cannot be empty".into()); } unquoted .parse::() .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; Ok(unquoted.to_owned()) } const KNOWN_CONFIG_KEYS: &[&str] = &[ "APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", "STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", "ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS", "REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED", "FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS", "UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE", "S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN", "LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE", "LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE", "LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL", "LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS", ]; #[derive(Default)] struct ConfigValues { file: HashMap, } impl ConfigValues { fn load(path: Option<&Path>) -> Result> { let Some(path) = path else { return Ok(Self::default()); }; let content = std::fs::read_to_string(path) .map_err(|error| format!("cannot read config file {}: {error}", path.display()))?; let file = parse_yaml_config(&content) .map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?; for key in file.keys() { if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) { return Err(format!("unknown configuration key: {key}").into()); } } Ok(Self { file }) } fn get(&self, name: &str, default: &str) -> String { env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned()) } fn optional(&self, name: &str) -> Option { env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty()) } fn required(&self, name: &str, context: &str) -> Result> { self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into()) } fn bool(&self, name: &str, default: bool) -> Result> { match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), _ => Err(format!("{name} must be true or false").into()), } } fn log_level(&self, name: &str, default: &str) -> Result> { let value = self.get(name, default).trim().to_ascii_lowercase(); match value.as_str() { "off" | "error" | "warn" | "info" | "debug" => Ok(value), _ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()), } } fn positive_i64(&self, name: &str, default: i64) -> Result> { let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?; if value <= 0 { return Err(format!("{name} must be greater than 0").into()); } Ok(value) } fn positive_u64(&self, name: &str, default: u64) -> Result> { let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?; if value == 0 { return Err(format!("{name} must be greater than 0").into()); } Ok(value) } fn nonnegative_u64(&self, name: &str, default: u64) -> Result> { self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into()) } } fn parse_yaml_config(content: &str) -> Result, String> { let mut output = HashMap::new(); let mut sections: Vec<(usize, String)> = Vec::new(); for (index, original) in content.lines().enumerate() { let line_number = index + 1; if original.contains('\t') { return Err(format!("line {line_number}: tabs are not allowed for indentation")); } let without_comment = strip_yaml_comment(original); if without_comment.trim().is_empty() || without_comment.trim() == "---" { continue; } let indent = without_comment.len() - without_comment.trim_start().len(); let line = without_comment.trim(); let (raw_key, raw_value) = line .split_once(':') .ok_or_else(|| format!("line {line_number}: expected key: value"))?; let key = raw_key.trim(); if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return Err(format!("line {line_number}: invalid key {key:?}")); } while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) { sections.pop(); } let normalized = key.to_ascii_uppercase().replace('-', "_"); let value = raw_value.trim(); if value.is_empty() { sections.push((indent, normalized)); continue; } if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) { return Err(format!("line {line_number}: only scalar values and nested mappings are supported")); } let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect(); path.push(&normalized); let full_key = path.join("_"); let parsed_value = parse_yaml_scalar(value) .map_err(|error| format!("line {line_number}: {error}"))?; if output.insert(full_key.clone(), parsed_value).is_some() { return Err(format!("line {line_number}: duplicate key {full_key}")); } } Ok(output) } fn strip_yaml_comment(line: &str) -> &str { let mut single = false; let mut double = false; let mut escaped = false; for (index, character) in line.char_indices() { if escaped { escaped = false; continue; } match character { '\\' if double => escaped = true, '\'' if !double => single = !single, '"' if !single => double = !double, '#' if !single && !double => return &line[..index], _ => {} } } line } fn parse_yaml_scalar(value: &str) -> Result { if value.starts_with('"') { if !value.ends_with('"') || value.len() < 2 { return Err("unterminated double-quoted value".to_owned()); } return serde_json::from_str::(value) .map_err(|error| format!("invalid double-quoted value: {error}")); } if value.starts_with('\'') { if !value.ends_with('\'') || value.len() < 2 { return Err("unterminated single-quoted value".to_owned()); } return Ok(value[1..value.len() - 1].replace("''", "'")); } if value.eq_ignore_ascii_case("null") || value == "~" { return Ok(String::new()); } Ok(value.to_owned()) } #[cfg(test)] mod tests { use super::normalize_smtp_from; #[test] fn smtp_from_accepts_unquoted_value() { assert_eq!( normalize_smtp_from("RustPad ".to_owned()).unwrap(), "RustPad " ); } #[test] fn smtp_from_removes_matching_double_quotes() { assert_eq!( normalize_smtp_from(" \"RustPad \" ".to_owned()).unwrap(), "RustPad " ); } #[test] fn smtp_from_removes_matching_single_quotes() { assert_eq!( normalize_smtp_from(" 'RustPad ' ".to_owned()).unwrap(), "RustPad " ); } #[test] fn smtp_from_preserves_valid_quoted_display_name() { assert_eq!( normalize_smtp_from("\"Rust, Pad\" ".to_owned()).unwrap(), "\"Rust, Pad\" " ); } #[test] fn smtp_from_rejects_unmatched_quotes() { assert!(normalize_smtp_from("\"RustPad ".to_owned()).is_err()); } #[test] fn smtp_from_rejects_invalid_mailbox() { assert!(normalize_smtp_from("RustPad".to_owned()).is_err()); } }