use std::env; use std::path::PathBuf; use std::time::Duration; /// Cache configuration #[derive(Debug, Clone)] pub struct CacheConfig { /// TTL for file cache entries (ms) pub file_ttl_ms: u64, /// TTL for directory cache entries (ms) pub directory_ttl_ms: u64, /// Maximum number of cache entries pub max_entries: usize, } impl Default for CacheConfig { fn default() -> Self { Self { file_ttl_ms: 60_000, // 1 minute directory_ttl_ms: 120_000, // 2 minutes max_entries: 10_000, // 10,000 entries } } } /// Timeout configuration for different operations #[derive(Debug, Clone)] pub struct TimeoutConfig { /// Timeout for file operations (ms) pub file_operation_ms: u64, /// Timeout for directory operations (ms) pub dir_operation_ms: u64, /// Timeout for lock acquisition (ms) pub lock_acquisition_ms: u64, /// Timeout for network operations (ms) pub network_operation_ms: u64, /// Timeout for thumbnail generation (ms) pub thumbnail_generation_ms: u64, } impl Default for TimeoutConfig { fn default() -> Self { Self { file_operation_ms: 10000, // 10 seconds dir_operation_ms: 30000, // 30 seconds lock_acquisition_ms: 5000, // 5 seconds network_operation_ms: 15000, // 15 seconds thumbnail_generation_ms: 30000, // 30 seconds } } } impl TimeoutConfig { /// Gets a Duration for file operations pub fn file_timeout(&self) -> Duration { Duration::from_millis(self.file_operation_ms) } /// Gets a Duration for file write operations pub fn file_write_timeout(&self) -> Duration { Duration::from_millis(self.file_operation_ms) } /// Gets a Duration for file read operations pub fn file_read_timeout(&self) -> Duration { Duration::from_millis(self.file_operation_ms) } /// Gets a Duration for file delete operations pub fn file_delete_timeout(&self) -> Duration { Duration::from_millis(self.file_operation_ms) } /// Gets a Duration for directory operations pub fn dir_timeout(&self) -> Duration { Duration::from_millis(self.dir_operation_ms) } /// Gets a Duration for lock acquisition pub fn lock_timeout(&self) -> Duration { Duration::from_millis(self.lock_acquisition_ms) } /// Gets a Duration for network operations pub fn network_timeout(&self) -> Duration { Duration::from_millis(self.network_operation_ms) } /// Gets a Duration for thumbnail generation operations pub fn thumbnail_timeout(&self) -> Duration { Duration::from_millis(self.thumbnail_generation_ms) } } /// Configuration for large resource handling #[derive(Debug, Clone)] pub struct ResourceConfig { /// Threshold in MB to consider a file as large pub large_file_threshold_mb: u64, /// Entry threshold to consider a directory as large pub large_dir_threshold_entries: usize, /// Chunk size for large file processing (bytes) pub chunk_size_bytes: usize, /// File size limit for loading into memory (MB) pub max_in_memory_file_size_mb: u64, } impl Default for ResourceConfig { fn default() -> Self { Self { large_file_threshold_mb: 100, // 100 MB large_dir_threshold_entries: 1000, // 1000 entries chunk_size_bytes: 1024 * 1024, // 1 MB max_in_memory_file_size_mb: 50, // 50 MB } } } impl ResourceConfig { /// Converts a size in bytes to MB pub fn bytes_to_mb(&self, bytes: u64) -> u64 { bytes / (1024 * 1024) } /// Determines if a file is considered large pub fn is_large_file(&self, size_bytes: u64) -> bool { self.bytes_to_mb(size_bytes) >= self.large_file_threshold_mb } /// Determines if a file is large enough for parallel processing pub fn needs_parallel_processing(&self, size_bytes: u64, config: &ConcurrencyConfig) -> bool { self.bytes_to_mb(size_bytes) >= config.min_size_for_parallel_chunks_mb } /// Determines if a file can be fully loaded into memory pub fn can_load_in_memory(&self, size_bytes: u64) -> bool { self.bytes_to_mb(size_bytes) <= self.max_in_memory_file_size_mb } /// Determines if a directory is considered large pub fn is_large_directory(&self, entry_count: usize) -> bool { entry_count >= self.large_dir_threshold_entries } /// Calculates the number of chunks for parallel processing pub fn calculate_optimal_chunks(&self, size_bytes: u64, config: &ConcurrencyConfig) -> usize { // If the file is not large enough, return 1 if !self.needs_parallel_processing(size_bytes, config) { return 1; } // Calculate the number of chunks based on size let chunk_count = (size_bytes as usize).div_ceil(config.parallel_chunk_size_bytes); // Limit to the maximum number of parallel chunks chunk_count.min(config.max_parallel_chunks) } /// Calculates the optimal size of each chunk for parallel processing pub fn calculate_chunk_size(&self, file_size: u64, chunk_count: usize) -> usize { if chunk_count <= 1 { return file_size as usize; } // Distribute the size evenly among the chunks (file_size as usize).div_ceil(chunk_count) } } /// Configuration for concurrent operations #[derive(Debug, Clone)] pub struct ConcurrencyConfig { /// Maximum concurrent file tasks pub max_concurrent_files: usize, /// Maximum concurrent directory tasks pub max_concurrent_dirs: usize, /// Maximum concurrent IO operations pub max_concurrent_io: usize, /// Maximum chunks to process in parallel per file pub max_parallel_chunks: usize, /// Minimum file size (MB) to apply parallel chunk processing pub min_size_for_parallel_chunks_mb: u64, /// Chunk size for parallel processing (bytes) pub parallel_chunk_size_bytes: usize, } impl Default for ConcurrencyConfig { fn default() -> Self { Self { max_concurrent_files: 10, max_concurrent_dirs: 5, max_concurrent_io: 20, max_parallel_chunks: 8, min_size_for_parallel_chunks_mb: 200, // 200 MB parallel_chunk_size_bytes: 8 * 1024 * 1024, // 8 MB } } } /// Storage configuration #[derive(Debug, Clone)] pub struct StorageConfig { /// Root directory for storage pub root_dir: String, /// Chunk size for file processing pub chunk_size: usize, /// Threshold for parallel processing pub parallel_threshold: usize, /// Retention days for files in the trash pub trash_retention_days: u32, /// Maximum upload file size in bytes (default: 10 GB). /// Applied as a hard limit to WebDAV PUT and streaming uploads. pub max_upload_size: usize, /// Which blob storage backend to use (`local`, `s3`, or `azure`). pub backend: StorageBackendType, /// S3-compatible backend configuration (used when `backend == S3`). pub s3: Option, /// Azure Blob Storage configuration (used when `backend == Azure`). pub azure: Option, /// Local disk cache for remote backends. pub cache: BlobCacheConfig, /// Client-side encryption. pub encryption: EncryptionConfig, /// Retry policy for remote backends. pub retry: RetryConfig, } /// Which blob storage backend to use. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum StorageBackendType { /// Local filesystem (default). #[default] Local, /// Any S3-compatible object store (AWS, Backblaze B2, R2, MinIO, …). S3, /// Azure Blob Storage. Azure, } /// Configuration for an S3-compatible blob storage backend. #[derive(Debug, Clone)] pub struct S3StorageConfig { /// Custom endpoint URL (required for non-AWS providers). pub endpoint_url: Option, /// S3 bucket name. pub bucket: String, /// AWS region (default: `us-east-1`). pub region: String, /// Access key ID. pub access_key: String, /// Secret access key. pub secret_key: String, /// Force path-style access (required for MinIO, R2, some providers). pub force_path_style: bool, } /// Configuration for Azure Blob Storage. #[derive(Debug, Clone)] pub struct AzureStorageConfig { /// Azure storage account name. pub account_name: String, /// Azure storage account key. pub account_key: String, /// Container name. pub container: String, /// Optional SAS token (alternative to account key). pub sas_token: Option, } /// LRU local disk cache configuration for remote blob backends. #[derive(Debug, Clone)] pub struct BlobCacheConfig { /// Enable the LRU disk cache (only useful for remote backends). pub enabled: bool, /// Maximum cache size in bytes (default: 50 GB). pub max_size_bytes: u64, /// Cache directory path (default: `{root_dir}/.blob-cache`). pub cache_path: Option, } impl Default for BlobCacheConfig { fn default() -> Self { Self { enabled: false, max_size_bytes: 50 * 1024 * 1024 * 1024, // 50 GB cache_path: None, } } } /// Client-side encryption configuration. #[derive(Debug, Clone)] pub struct EncryptionConfig { /// Enable AES-256-GCM encryption for blobs at rest. pub enabled: bool, /// Base64-encoded 32-byte encryption key. pub key_base64: Option, } impl Default for EncryptionConfig { #[allow(clippy::derivable_impls)] fn default() -> Self { Self { enabled: false, key_base64: None, } } } /// Retry policy configuration for remote backends. #[derive(Debug, Clone)] pub struct RetryConfig { /// Enable retry with exponential backoff. pub enabled: bool, /// Maximum number of retry attempts. pub max_retries: u32, /// Initial backoff in milliseconds. pub initial_backoff_ms: u64, /// Maximum backoff in milliseconds. pub max_backoff_ms: u64, /// Backoff multiplier. pub backoff_multiplier: f64, } impl Default for RetryConfig { fn default() -> Self { Self { enabled: true, max_retries: 3, initial_backoff_ms: 100, max_backoff_ms: 10_000, backoff_multiplier: 2.0, } } } impl Default for StorageConfig { fn default() -> Self { // Architecture-appropriate max upload size to avoid overflow on 32-bit systems const MAX_UPLOAD_SIZE: usize = if cfg!(target_pointer_width = "64") { 10 * 1024 * 1024 * 1024 // 10 GB on 64-bit } else { 1024 * 1024 * 1024 // 1 GB on 32-bit }; Self { root_dir: "storage".to_string(), chunk_size: 1024 * 1024, // 1 MB parallel_threshold: 100 * 1024 * 1024, // 100 MB trash_retention_days: 30, // 30 days max_upload_size: MAX_UPLOAD_SIZE, backend: StorageBackendType::Local, s3: None, azure: None, cache: BlobCacheConfig::default(), encryption: EncryptionConfig::default(), retry: RetryConfig::default(), } } } /// Database configuration #[derive(Debug, Clone)] pub struct DatabaseConfig { pub connection_string: String, pub max_connections: u32, pub min_connections: u32, pub connect_timeout_secs: u64, pub idle_timeout_secs: u64, pub max_lifetime_secs: u64, /// Maximum connections for the maintenance pool (background/batch tasks). /// Defaults to 25% of `max_connections` (minimum 2). pub maintenance_max_connections: u32, /// Minimum connections for the maintenance pool. /// Defaults to 1. pub maintenance_min_connections: u32, } impl Default for DatabaseConfig { fn default() -> Self { Self { // Updated connection string with default credentials that PostgreSQL often uses connection_string: "postgres://postgres:postgres@localhost:5432/oxicloud".to_string(), max_connections: 20, min_connections: 5, connect_timeout_secs: 10, idle_timeout_secs: 300, max_lifetime_secs: 1800, maintenance_max_connections: 5, maintenance_min_connections: 1, } } } /// Authentication configuration #[derive(Debug, Clone)] pub struct AuthConfig { pub jwt_secret: String, pub access_token_expiry_secs: i64, pub refresh_token_expiry_secs: i64, /// Argon2id memory cost in KiB (default 65536 = 64 MiB) pub hash_memory_cost: u32, /// Argon2id time cost / iterations (default 3) pub hash_time_cost: u32, /// Argon2id parallelism lanes (default 2) pub hash_parallelism: u32, /// Rate limiting / account lockout configuration pub rate_limit: RateLimitConfig, } /// Rate limiting and brute-force protection configuration. #[derive(Debug, Clone)] pub struct RateLimitConfig { /// Max login attempts per IP per window (default: 10) pub login_max_requests: u32, /// Login rate-limit window in seconds (default: 60) pub login_window_secs: u64, /// Max registration attempts per IP per window (default: 5) pub register_max_requests: u32, /// Registration rate-limit window in seconds (default: 3600) pub register_window_secs: u64, /// Max token refresh attempts per IP per window (default: 20) pub refresh_max_requests: u32, /// Refresh rate-limit window in seconds (default: 60) pub refresh_window_secs: u64, /// Consecutive failed logins before account lockout (default: 5) pub lockout_max_failures: u32, /// Account lockout duration in seconds (default: 900 = 15 min) pub lockout_duration_secs: u64, } impl Default for RateLimitConfig { fn default() -> Self { Self { login_max_requests: 10, login_window_secs: 60, register_max_requests: 5, register_window_secs: 3600, refresh_max_requests: 20, refresh_window_secs: 60, lockout_max_failures: 5, lockout_duration_secs: 900, } } } impl Default for AuthConfig { fn default() -> Self { Self { // SECURITY: This default is intentionally insecure to force operators // to set OXICLOUD_JWT_SECRET in production. The from_env() method // will validate this and warn/panic if not configured. jwt_secret: String::new(), access_token_expiry_secs: 3600, // 1 hour refresh_token_expiry_secs: 604800, // 7 days — with rotation, active sessions auto-renew hash_memory_cost: 65536, // 64 MiB hash_time_cost: 3, hash_parallelism: 2, rate_limit: RateLimitConfig::default(), } } } /// OpenID Connect (OIDC) configuration #[derive(Debug, Clone)] pub struct OidcConfig { /// Whether OIDC authentication is enabled pub enabled: bool, /// OIDC Issuer URL (e.g. https://authentik.example.com/application/o/oxicloud/) pub issuer_url: String, /// OIDC Client ID pub client_id: String, /// OIDC Client Secret pub client_secret: String, /// Redirect URI after OIDC authentication (must match IdP config) pub redirect_uri: String, /// OIDC scopes to request pub scopes: String, /// Frontend URL to redirect after successful OIDC login (tokens appended as fragment) pub frontend_url: String, /// Whether to auto-create users on first OIDC login (JIT provisioning) pub auto_provision: bool, /// Comma-separated list of OIDC groups that map to admin role pub admin_groups: String, /// Whether to disable password-based login entirely pub disable_password_login: bool, /// OIDC provider display name (shown in UI) pub provider_name: String, } impl Default for OidcConfig { fn default() -> Self { Self { enabled: false, issuer_url: String::new(), client_id: String::new(), client_secret: String::new(), redirect_uri: "http://localhost:8086/api/auth/oidc/callback".to_string(), scopes: "openid profile email".to_string(), frontend_url: "http://localhost:8086".to_string(), auto_provision: true, admin_groups: String::new(), disable_password_login: false, provider_name: "SSO".to_string(), } } } impl OidcConfig { /// Load OIDC configuration from environment variables only pub fn from_env() -> Self { use std::env; let mut cfg = Self::default(); if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") { cfg.enabled = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { cfg.issuer_url = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { cfg.client_id = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { cfg.client_secret = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { cfg.redirect_uri = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { cfg.scopes = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { cfg.frontend_url = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") { cfg.auto_provision = v.parse::().unwrap_or(true); } if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { cfg.admin_groups = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") { cfg.disable_password_login = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { cfg.provider_name = v; } cfg } } /// WOPI (Web Application Open Platform Interface) configuration #[derive(Debug, Clone)] pub struct WopiConfig { /// Whether WOPI integration is enabled pub enabled: bool, /// URL to the WOPI client's discovery endpoint /// e.g., "http://collabora:9980/hosting/discovery" pub discovery_url: String, /// Secret key for signing WOPI access tokens /// Falls back to JWT secret if empty pub secret: String, /// Access token TTL in seconds (default: 86400 = 24 hours) pub token_ttl_secs: i64, /// Lock expiration in seconds (default: 1800 = 30 minutes) pub lock_ttl_secs: u64, } impl Default for WopiConfig { fn default() -> Self { Self { enabled: false, discovery_url: String::new(), secret: String::new(), token_ttl_secs: 86400, lock_ttl_secs: 1800, } } } /// Nextcloud compatibility configuration #[derive(Debug, Clone)] pub struct NextcloudConfig { /// Whether the Nextcloud compatibility layer is enabled pub enabled: bool, /// Instance ID suffix for oc:id formatting (e.g., "ocnca") pub instance_id: String, /// Emulated Nextcloud version (major.minor.patch). /// Clients use this to decide which features to enable. pub emulated_version: (u32, u32, u32), /// Login Flow v2 token TTL in seconds (default: 600 = 10 minutes) pub login_flow_ttl_secs: u64, } impl Default for NextcloudConfig { fn default() -> Self { Self { enabled: false, instance_id: "ocnca".to_string(), emulated_version: (28, 0, 4), login_flow_ttl_secs: 600, } } } impl NextcloudConfig { /// Version string, e.g. "28.0.4". pub fn version_string(&self) -> String { let (maj, min, pat) = self.emulated_version; format!("{}.{}.{}", maj, min, pat) } } /// Feature configuration (feature flags) #[derive(Debug, Clone)] pub struct FeaturesConfig { pub enable_auth: bool, pub enable_user_storage_quotas: bool, pub enable_file_sharing: bool, pub enable_trash: bool, pub enable_search: bool, pub enable_music: bool, } impl Default for FeaturesConfig { fn default() -> Self { Self { enable_auth: true, // Enable authentication by default enable_user_storage_quotas: false, enable_file_sharing: true, // Enable file sharing by default enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature enable_music: true, // Enable music feature } } } /// Global application configuration #[derive(Debug, Clone)] pub struct AppConfig { /// Storage directory path pub storage_path: PathBuf, /// Static files directory path pub static_path: PathBuf, /// Server port pub server_port: u16, /// Server host pub server_host: String, /// Cache configuration pub cache: CacheConfig, /// Timeout configuration pub timeouts: TimeoutConfig, /// Resource configuration pub resources: ResourceConfig, /// Concurrency configuration pub concurrency: ConcurrencyConfig, /// Storage configuration pub storage: StorageConfig, /// Database configuration pub database: DatabaseConfig, /// Authentication configuration pub auth: AuthConfig, /// Feature configuration pub features: FeaturesConfig, /// OIDC configuration pub oidc: OidcConfig, /// WOPI configuration pub wopi: WopiConfig, /// Nextcloud compatibility configuration pub nextcloud: NextcloudConfig, } impl Default for AppConfig { fn default() -> Self { Self { storage_path: PathBuf::from("./storage"), static_path: PathBuf::from("./static"), server_port: 8086, server_host: "127.0.0.1".to_string(), cache: CacheConfig::default(), timeouts: TimeoutConfig::default(), resources: ResourceConfig::default(), concurrency: ConcurrencyConfig::default(), storage: StorageConfig::default(), database: DatabaseConfig::default(), auth: AuthConfig::default(), features: FeaturesConfig::default(), oidc: OidcConfig::default(), wopi: WopiConfig::default(), nextcloud: NextcloudConfig::default(), } } } impl AppConfig { pub fn from_env() -> Self { let mut config = Self::default(); // Use environment variables to override default values if let Ok(storage_path) = env::var("OXICLOUD_STORAGE_PATH") { config.storage_path = PathBuf::from(storage_path); } if let Ok(static_path) = env::var("OXICLOUD_STATIC_PATH") { config.static_path = PathBuf::from(static_path); } if let Ok(server_port) = env::var("OXICLOUD_SERVER_PORT") && let Ok(port) = server_port.parse::() { config.server_port = port; } if let Ok(server_host) = env::var("OXICLOUD_SERVER_HOST") { config.server_host = server_host; } // Database configuration if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") { config.database.connection_string = connection_string; } if let Ok(max_connections) = env::var("OXICLOUD_DB_MAX_CONNECTIONS").map(|v| v.parse::()) && let Ok(val) = max_connections { config.database.max_connections = val; } if let Ok(min_connections) = env::var("OXICLOUD_DB_MIN_CONNECTIONS").map(|v| v.parse::()) && let Ok(val) = min_connections { config.database.min_connections = val; } if let Ok(max_conn) = env::var("OXICLOUD_DB_MAINTENANCE_MAX_CONNECTIONS").map(|v| v.parse::()) && let Ok(val) = max_conn { config.database.maintenance_max_connections = val; } if let Ok(min_conn) = env::var("OXICLOUD_DB_MAINTENANCE_MIN_CONNECTIONS").map(|v| v.parse::()) && let Ok(val) = min_conn { config.database.maintenance_min_connections = val; } // Auth configuration if let Some(jwt_secret) = env::var("OXICLOUD_JWT_SECRET") .ok() .filter(|s| !s.is_empty()) { // SECURITY: Validate JWT secret minimum entropy (RFC 7518 §3.2 // recommends ≥256 bits for HS256). Panic on dangerously short // secrets, warn on sub-optimal ones. let len = jwt_secret.len(); if config.features.enable_auth && len < 16 { panic!( "FATAL: OXICLOUD_JWT_SECRET is dangerously short ({} bytes). \ Minimum: 32 bytes (256 bits) for HS256. \ Generate a secure secret with: openssl rand -hex 32", len ); } else if config.features.enable_auth && len < 32 { tracing::warn!("=========================================================="); tracing::warn!( "OXICLOUD_JWT_SECRET is only {} bytes — recommended minimum is 32 (256 bits).", len ); tracing::warn!("Generate a stronger secret with: openssl rand -hex 32"); tracing::warn!("=========================================================="); } config.auth.jwt_secret = jwt_secret; } // SECURITY: Auto-persist JWT secret to storage so it survives restarts. // Priority: env var > persisted file > generate new. if config.features.enable_auth && config.auth.jwt_secret.is_empty() { let secret_file = config.storage_path.join(".jwt_secret"); if secret_file.exists() { // Read persisted secret from previous run match std::fs::read_to_string(&secret_file) { Ok(persisted) => { let persisted = persisted.trim().to_string(); if persisted.len() >= 32 { config.auth.jwt_secret = persisted; tracing::info!("JWT secret loaded from {}", secret_file.display()); } else { tracing::warn!( "Persisted JWT secret too short ({}B), regenerating", persisted.len() ); } } Err(e) => { tracing::warn!("Failed to read {}: {}", secret_file.display(), e); } } } // Still empty → generate and persist if config.auth.jwt_secret.is_empty() { use rand_core::{OsRng, RngCore}; let mut key = [0u8; 32]; OsRng.fill_bytes(&mut key); let generated_secret: String = key.iter().map(|b| format!("{:02x}", b)).collect(); // Persist to storage volume so it survives container restarts if let Err(e) = std::fs::write(&secret_file, &generated_secret) { tracing::error!( "Failed to persist JWT secret to {}: {}. \ Tokens will be invalidated on restart!", secret_file.display(), e ); } else { // Restrict file permissions (owner-only read/write) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions( &secret_file, std::fs::Permissions::from_mode(0o600), ); } tracing::info!( "JWT secret auto-generated and persisted to {}", secret_file.display() ); } config.auth.jwt_secret = generated_secret; } } if let Ok(access_token_expiry) = env::var("OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS").map(|v| v.parse::()) && let Ok(val) = access_token_expiry { config.auth.access_token_expiry_secs = val; } if let Ok(refresh_token_expiry) = env::var("OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS").map(|v| v.parse::()) && let Ok(val) = refresh_token_expiry { config.auth.refresh_token_expiry_secs = val; } // Argon2 hashing parameters if let Ok(v) = env::var("OXICLOUD_HASH_MEMORY_COST").map(|v| v.parse::()) && let Ok(val) = v { config.auth.hash_memory_cost = val; } if let Ok(v) = env::var("OXICLOUD_HASH_TIME_COST").map(|v| v.parse::()) && let Ok(val) = v { config.auth.hash_time_cost = val; } if let Ok(v) = env::var("OXICLOUD_HASH_PARALLELISM").map(|v| v.parse::()) && let Ok(val) = v { config.auth.hash_parallelism = val; } // Rate limiting / account lockout if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_LOGIN_MAX").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.login_max_requests = val; } if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.login_window_secs = val; } if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REGISTER_MAX").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.register_max_requests = val; } if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.register_window_secs = val; } if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REFRESH_MAX").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.refresh_max_requests = val; } if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REFRESH_WINDOW_SECS").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.refresh_window_secs = val; } if let Ok(v) = env::var("OXICLOUD_LOCKOUT_MAX_FAILURES").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.lockout_max_failures = val; } if let Ok(v) = env::var("OXICLOUD_LOCKOUT_DURATION_SECS").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.lockout_duration_secs = val; } // Feature flags if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::()) && let Ok(val) = enable_auth { config.features.enable_auth = val; } if let Ok(enable_user_storage_quotas) = env::var("OXICLOUD_ENABLE_USER_STORAGE_QUOTAS").map(|v| v.parse::()) && let Ok(val) = enable_user_storage_quotas { config.features.enable_user_storage_quotas = val; } if let Ok(enable_file_sharing) = env::var("OXICLOUD_ENABLE_FILE_SHARING").map(|v| v.parse::()) && let Ok(val) = enable_file_sharing { config.features.enable_file_sharing = val; } if let Ok(enable_trash) = env::var("OXICLOUD_ENABLE_TRASH").map(|v| v.parse::()) && let Ok(val) = enable_trash { config.features.enable_trash = val; } if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::()) && let Ok(val) = enable_search { config.features.enable_search = val; } if let Ok(enable_music) = env::var("OXICLOUD_ENABLE_MUSIC").map(|v| v.parse::()) && let Ok(val) = enable_music { config.features.enable_music = val; } // Storage limits if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::()) && let Ok(val) = max_upload { config.storage.max_upload_size = val; } // Storage backend selection if let Ok(backend) = env::var("OXICLOUD_STORAGE_BACKEND") { match backend.to_lowercase().as_str() { "s3" => config.storage.backend = StorageBackendType::S3, "azure" => config.storage.backend = StorageBackendType::Azure, _ => config.storage.backend = StorageBackendType::Local, } } // S3-compatible storage configuration if config.storage.backend == StorageBackendType::S3 { let bucket = env::var("OXICLOUD_S3_BUCKET").unwrap_or_default(); if bucket.is_empty() { tracing::warn!("OXICLOUD_STORAGE_BACKEND=s3 but OXICLOUD_S3_BUCKET is not set"); } config.storage.s3 = Some(S3StorageConfig { endpoint_url: env::var("OXICLOUD_S3_ENDPOINT_URL").ok(), bucket, region: env::var("OXICLOUD_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()), access_key: env::var("OXICLOUD_S3_ACCESS_KEY").unwrap_or_default(), secret_key: env::var("OXICLOUD_S3_SECRET_KEY").unwrap_or_default(), force_path_style: env::var("OXICLOUD_S3_FORCE_PATH_STYLE") .map(|v| v.parse::().unwrap_or(false)) .unwrap_or(false), }); } // Azure Blob Storage configuration if config.storage.backend == StorageBackendType::Azure { let container = env::var("OXICLOUD_AZURE_CONTAINER").unwrap_or_default(); if container.is_empty() { tracing::warn!( "OXICLOUD_STORAGE_BACKEND=azure but OXICLOUD_AZURE_CONTAINER is not set" ); } config.storage.azure = Some(AzureStorageConfig { account_name: env::var("OXICLOUD_AZURE_ACCOUNT_NAME").unwrap_or_default(), account_key: env::var("OXICLOUD_AZURE_ACCOUNT_KEY").unwrap_or_default(), container, sas_token: env::var("OXICLOUD_AZURE_SAS_TOKEN").ok(), }); } // Blob cache configuration if let Ok(v) = env::var("OXICLOUD_STORAGE_CACHE_ENABLED") { config.storage.cache.enabled = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_STORAGE_CACHE_MAX_SIZE") && let Ok(bytes) = v.parse::() { config.storage.cache.max_size_bytes = bytes; } if let Ok(v) = env::var("OXICLOUD_STORAGE_CACHE_PATH") { config.storage.cache.cache_path = Some(v); } // Encryption configuration if let Ok(v) = env::var("OXICLOUD_STORAGE_ENCRYPTION_ENABLED") { config.storage.encryption.enabled = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_STORAGE_ENCRYPTION_KEY") { config.storage.encryption.key_base64 = Some(v); } // Retry configuration if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_ENABLED") { config.storage.retry.enabled = v.parse::().unwrap_or(true); } if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_MAX_RETRIES") && let Ok(n) = v.parse::() { config.storage.retry.max_retries = n; } if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_INITIAL_BACKOFF_MS") && let Ok(n) = v.parse::() { config.storage.retry.initial_backoff_ms = n; } if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_MAX_BACKOFF_MS") && let Ok(n) = v.parse::() { config.storage.retry.max_backoff_ms = n; } if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_BACKOFF_MULTIPLIER") && let Ok(n) = v.parse::() { config.storage.retry.backoff_multiplier = n; } // OIDC configuration if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") { config.oidc.enabled = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { config.oidc.issuer_url = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { config.oidc.client_id = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { config.oidc.client_secret = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { config.oidc.redirect_uri = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { config.oidc.scopes = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { config.oidc.frontend_url = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") { config.oidc.auto_provision = v.parse::().unwrap_or(true); } if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { config.oidc.admin_groups = v; } if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") { config.oidc.disable_password_login = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { config.oidc.provider_name = v; } // Validate OIDC config when enabled if config.oidc.enabled && (config.oidc.issuer_url.is_empty() || config.oidc.client_id.is_empty() || config.oidc.client_secret.is_empty()) { tracing::error!( "OIDC is enabled but OXICLOUD_OIDC_ISSUER_URL, OXICLOUD_OIDC_CLIENT_ID, or OXICLOUD_OIDC_CLIENT_SECRET are not set" ); config.oidc.enabled = false; } // WOPI configuration if let Ok(v) = env::var("OXICLOUD_WOPI_ENABLED") { config.wopi.enabled = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_WOPI_DISCOVERY_URL") { config.wopi.discovery_url = v; } if let Ok(v) = env::var("OXICLOUD_WOPI_SECRET") { config.wopi.secret = v; } if let Ok(v) = env::var("OXICLOUD_WOPI_TOKEN_TTL_SECS") && let Ok(val) = v.parse::() { config.wopi.token_ttl_secs = val; } if let Ok(v) = env::var("OXICLOUD_WOPI_LOCK_TTL_SECS") && let Ok(val) = v.parse::() { config.wopi.lock_ttl_secs = val; } // WOPI secret fallback: use JWT secret if WOPI secret not set if config.wopi.enabled && config.wopi.secret.is_empty() { config.wopi.secret = config.auth.jwt_secret.clone(); tracing::info!("WOPI secret not set, falling back to JWT secret"); } // Nextcloud compatibility configuration if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_ENABLED") { config.nextcloud.enabled = v.parse::().unwrap_or(false); } if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_INSTANCE_ID") { let trimmed = v.trim(); if !trimmed.is_empty() { config.nextcloud.instance_id = trimmed.to_string(); } } if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_VERSION") { // Expected format: "28.0.4" let parts: Vec<&str> = v.trim().splitn(3, '.').collect(); if parts.len() == 3 && let (Ok(maj), Ok(min), Ok(pat)) = ( parts[0].parse::(), parts[1].parse::(), parts[2].parse::(), ) { config.nextcloud.emulated_version = (maj, min, pat); } } config } pub fn with_features(mut self, features: FeaturesConfig) -> Self { self.features = features; self } pub fn db_enabled(&self) -> bool { self.features.enable_auth } pub fn auth_enabled(&self) -> bool { self.features.enable_auth } /// Build the public base URL for generating share links and other external URLs. /// /// Priority: /// 1. `OXICLOUD_BASE_URL` env var (used as-is) /// 2. If `server_host` already contains a scheme (`http://` or `https://`), /// treat it as a full origin and do **not** prepend a scheme or append a port. /// 3. Otherwise, fall back to `http://{server_host}:{server_port}`. pub fn base_url(&self) -> String { if let Ok(explicit) = std::env::var("OXICLOUD_BASE_URL") { return explicit.trim_end_matches('/').to_string(); } let host = self.server_host.trim_end_matches('/'); if host.starts_with("http://") || host.starts_with("https://") { // The user already provided a full origin — use it directly. host.to_string() } else { format!("http://{}:{}", host, self.server_port) } } } /// Gets a default global configuration pub fn default_config() -> AppConfig { AppConfig::default() }