//! Type-safe application configuration. //! //! This module provides a strongly-typed configuration system that: //! - Loads from environment variables and optional TOML files //! - Fails fast at startup if required variables are missing //! - Supports hierarchical configuration (default -> environment-specific) use config::{Config, ConfigError, Environment, File}; use once_cell::sync::Lazy; use serde::Deserialize; use std::env; /// Application configuration loaded at startup. /// All fields are required unless marked as `Option`. #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { /// Database connection URL (PostgreSQL) pub database_url: String, /// Secret key for JWT signing pub jwt_secret: String, /// Redis connection URL #[serde(default)] pub redis_url: String, /// Server port to bind to #[serde(default = "default_port")] pub server_port: u16, /// Environment (development, staging, production) #[serde(default = "default_env")] pub environment: String, /// Allowed CORS origins (comma-separated) #[serde(default)] pub cors_origins: Vec, /// Log level (trace, debug, info, warn, error) #[serde(default = "default_log_level")] pub log_level: String, /// SMTP configuration for emails (optional) pub smtp: Option, /// Database pool configuration #[serde(default)] pub db: DbConfig, /// Max concurrent image processing tasks #[serde(default = "default_image_processing_concurrency")] pub image_processing_concurrency: usize, /// Domain/URL values that may change between deployments #[serde(default)] pub urls: UrlConfig, } #[derive(Debug, Clone, Deserialize)] pub struct UrlConfig { #[serde(default = "default_site_url")] pub site_url: String, #[serde(default = "default_picser_api_url")] pub picser_api_url: String, #[serde(default = "default_fallback_upload_api_url")] pub fallback_upload_api_url: String, } impl Default for UrlConfig { fn default() -> Self { Self { site_url: default_site_url(), picser_api_url: default_picser_api_url(), fallback_upload_api_url: default_fallback_upload_api_url(), } } } #[derive(Debug, Clone, Deserialize)] pub struct DbConfig { #[serde(default = "default_db_max_connections")] pub max_connections: u32, #[serde(default = "default_db_min_connections")] pub min_connections: u32, #[serde(default = "default_db_connect_timeout")] pub connect_timeout_seconds: u64, #[serde(default = "default_db_idle_timeout")] pub idle_timeout_seconds: u64, #[serde(default = "default_db_acquire_timeout")] pub acquire_timeout_seconds: u64, #[serde(default = "default_db_max_lifetime")] pub max_lifetime_seconds: u64, } impl Default for DbConfig { fn default() -> Self { Self { max_connections: default_db_max_connections(), min_connections: default_db_min_connections(), connect_timeout_seconds: default_db_connect_timeout(), idle_timeout_seconds: default_db_idle_timeout(), acquire_timeout_seconds: default_db_acquire_timeout(), max_lifetime_seconds: default_db_max_lifetime(), } } } /// SMTP configuration for sending emails #[derive(Debug, Clone, Deserialize)] pub struct SmtpConfig { pub host: String, pub port: u16, pub username: String, pub password: String, pub from_email: String, pub from_name: String, } /// MinIO/S3-compatible storage configuration #[derive(Debug, Clone)] pub struct MinioConfig { /// MinIO endpoint URL (e.g., "https://cdn.asepharyana.my.id") pub endpoint: String, /// Bucket name pub bucket_name: String, /// Access key / username pub access_key: String, /// Secret key / password pub secret_key: String, /// Use HTTPS (true) or HTTP (false) pub secure: bool, /// AWS region (default: us-east-1) pub region: String, /// Public URL for serving files (optional) pub public_url: Option, /// Prefix for avatar files (e.g., "avatars") pub avatar_prefix: String, } impl MinioConfig { /// Load MinIO configuration from environment variables pub fn from_env() -> Option { let endpoint = env::var("MINIO_ENDPOINT").ok()?; let bucket_name = env::var("MINIO_BUCKET_NAME").ok()?; let access_key = env::var("MINIO_ACCESS_KEY").ok()?; let secret_key = env::var("MINIO_SECRET_KEY").ok()?; let secure = env::var("MINIO_SECURE") .map(|v| v.to_lowercase() == "true") .unwrap_or(true); let region = env::var("MINIO_REGION").unwrap_or_else(|_| "us-east-1".to_string()); let public_url = env::var("MINIO_PUBLIC_URL").ok(); let avatar_prefix = env::var("MINIO_AVATAR_PREFIX").unwrap_or_else(|_| "avatars".to_string()); Some(Self { endpoint, bucket_name, access_key, secret_key, secure, region, public_url, avatar_prefix, }) } } fn default_port() -> u16 { 4091 } fn default_env() -> String { "development".to_string() } fn default_log_level() -> String { "info".to_string() } fn default_image_processing_concurrency() -> usize { 5 } fn default_site_url() -> String { "https://asepharyana.my.id".to_string() } fn default_picser_api_url() -> String { "https://picser.asepharyana.my.id/api/upload".to_string() } fn default_fallback_upload_api_url() -> String { "https://upload.asepharyana.my.id/api/upload".to_string() } fn default_db_max_connections() -> u32 { 100 } fn default_db_min_connections() -> u32 { 10 } fn default_db_connect_timeout() -> u64 { 5 } fn default_db_idle_timeout() -> u64 { 300 } fn default_db_acquire_timeout() -> u64 { 10 } fn default_db_max_lifetime() -> u64 { 1800 } impl AppConfig { /// Load configuration from environment and optional config files. /// /// Priority (highest to lowest): /// 1. Environment variables (prefixed with APP_) /// 2. `config/{environment}.toml` /// 3. `config/default.toml` pub fn load() -> Result { // Load .env file first if let Err(e) = dotenvy::dotenv() { tracing::debug!("Could not load .env file: {}", e); } let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); let config = Config::builder() // Start with default config file .add_source(File::with_name("config/default").required(false)) // Layer on environment-specific values .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) // Add environment variables (with APP_ prefix) .add_source( Environment::with_prefix("APP") .separator("__") .try_parsing(true) .list_separator(","), ) // Map legacy env vars to new config structure .set_override_option("database_url", env::var("DATABASE_URL").ok())? .set_override_option("jwt_secret", env::var("JWT_SECRET").ok())? .set_override_option("redis_url", env::var("REDIS_URL").ok())? .build()?; config.try_deserialize() } /// Check if running in production mode pub fn is_production(&self) -> bool { self.environment == "production" } /// Check if running in development mode pub fn is_development(&self) -> bool { self.environment == "development" } } /// Global configuration instance, loaded once at startup. /// Panics if configuration is invalid - this is intentional for fail-fast behavior. pub static CONFIG: Lazy = Lazy::new(|| { AppConfig::load().unwrap_or_else(|e| { eprintln!("❌ Failed to load configuration: {}", e); eprintln!(" Make sure all required environment variables are set:"); eprintln!(" - DATABASE_URL"); eprintln!(" - JWT_SECRET"); eprintln!(" - REDIS_URL (or APP_REDIS_URL)"); std::process::exit(1); }) }); /// Global MinIO configuration, loaded from environment variables. /// Returns None if required MINIO_* variables are not set. pub static MINIO_CONFIG: Lazy> = Lazy::new(|| { let _ = dotenvy::dotenv(); MinioConfig::from_env() });