/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ mod smtp; mod values; use smtp::load_smtp; use std::{net::IpAddr, path::Path}; use values::ConfigValues; #[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 guest_upload_enabled: bool, pub guest_upload_max_size_bytes: usize, pub asset_version: String, pub asset_cache_max_age_seconds: u64, pub http_compression: bool, pub file_cache_max_age_seconds: u64, pub files_public_url: Option, 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 = values.positive_u64("UPLOAD_MAX_SIZE_MB", 20)?; let guest_upload_enabled = values.bool("GUEST_UPLOAD_ENABLED", false)?; let guest_upload_max_size_mb = values.positive_u64("GUEST_UPLOAD_MAX_SIZE_MB", 5)?; 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 files_public_url = crate::file_urls::normalize_public_base(values.optional("FILES_PUBLIC_URL"))?; 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()), }; 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 = load_smtp(&values)?; 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: megabytes_to_bytes("UPLOAD_MAX_SIZE_MB", upload_max_size_mb)?, guest_upload_enabled, guest_upload_max_size_bytes: megabytes_to_bytes( "GUEST_UPLOAD_MAX_SIZE_MB", guest_upload_max_size_mb, )?, asset_version: format!("{}-ui3", env!("CARGO_PKG_VERSION")), http_compression: values.bool("HTTP_COMPRESSION", false)?, 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)?, files_public_url, 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 { 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(()) } pub fn upload_body_limit_bytes(&self) -> usize { multipart_body_limit_bytes( self.upload_max_size_bytes, self.guest_upload_enabled, self.guest_upload_max_size_bytes, ) } } fn multipart_body_limit_bytes( user_limit_bytes: usize, guest_upload_enabled: bool, guest_limit_bytes: usize, ) -> usize { let file_limit = if guest_upload_enabled { user_limit_bytes.max(guest_limit_bytes) } else { user_limit_bytes }; file_limit.saturating_add(1024 * 1024) } fn megabytes_to_bytes(name: &str, megabytes: u64) -> Result> { let bytes = megabytes .checked_mul(1024 * 1024) .ok_or_else(|| format!("{name} is too large"))?; usize::try_from(bytes).map_err(|_| format!("{name} is too large").into()) } #[cfg(test)] #[path = "../tests/config.rs"] mod tests;