2025-03-20 09:22:31 +01:00
|
|
|
use std::env;
|
2026-02-14 01:29:34 +01:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::time::Duration;
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Cache configuration
|
2025-03-19 19:52:12 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct CacheConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// TTL for file cache entries (ms)
|
2025-03-19 19:52:12 +01:00
|
|
|
pub file_ttl_ms: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// TTL for directory cache entries (ms)
|
2025-03-19 19:52:12 +01:00
|
|
|
pub directory_ttl_ms: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Maximum number of cache entries
|
2025-03-19 19:52:12 +01:00
|
|
|
pub max_entries: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for CacheConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-02-14 01:29:34 +01:00
|
|
|
file_ttl_ms: 60_000, // 1 minute
|
2026-02-12 09:41:25 +01:00
|
|
|
directory_ttl_ms: 120_000, // 2 minutes
|
2026-02-14 01:29:34 +01:00
|
|
|
max_entries: 10_000, // 10,000 entries
|
2025-03-19 19:52:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timeout configuration for different operations
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct TimeoutConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timeout for file operations (ms)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub file_operation_ms: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timeout for directory operations (ms)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub dir_operation_ms: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timeout for lock acquisition (ms)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub lock_acquisition_ms: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timeout for network operations (ms)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub network_operation_ms: u64,
|
2026-03-28 18:40:34 +00:00
|
|
|
/// Timeout for thumbnail generation (ms)
|
|
|
|
|
pub thumbnail_generation_ms: u64,
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for TimeoutConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-03-28 18:40:34 +00:00
|
|
|
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
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TimeoutConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for file operations
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn file_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.file_operation_ms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for file write operations
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn file_write_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.file_operation_ms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for file read operations
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn file_read_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.file_operation_ms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for file delete operations
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn file_delete_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.file_operation_ms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for directory operations
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn dir_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.dir_operation_ms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for lock acquisition
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn lock_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.lock_acquisition_ms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a Duration for network operations
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn network_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.network_operation_ms)
|
|
|
|
|
}
|
2026-03-28 18:40:34 +00:00
|
|
|
|
|
|
|
|
/// Gets a Duration for thumbnail generation operations
|
|
|
|
|
pub fn thumbnail_timeout(&self) -> Duration {
|
|
|
|
|
Duration::from_millis(self.thumbnail_generation_ms)
|
|
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Configuration for large resource handling
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct ResourceConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Threshold in MB to consider a file as large
|
2025-03-19 00:44:27 +01:00
|
|
|
pub large_file_threshold_mb: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Entry threshold to consider a directory as large
|
2025-03-19 00:44:27 +01:00
|
|
|
pub large_dir_threshold_entries: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Chunk size for large file processing (bytes)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub chunk_size_bytes: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// File size limit for loading into memory (MB)
|
2025-03-19 00:44:27 +01:00
|
|
|
pub max_in_memory_file_size_mb: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for ResourceConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-02-14 01:29:34 +01:00
|
|
|
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
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ResourceConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Converts a size in bytes to MB
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn bytes_to_mb(&self, bytes: u64) -> u64 {
|
|
|
|
|
bytes / (1024 * 1024)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Determines if a file is considered large
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn is_large_file(&self, size_bytes: u64) -> bool {
|
|
|
|
|
self.bytes_to_mb(size_bytes) >= self.large_file_threshold_mb
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Determines if a file is large enough for parallel processing
|
2025-03-19 00:44:27 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Determines if a file can be fully loaded into memory
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn can_load_in_memory(&self, size_bytes: u64) -> bool {
|
|
|
|
|
self.bytes_to_mb(size_bytes) <= self.max_in_memory_file_size_mb
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Determines if a directory is considered large
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn is_large_directory(&self, entry_count: usize) -> bool {
|
|
|
|
|
entry_count >= self.large_dir_threshold_entries
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Calculates the number of chunks for parallel processing
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn calculate_optimal_chunks(&self, size_bytes: u64, config: &ConcurrencyConfig) -> usize {
|
2026-02-12 09:41:25 +01:00
|
|
|
// If the file is not large enough, return 1
|
2025-03-19 00:44:27 +01:00
|
|
|
if !self.needs_parallel_processing(size_bytes, config) {
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Calculate the number of chunks based on size
|
2026-02-14 01:26:02 +01:00
|
|
|
let chunk_count = (size_bytes as usize).div_ceil(config.parallel_chunk_size_bytes);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Limit to the maximum number of parallel chunks
|
2025-03-19 00:44:27 +01:00
|
|
|
chunk_count.min(config.max_parallel_chunks)
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Calculates the optimal size of each chunk for parallel processing
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn calculate_chunk_size(&self, file_size: u64, chunk_count: usize) -> usize {
|
|
|
|
|
if chunk_count <= 1 {
|
|
|
|
|
return file_size as usize;
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Distribute the size evenly among the chunks
|
2026-02-14 01:26:02 +01:00
|
|
|
(file_size as usize).div_ceil(chunk_count)
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Configuration for concurrent operations
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct ConcurrencyConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Maximum concurrent file tasks
|
2025-03-19 00:44:27 +01:00
|
|
|
pub max_concurrent_files: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Maximum concurrent directory tasks
|
2025-03-19 00:44:27 +01:00
|
|
|
pub max_concurrent_dirs: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Maximum concurrent IO operations
|
2025-03-19 00:44:27 +01:00
|
|
|
pub max_concurrent_io: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Maximum chunks to process in parallel per file
|
2025-03-19 00:44:27 +01:00
|
|
|
pub max_parallel_chunks: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Minimum file size (MB) to apply parallel chunk processing
|
2025-03-19 00:44:27 +01:00
|
|
|
pub min_size_for_parallel_chunks_mb: u64,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Chunk size for parallel processing (bytes)
|
2025-03-19 00:44:27 +01:00
|
|
|
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,
|
2026-02-14 01:29:34 +01:00
|
|
|
min_size_for_parallel_chunks_mb: 200, // 200 MB
|
2025-03-19 00:44:27 +01:00
|
|
|
parallel_chunk_size_bytes: 8 * 1024 * 1024, // 8 MB
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Storage configuration
|
2025-03-24 16:47:42 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct StorageConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Root directory for storage
|
2025-03-24 16:47:42 +01:00
|
|
|
pub root_dir: String,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Chunk size for file processing
|
2025-03-24 16:47:42 +01:00
|
|
|
pub chunk_size: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Threshold for parallel processing
|
2025-03-24 16:47:42 +01:00
|
|
|
pub parallel_threshold: usize,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Retention days for files in the trash
|
2025-03-24 16:47:42 +01:00
|
|
|
pub trash_retention_days: u32,
|
2026-02-22 23:28:03 +01:00
|
|
|
/// 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,
|
2026-04-14 21:33:38 +02:00
|
|
|
/// Which blob storage backend to use (`local`, `s3`, or `azure`).
|
|
|
|
|
pub backend: StorageBackendType,
|
|
|
|
|
/// S3-compatible backend configuration (used when `backend == S3`).
|
|
|
|
|
pub s3: Option<S3StorageConfig>,
|
|
|
|
|
/// Azure Blob Storage configuration (used when `backend == Azure`).
|
|
|
|
|
pub azure: Option<AzureStorageConfig>,
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-24 16:47:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for StorageConfig {
|
|
|
|
|
fn default() -> Self {
|
2026-03-17 04:39:18 +08:00
|
|
|
// 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
|
|
|
|
|
};
|
2025-03-24 16:47:42 +01:00
|
|
|
Self {
|
|
|
|
|
root_dir: "storage".to_string(),
|
2026-03-26 09:46:50 +01:00
|
|
|
chunk_size: 1024 * 1024, // 1 MB
|
|
|
|
|
parallel_threshold: 100 * 1024 * 1024, // 100 MB
|
|
|
|
|
trash_retention_days: 30, // 30 days
|
2026-03-17 04:39:18 +08:00
|
|
|
max_upload_size: MAX_UPLOAD_SIZE,
|
2026-04-14 21:33:38 +02:00
|
|
|
backend: StorageBackendType::Local,
|
|
|
|
|
s3: None,
|
|
|
|
|
azure: None,
|
|
|
|
|
cache: BlobCacheConfig::default(),
|
|
|
|
|
encryption: EncryptionConfig::default(),
|
|
|
|
|
retry: RetryConfig::default(),
|
2025-03-24 16:47:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Database configuration
|
2025-03-20 09:22:31 +01:00
|
|
|
#[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,
|
2026-02-24 19:28:00 +01:00
|
|
|
/// 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,
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for DatabaseConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2025-03-23 22:44:18 +01:00
|
|
|
// Updated connection string with default credentials that PostgreSQL often uses
|
2026-03-04 15:14:07 +01:00
|
|
|
connection_string: "postgres://postgres:postgres@localhost:5432/oxicloud".to_string(),
|
2025-03-20 09:22:31 +01:00
|
|
|
max_connections: 20,
|
|
|
|
|
min_connections: 5,
|
|
|
|
|
connect_timeout_secs: 10,
|
|
|
|
|
idle_timeout_secs: 300,
|
|
|
|
|
max_lifetime_secs: 1800,
|
2026-02-24 19:28:00 +01:00
|
|
|
maintenance_max_connections: 5,
|
|
|
|
|
maintenance_min_connections: 1,
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Authentication configuration
|
2025-03-20 09:22:31 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct AuthConfig {
|
|
|
|
|
pub jwt_secret: String,
|
|
|
|
|
pub access_token_expiry_secs: i64,
|
|
|
|
|
pub refresh_token_expiry_secs: i64,
|
2026-02-24 17:15:36 +01:00
|
|
|
/// Argon2id memory cost in KiB (default 65536 = 64 MiB)
|
2025-03-20 09:22:31 +01:00
|
|
|
pub hash_memory_cost: u32,
|
2026-02-24 17:15:36 +01:00
|
|
|
/// Argon2id time cost / iterations (default 3)
|
2025-03-20 09:22:31 +01:00
|
|
|
pub hash_time_cost: u32,
|
2026-02-24 17:15:36 +01:00
|
|
|
/// Argon2id parallelism lanes (default 2)
|
|
|
|
|
pub hash_parallelism: u32,
|
2026-03-03 01:44:39 +01:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for AuthConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-02-08 13:40:23 +01:00
|
|
|
// 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(),
|
2026-05-07 09:30:09 +02:00
|
|
|
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
|
2025-03-20 09:22:31 +01:00
|
|
|
hash_time_cost: 3,
|
2026-02-24 17:15:36 +01:00
|
|
|
hash_parallelism: 2,
|
2026-03-03 01:44:39 +01:00
|
|
|
rate_limit: RateLimitConfig::default(),
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// OpenID Connect (OIDC) configuration
|
2026-02-10 20:32:32 +01:00
|
|
|
#[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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 00:15:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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::<bool>().unwrap_or(false);
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-11 00:15:26 +01:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") {
|
|
|
|
|
cfg.auto_provision = v.parse::<bool>().unwrap_or(true);
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") {
|
|
|
|
|
cfg.admin_groups = v;
|
|
|
|
|
}
|
2026-02-11 00:15:26 +01:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") {
|
|
|
|
|
cfg.disable_password_login = v.parse::<bool>().unwrap_or(false);
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") {
|
|
|
|
|
cfg.provider_name = v;
|
|
|
|
|
}
|
2026-02-11 00:15:26 +01:00
|
|
|
cfg
|
|
|
|
|
}
|
2026-02-10 20:32:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:39:27 +01:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:02:15 +01:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:14:24 +02:00
|
|
|
/// Transport encryption mode for the SMTP relay. Picked at startup
|
|
|
|
|
/// from `OXICLOUD_SMTP_TLS=starttls|tls|none`. The default for an
|
|
|
|
|
/// unconfigured deployment is `Starttls` (port 587 with `STARTTLS`),
|
|
|
|
|
/// matching the most common modern submission setup.
|
|
|
|
|
///
|
|
|
|
|
/// `None` is allowed for development against MailHog / a local
|
|
|
|
|
/// netcat trap. Production deployments using `None` get a startup
|
|
|
|
|
/// `WARN` log so the choice is visible in operational telemetry.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum SmtpTlsMode {
|
|
|
|
|
/// Plain submission with `STARTTLS` upgrade (RFC 3207). Standard
|
|
|
|
|
/// for port 587.
|
|
|
|
|
Starttls,
|
|
|
|
|
/// Implicit TLS from the first byte (RFC 8314). Standard for
|
|
|
|
|
/// port 465.
|
|
|
|
|
Tls,
|
|
|
|
|
/// No encryption. Development only.
|
|
|
|
|
None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SmtpTlsMode {
|
|
|
|
|
fn parse(s: &str) -> Option<Self> {
|
|
|
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"starttls" => Some(Self::Starttls),
|
|
|
|
|
"tls" | "implicit" | "smtps" => Some(Self::Tls),
|
|
|
|
|
"none" | "plain" => Some(Self::None),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Outbound SMTP transport configuration. Sourced exclusively from
|
|
|
|
|
/// `OXICLOUD_SMTP_*` env vars. `host` empty means the feature is
|
|
|
|
|
/// disabled — every endpoint that needs email returns 503 in that
|
|
|
|
|
/// state so admins notice misconfiguration immediately rather than
|
|
|
|
|
/// silently dropping mail.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct SmtpConfig {
|
|
|
|
|
/// SMTP server hostname or IP. Empty string disables the feature.
|
|
|
|
|
pub host: String,
|
|
|
|
|
/// Submission port (typically 587 for STARTTLS, 465 for implicit
|
|
|
|
|
/// TLS, 25 for relay-to-relay).
|
|
|
|
|
pub port: u16,
|
|
|
|
|
/// SASL username. Empty = no authentication (anonymous relay).
|
|
|
|
|
pub user: String,
|
|
|
|
|
/// SASL password. Logged as `***` redacted in startup banner.
|
|
|
|
|
pub pass: String,
|
|
|
|
|
/// `From:` mailbox. Either a bare address (`noreply@example.com`)
|
|
|
|
|
/// or RFC 5322 name-address (`OxiCloud <noreply@example.com>`).
|
|
|
|
|
pub from: String,
|
|
|
|
|
/// Transport encryption mode. See [`SmtpTlsMode`].
|
|
|
|
|
pub tls: SmtpTlsMode,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for SmtpConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
host: String::new(),
|
|
|
|
|
port: 587,
|
|
|
|
|
user: String::new(),
|
|
|
|
|
pass: String::new(),
|
|
|
|
|
from: String::new(),
|
|
|
|
|
tls: SmtpTlsMode::Starttls,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SmtpConfig {
|
|
|
|
|
/// `true` iff `OXICLOUD_SMTP_HOST` was set to a non-empty value.
|
|
|
|
|
/// Used by DI to decide whether to construct an `EmailSender`.
|
|
|
|
|
pub fn is_enabled(&self) -> bool {
|
|
|
|
|
!self.host.is_empty()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:57:07 +02:00
|
|
|
/// Magic-link authentication configuration. Knobs that are specific to
|
|
|
|
|
/// the invite-by-email / login-via-email flow.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct MagicLinkConfig {
|
2026-06-02 23:12:41 +02:00
|
|
|
/// TTL for **login-via-email** tokens (the ones a user requests
|
|
|
|
|
/// themselves from their own browser). Short by design — the user
|
|
|
|
|
/// just clicked the button moments before; if they take >10 minutes
|
|
|
|
|
/// to click the link, something's wrong. Combined with the per-
|
|
|
|
|
/// request challenge cookie (PR 22), this bounds the window for
|
|
|
|
|
/// mailbox compromise to turn into a session.
|
|
|
|
|
///
|
|
|
|
|
/// Default: 10 minutes.
|
|
|
|
|
pub login_ttl_minutes: u64,
|
|
|
|
|
/// TTL for **invitation** tokens (the ones a sharer mints via
|
|
|
|
|
/// `POST /api/grants` for a recipient who has no prior browser
|
|
|
|
|
/// context with the server). Long because the recipient may not
|
|
|
|
|
/// check their email for hours or days. Cross-device by design;
|
|
|
|
|
/// no challenge cookie.
|
|
|
|
|
///
|
|
|
|
|
/// Default: 24 hours. The legacy `OXICLOUD_MAGIC_LINK_TTL_HOURS`
|
|
|
|
|
/// env var is a deprecated alias that writes here.
|
|
|
|
|
pub invite_ttl_hours: u64,
|
2026-06-01 21:57:07 +02:00
|
|
|
/// Kill switch for the whole magic-link flow. When `false`:
|
|
|
|
|
/// - `POST /api/grants` rejects `subject.type = "email"` for unknown
|
|
|
|
|
/// email addresses (no lazy external-user creation).
|
|
|
|
|
/// - `POST /api/auth/magic-link/send` returns the uniform stub
|
|
|
|
|
/// response without actually issuing a token.
|
|
|
|
|
///
|
2026-06-02 00:09:19 +02:00
|
|
|
/// This is the coarser "turn it all off" switch; the fine-grained
|
|
|
|
|
/// version is [`allowed_email_domains`] below.
|
2026-06-01 21:57:07 +02:00
|
|
|
pub allow_external_users: bool,
|
2026-06-02 00:09:19 +02:00
|
|
|
/// Allowlist of email domains accepted when minting a new external
|
|
|
|
|
/// user. Empty = no restriction (any domain is allowed, subject to
|
|
|
|
|
/// [`allow_external_users`]). Entries are lowercased and trimmed
|
|
|
|
|
/// at load time; matching is case-insensitive exact-match on the
|
|
|
|
|
/// post-`@` part of the address.
|
|
|
|
|
///
|
|
|
|
|
/// Example: `["partner-a.com", "partner-b.io"]` — only addresses
|
|
|
|
|
/// `<anything>@partner-a.com` or `<anything>@partner-b.io` can be
|
|
|
|
|
/// invited; everything else is rejected with 403.
|
|
|
|
|
///
|
|
|
|
|
/// Wildcards / subdomain semantics are intentionally out of scope:
|
|
|
|
|
/// `partner.com` does NOT match `eng.partner.com`. List every
|
|
|
|
|
/// subdomain explicitly.
|
|
|
|
|
pub allowed_email_domains: Vec<String>,
|
2026-06-02 14:23:31 +02:00
|
|
|
/// Per-sharer ceiling on email-typed grant invitations from
|
|
|
|
|
/// `POST /api/grants`. Keyed on `caller_id`. Exceeding the ceiling
|
|
|
|
|
/// returns 429. Default: 50/hour.
|
|
|
|
|
pub invite_per_caller_per_hour: u32,
|
|
|
|
|
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`,
|
|
|
|
|
/// keyed on the normalised recipient address. Anti-bombing.
|
|
|
|
|
/// Exceeding the ceiling is silently absorbed (uniform 200) so
|
|
|
|
|
/// the response shape can't be used as an enumeration oracle.
|
|
|
|
|
/// Default: 5/hour.
|
|
|
|
|
pub send_per_email_per_hour: u32,
|
|
|
|
|
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`,
|
|
|
|
|
/// keyed on the trusted client IP. Bounds the cost of an attacker
|
|
|
|
|
/// spreading low per-email volume across many target addresses.
|
|
|
|
|
/// Default: 200/hour.
|
|
|
|
|
pub send_per_ip_per_hour: u32,
|
2026-06-02 22:30:00 +02:00
|
|
|
/// Policy switch: whether magic-link is offered to users who
|
|
|
|
|
/// already have a password configured.
|
|
|
|
|
///
|
|
|
|
|
/// - `false` (default, strict): users with a password get
|
|
|
|
|
/// audit-logged `has_password` and no mail. Their password is
|
|
|
|
|
/// the only authentication path; magic-link would weaken it to
|
|
|
|
|
/// "mailbox compromise = account compromise".
|
|
|
|
|
/// - `true` (lenient): users with a password can also request a
|
|
|
|
|
/// magic-link as a sign-in path. Aligns with modern SaaS UX
|
|
|
|
|
/// (Slack, Notion, etc.) — operators who treat email as the
|
|
|
|
|
/// canonical recovery channel anyway pick this.
|
|
|
|
|
///
|
|
|
|
|
/// OIDC-linked users are **always** rejected from magic-link
|
|
|
|
|
/// regardless of this flag — the IdP is the security boundary and
|
|
|
|
|
/// may enforce MFA we shouldn't bypass. See
|
|
|
|
|
/// `magic_link_eligibility()` for the precedence ladder.
|
|
|
|
|
pub open_to_password_users: bool,
|
2026-06-01 21:57:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for MagicLinkConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-06-02 23:12:41 +02:00
|
|
|
login_ttl_minutes: 10,
|
|
|
|
|
invite_ttl_hours: 24,
|
2026-06-01 21:57:07 +02:00
|
|
|
allow_external_users: true,
|
2026-06-02 00:09:19 +02:00
|
|
|
allowed_email_domains: Vec::new(),
|
2026-06-02 14:23:31 +02:00
|
|
|
invite_per_caller_per_hour: 50,
|
|
|
|
|
send_per_email_per_hour: 5,
|
|
|
|
|
send_per_ip_per_hour: 200,
|
2026-06-02 22:30:00 +02:00
|
|
|
open_to_password_users: false,
|
2026-06-01 21:57:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:09:19 +02:00
|
|
|
impl MagicLinkConfig {
|
|
|
|
|
/// Whether an email address is allowed under the current allowlist.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `true` when the allowlist is empty (no restriction).
|
|
|
|
|
/// Otherwise the domain part of `email` (lowercased) must match one
|
|
|
|
|
/// of the allowlist entries exactly. Malformed addresses without an
|
|
|
|
|
/// `@` always return `false` — fail closed so a typo in the
|
|
|
|
|
/// upstream validator can't slip past this check.
|
|
|
|
|
///
|
|
|
|
|
/// Caller is expected to have already passed `email` through the
|
|
|
|
|
/// email regex / normaliser; this method does not re-validate. It
|
|
|
|
|
/// only performs the domain comparison.
|
|
|
|
|
pub fn is_email_allowed(&self, email: &str) -> bool {
|
|
|
|
|
if self.allowed_email_domains.is_empty() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
let Some((_, domain)) = email.rsplit_once('@') else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let domain_lc = domain.to_ascii_lowercase();
|
|
|
|
|
self.allowed_email_domains
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.as_str() == domain_lc.as_str())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Feature configuration (feature flags)
|
2025-03-20 09:22:31 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct FeaturesConfig {
|
|
|
|
|
pub enable_auth: bool,
|
|
|
|
|
pub enable_user_storage_quotas: bool,
|
|
|
|
|
pub enable_file_sharing: bool,
|
2025-03-24 16:47:42 +01:00
|
|
|
pub enable_trash: bool,
|
2025-03-27 01:13:34 +01:00
|
|
|
pub enable_search: bool,
|
2026-04-08 15:14:03 +03:00
|
|
|
pub enable_music: bool,
|
2026-05-10 22:12:11 +02:00
|
|
|
/// Expose other OxiCloud users as a read-only "system" address book
|
|
|
|
|
/// at GET /api/address-books. Set to false to hide the user directory.
|
|
|
|
|
pub expose_system_users: bool,
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for FeaturesConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-02-14 01:29:34 +01:00
|
|
|
enable_auth: true, // Enable authentication by default
|
2025-03-20 09:22:31 +01:00
|
|
|
enable_user_storage_quotas: false,
|
2026-02-14 01:29:34 +01:00
|
|
|
enable_file_sharing: true, // Enable file sharing by default
|
|
|
|
|
enable_trash: true, // Enable trash feature
|
|
|
|
|
enable_search: true, // Enable search feature
|
2026-04-08 15:14:03 +03:00
|
|
|
enable_music: true, // Enable music feature
|
2026-05-10 22:12:11 +02:00
|
|
|
expose_system_users: true, // Expose OxiCloud users as address book by default
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Global application configuration
|
2025-03-19 00:44:27 +01:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct AppConfig {
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Storage directory path
|
2025-03-20 09:22:31 +01:00
|
|
|
pub storage_path: PathBuf,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Static files directory path
|
2025-03-20 09:22:31 +01:00
|
|
|
pub static_path: PathBuf,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Server port
|
2025-03-20 09:22:31 +01:00
|
|
|
pub server_port: u16,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Server host
|
2025-03-20 09:22:31 +01:00
|
|
|
pub server_host: String,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Cache configuration
|
2025-03-19 19:52:12 +01:00
|
|
|
pub cache: CacheConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Timeout configuration
|
2025-03-19 00:44:27 +01:00
|
|
|
pub timeouts: TimeoutConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Resource configuration
|
2025-03-19 00:44:27 +01:00
|
|
|
pub resources: ResourceConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Concurrency configuration
|
2025-03-19 00:44:27 +01:00
|
|
|
pub concurrency: ConcurrencyConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Storage configuration
|
2025-03-24 16:47:42 +01:00
|
|
|
pub storage: StorageConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Database configuration
|
2025-03-20 09:22:31 +01:00
|
|
|
pub database: DatabaseConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Authentication configuration
|
2025-03-20 09:22:31 +01:00
|
|
|
pub auth: AuthConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Feature configuration
|
2025-03-20 09:22:31 +01:00
|
|
|
pub features: FeaturesConfig,
|
2026-02-12 09:41:25 +01:00
|
|
|
/// OIDC configuration
|
2026-02-10 20:32:32 +01:00
|
|
|
pub oidc: OidcConfig,
|
2026-02-21 13:39:27 +01:00
|
|
|
/// WOPI configuration
|
|
|
|
|
pub wopi: WopiConfig,
|
2026-03-04 14:02:15 +01:00
|
|
|
/// Nextcloud compatibility configuration
|
|
|
|
|
pub nextcloud: NextcloudConfig,
|
2026-06-01 21:14:24 +02:00
|
|
|
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
|
|
|
|
pub smtp: SmtpConfig,
|
2026-06-01 21:57:07 +02:00
|
|
|
/// Magic-link authentication configuration (TTL, external-users kill switch)
|
|
|
|
|
pub magic_link: MagicLinkConfig,
|
2026-06-03 13:18:05 +02:00
|
|
|
/// I18n configuration (default locale for server-rendered surfaces)
|
|
|
|
|
pub i18n: I18nConfig,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Server-side i18n knobs.
|
|
|
|
|
///
|
|
|
|
|
/// Locale discovery itself is driven by `static/locales/*.json` at boot
|
|
|
|
|
/// (see [`crate::common::locale::LocaleRegistry`]) — no hardcoded list,
|
|
|
|
|
/// no `build.rs`. This struct only carries the configurable defaults
|
|
|
|
|
/// around that discovery.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct I18nConfig {
|
|
|
|
|
/// Fallback locale used when:
|
|
|
|
|
/// - an anonymous request's `Accept-Language` matches nothing in
|
|
|
|
|
/// the registry,
|
|
|
|
|
/// - a user's `preferred_locale` is `NULL`,
|
|
|
|
|
/// - an OIDC `locale` claim doesn't resolve.
|
|
|
|
|
///
|
|
|
|
|
/// Must be present in `static/locales/`; the registry-build step
|
|
|
|
|
/// errors at startup if this is set to a locale we don't ship.
|
|
|
|
|
/// Defaults to `"en"`. Override via `OXICLOUD_DEFAULT_LOCALE`.
|
|
|
|
|
pub default_locale: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for I18nConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
default_locale: "en".to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for AppConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2025-03-20 09:22:31 +01:00
|
|
|
storage_path: PathBuf::from("./storage"),
|
|
|
|
|
static_path: PathBuf::from("./static"),
|
2026-02-21 18:41:18 -08:00
|
|
|
server_port: 8086,
|
2025-03-20 09:22:31 +01:00
|
|
|
server_host: "127.0.0.1".to_string(),
|
2025-03-19 19:52:12 +01:00
|
|
|
cache: CacheConfig::default(),
|
2025-03-19 00:44:27 +01:00
|
|
|
timeouts: TimeoutConfig::default(),
|
|
|
|
|
resources: ResourceConfig::default(),
|
|
|
|
|
concurrency: ConcurrencyConfig::default(),
|
2025-03-24 16:47:42 +01:00
|
|
|
storage: StorageConfig::default(),
|
2025-03-20 09:22:31 +01:00
|
|
|
database: DatabaseConfig::default(),
|
|
|
|
|
auth: AuthConfig::default(),
|
|
|
|
|
features: FeaturesConfig::default(),
|
2026-02-10 20:32:32 +01:00
|
|
|
oidc: OidcConfig::default(),
|
2026-02-21 13:39:27 +01:00
|
|
|
wopi: WopiConfig::default(),
|
2026-03-04 14:02:15 +01:00
|
|
|
nextcloud: NextcloudConfig::default(),
|
2026-06-01 21:14:24 +02:00
|
|
|
smtp: SmtpConfig::default(),
|
2026-06-01 21:57:07 +02:00
|
|
|
magic_link: MagicLinkConfig::default(),
|
2026-06-03 13:18:05 +02:00
|
|
|
i18n: I18nConfig::default(),
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AppConfig {
|
|
|
|
|
pub fn from_env() -> Self {
|
|
|
|
|
let mut config = Self::default();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Use environment variables to override default values
|
2025-03-20 09:22:31 +01:00
|
|
|
if let Ok(storage_path) = env::var("OXICLOUD_STORAGE_PATH") {
|
|
|
|
|
config.storage_path = PathBuf::from(storage_path);
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
if let Ok(static_path) = env::var("OXICLOUD_STATIC_PATH") {
|
|
|
|
|
config.static_path = PathBuf::from(static_path);
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-14 01:26:02 +01:00
|
|
|
if let Ok(server_port) = env::var("OXICLOUD_SERVER_PORT")
|
2026-02-14 01:29:34 +01:00
|
|
|
&& let Ok(port) = server_port.parse::<u16>()
|
|
|
|
|
{
|
|
|
|
|
config.server_port = port;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
if let Ok(server_host) = env::var("OXICLOUD_SERVER_HOST") {
|
|
|
|
|
config.server_host = server_host;
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Database configuration
|
2025-03-20 09:22:31 +01:00
|
|
|
if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") {
|
|
|
|
|
config.database.connection_string = connection_string;
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
if let Ok(max_connections) =
|
|
|
|
|
env::var("OXICLOUD_DB_MAX_CONNECTIONS").map(|v| v.parse::<u32>())
|
|
|
|
|
&& 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::<u32>())
|
|
|
|
|
&& let Ok(val) = min_connections
|
|
|
|
|
{
|
|
|
|
|
config.database.min_connections = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 19:28:00 +01:00
|
|
|
if let Ok(max_conn) =
|
|
|
|
|
env::var("OXICLOUD_DB_MAINTENANCE_MAX_CONNECTIONS").map(|v| v.parse::<u32>())
|
|
|
|
|
&& 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::<u32>())
|
|
|
|
|
&& let Ok(val) = min_conn
|
|
|
|
|
{
|
|
|
|
|
config.database.maintenance_min_connections = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Auth configuration
|
2026-03-05 16:32:42 -05:00
|
|
|
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!("==========================================================");
|
2026-03-05 16:57:44 +01:00
|
|
|
}
|
2026-03-05 16:32:42 -05:00
|
|
|
config.auth.jwt_secret = jwt_secret;
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-05 22:12:21 +01:00
|
|
|
// SECURITY: Auto-persist JWT secret to storage so it survives restarts.
|
|
|
|
|
// Priority: env var > persisted file > generate new.
|
2026-02-08 13:40:23 +01:00
|
|
|
if config.features.enable_auth && config.auth.jwt_secret.is_empty() {
|
2026-03-05 22:12:21 +01:00
|
|
|
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;
|
2026-03-05 16:32:42 -05:00
|
|
|
tracing::info!("JWT secret loaded from {}", secret_file.display());
|
2026-03-05 22:12:21 +01:00
|
|
|
} 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);
|
2026-03-05 16:32:42 -05:00
|
|
|
let generated_secret: String = key.iter().map(|b| format!("{:02x}", b)).collect();
|
2026-03-05 22:12:21 +01:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
if let Ok(access_token_expiry) =
|
|
|
|
|
env::var("OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS").map(|v| v.parse::<i64>())
|
|
|
|
|
&& 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::<i64>())
|
|
|
|
|
&& let Ok(val) = refresh_token_expiry
|
|
|
|
|
{
|
|
|
|
|
config.auth.refresh_token_expiry_secs = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
// Argon2 hashing parameters
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_HASH_MEMORY_COST").map(|v| v.parse::<u32>())
|
|
|
|
|
&& let Ok(val) = v
|
|
|
|
|
{
|
|
|
|
|
config.auth.hash_memory_cost = val;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_HASH_TIME_COST").map(|v| v.parse::<u32>())
|
|
|
|
|
&& let Ok(val) = v
|
|
|
|
|
{
|
|
|
|
|
config.auth.hash_time_cost = val;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_HASH_PARALLELISM").map(|v| v.parse::<u32>())
|
|
|
|
|
&& let Ok(val) = v
|
|
|
|
|
{
|
|
|
|
|
config.auth.hash_parallelism = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 01:44:39 +01:00
|
|
|
// Rate limiting / account lockout
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_LOGIN_MAX").map(|v| v.parse::<u32>())
|
|
|
|
|
&& 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::<u64>())
|
|
|
|
|
&& 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::<u32>())
|
|
|
|
|
&& let Ok(val) = v
|
|
|
|
|
{
|
|
|
|
|
config.auth.rate_limit.register_max_requests = val;
|
|
|
|
|
}
|
2026-03-03 01:49:18 +01:00
|
|
|
if let Ok(v) =
|
|
|
|
|
env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::<u64>())
|
2026-03-03 01:44:39 +01:00
|
|
|
&& 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::<u32>())
|
|
|
|
|
&& 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::<u64>())
|
|
|
|
|
&& 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::<u32>())
|
|
|
|
|
&& 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::<u64>())
|
|
|
|
|
&& let Ok(val) = v
|
|
|
|
|
{
|
|
|
|
|
config.auth.rate_limit.lockout_duration_secs = val;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
// Feature flags
|
2026-02-14 01:29:34 +01:00
|
|
|
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
|
|
|
|
&& 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::<bool>())
|
|
|
|
|
&& 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::<bool>())
|
|
|
|
|
&& 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::<bool>())
|
|
|
|
|
&& let Ok(val) = enable_trash
|
|
|
|
|
{
|
|
|
|
|
config.features.enable_trash = val;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::<bool>())
|
|
|
|
|
&& let Ok(val) = enable_search
|
|
|
|
|
{
|
|
|
|
|
config.features.enable_search = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 15:14:03 +03:00
|
|
|
if let Ok(enable_music) = env::var("OXICLOUD_ENABLE_MUSIC").map(|v| v.parse::<bool>())
|
|
|
|
|
&& let Ok(val) = enable_music
|
|
|
|
|
{
|
|
|
|
|
config.features.enable_music = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:12:11 +02:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::<bool>())
|
|
|
|
|
&& let Ok(val) = v
|
|
|
|
|
{
|
|
|
|
|
config.features.expose_system_users = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 23:28:03 +01:00
|
|
|
// Storage limits
|
2026-02-25 10:28:34 +01:00
|
|
|
if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::<usize>())
|
2026-02-22 23:28:03 +01:00
|
|
|
&& let Ok(val) = max_upload
|
|
|
|
|
{
|
|
|
|
|
config.storage.max_upload_size = val;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 21:33:38 +02:00
|
|
|
// 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::<bool>().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::<bool>().unwrap_or(false);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_STORAGE_CACHE_MAX_SIZE")
|
|
|
|
|
&& let Ok(bytes) = v.parse::<u64>()
|
|
|
|
|
{
|
|
|
|
|
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::<bool>().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::<bool>().unwrap_or(true);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_MAX_RETRIES")
|
|
|
|
|
&& let Ok(n) = v.parse::<u32>()
|
|
|
|
|
{
|
|
|
|
|
config.storage.retry.max_retries = n;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_INITIAL_BACKOFF_MS")
|
|
|
|
|
&& let Ok(n) = v.parse::<u64>()
|
|
|
|
|
{
|
|
|
|
|
config.storage.retry.initial_backoff_ms = n;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_MAX_BACKOFF_MS")
|
|
|
|
|
&& let Ok(n) = v.parse::<u64>()
|
|
|
|
|
{
|
|
|
|
|
config.storage.retry.max_backoff_ms = n;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_STORAGE_RETRY_BACKOFF_MULTIPLIER")
|
|
|
|
|
&& let Ok(n) = v.parse::<f64>()
|
|
|
|
|
{
|
|
|
|
|
config.storage.retry.backoff_multiplier = n;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 20:32:32 +01:00
|
|
|
// OIDC configuration
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") {
|
|
|
|
|
config.oidc.enabled = v.parse::<bool>().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::<bool>().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::<bool>().unwrap_or(false);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") {
|
|
|
|
|
config.oidc.provider_name = v;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate OIDC config when enabled
|
2026-02-14 01:26:02 +01:00
|
|
|
if config.oidc.enabled
|
2026-02-14 01:29:34 +01:00
|
|
|
&& (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;
|
|
|
|
|
}
|
2026-02-10 20:32:32 +01:00
|
|
|
|
2026-02-21 13:39:27 +01:00
|
|
|
// WOPI configuration
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_WOPI_ENABLED") {
|
|
|
|
|
config.wopi.enabled = v.parse::<bool>().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::<i64>()
|
|
|
|
|
{
|
|
|
|
|
config.wopi.token_ttl_secs = val;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_WOPI_LOCK_TTL_SECS")
|
|
|
|
|
&& let Ok(val) = v.parse::<u64>()
|
|
|
|
|
{
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:02:15 +01:00
|
|
|
// Nextcloud compatibility configuration
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_ENABLED") {
|
|
|
|
|
config.nextcloud.enabled = v.parse::<bool>().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::<u32>(),
|
|
|
|
|
parts[1].parse::<u32>(),
|
|
|
|
|
parts[2].parse::<u32>(),
|
|
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
config.nextcloud.emulated_version = (maj, min, pat);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:14:24 +02:00
|
|
|
// SMTP configuration. `HOST` empty = feature disabled — every
|
|
|
|
|
// endpoint that needs email returns 503 in that state.
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_SMTP_HOST") {
|
|
|
|
|
config.smtp.host = v.trim().to_string();
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_SMTP_PORT")
|
|
|
|
|
&& let Ok(p) = v.parse::<u16>()
|
|
|
|
|
{
|
|
|
|
|
config.smtp.port = p;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_SMTP_USER") {
|
|
|
|
|
config.smtp.user = v;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_SMTP_PASS") {
|
|
|
|
|
config.smtp.pass = v;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_SMTP_FROM") {
|
|
|
|
|
config.smtp.from = v;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_SMTP_TLS")
|
|
|
|
|
&& let Some(mode) = SmtpTlsMode::parse(&v)
|
|
|
|
|
{
|
|
|
|
|
config.smtp.tls = mode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if config.smtp.is_enabled() && config.smtp.tls == SmtpTlsMode::None {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"OXICLOUD_SMTP_TLS=none — outbound mail will travel in plaintext. \
|
|
|
|
|
Use 'starttls' or 'tls' for production deployments."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:57:07 +02:00
|
|
|
// Magic-link configuration
|
2026-06-02 23:12:41 +02:00
|
|
|
// Legacy `OXICLOUD_MAGIC_LINK_TTL_HOURS` is preserved as a
|
|
|
|
|
// deprecated alias for `OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS`.
|
|
|
|
|
// Existing deployments keep working with their old env var;
|
|
|
|
|
// the new explicit var wins if both are set.
|
2026-06-01 21:57:07 +02:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_TTL_HOURS")
|
|
|
|
|
&& let Ok(h) = v.parse::<u64>()
|
|
|
|
|
&& h > 0
|
|
|
|
|
{
|
2026-06-02 23:12:41 +02:00
|
|
|
tracing::warn!(
|
|
|
|
|
"OXICLOUD_MAGIC_LINK_TTL_HOURS is deprecated — \
|
|
|
|
|
use OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS (invitations) \
|
|
|
|
|
and OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES (login-via-email)."
|
|
|
|
|
);
|
|
|
|
|
config.magic_link.invite_ttl_hours = h;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS")
|
|
|
|
|
&& let Ok(h) = v.parse::<u64>()
|
|
|
|
|
&& h > 0
|
|
|
|
|
{
|
|
|
|
|
config.magic_link.invite_ttl_hours = h;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES")
|
|
|
|
|
&& let Ok(m) = v.parse::<u64>()
|
|
|
|
|
&& m > 0
|
|
|
|
|
{
|
|
|
|
|
config.magic_link.login_ttl_minutes = m;
|
2026-06-01 21:57:07 +02:00
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") {
|
|
|
|
|
config.magic_link.allow_external_users = v.parse::<bool>().unwrap_or(true);
|
|
|
|
|
}
|
2026-06-02 00:09:19 +02:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_EXTERNAL_EMAIL_DOMAINS") {
|
|
|
|
|
config.magic_link.allowed_email_domains = v
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(|d| d.trim().to_ascii_lowercase())
|
|
|
|
|
.filter(|d| !d.is_empty())
|
|
|
|
|
.collect();
|
|
|
|
|
}
|
2026-06-02 14:23:31 +02:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR")
|
|
|
|
|
&& let Ok(n) = v.parse::<u32>()
|
|
|
|
|
&& n > 0
|
|
|
|
|
{
|
|
|
|
|
config.magic_link.invite_per_caller_per_hour = n;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR")
|
|
|
|
|
&& let Ok(n) = v.parse::<u32>()
|
|
|
|
|
&& n > 0
|
|
|
|
|
{
|
|
|
|
|
config.magic_link.send_per_email_per_hour = n;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR")
|
|
|
|
|
&& let Ok(n) = v.parse::<u32>()
|
|
|
|
|
&& n > 0
|
|
|
|
|
{
|
|
|
|
|
config.magic_link.send_per_ip_per_hour = n;
|
|
|
|
|
}
|
2026-06-02 22:30:00 +02:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
|
|
|
|
|
config.magic_link.open_to_password_users = v == "true" || v == "1";
|
|
|
|
|
}
|
2026-06-01 21:57:07 +02:00
|
|
|
|
2026-06-03 13:18:05 +02:00
|
|
|
if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") {
|
|
|
|
|
let trimmed = v.trim();
|
|
|
|
|
if !trimmed.is_empty() {
|
|
|
|
|
config.i18n.default_locale = trimmed.to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
config
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn with_features(mut self, features: FeaturesConfig) -> Self {
|
|
|
|
|
self.features = features;
|
|
|
|
|
self
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn db_enabled(&self) -> bool {
|
|
|
|
|
self.features.enable_auth
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn auth_enabled(&self) -> bool {
|
|
|
|
|
self.features.enable_auth
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 00:18:59 +01:00
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Gets a default global configuration
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn default_config() -> AppConfig {
|
|
|
|
|
AppConfig::default()
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
2026-06-02 00:09:19 +02:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn empty_allowlist_accepts_any_email() {
|
|
|
|
|
let cfg = MagicLinkConfig::default();
|
|
|
|
|
assert!(cfg.allowed_email_domains.is_empty());
|
|
|
|
|
assert!(cfg.is_email_allowed("alice@example.com"));
|
|
|
|
|
assert!(cfg.is_email_allowed("bob@whatever.io"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn allowlist_matches_case_insensitively() {
|
|
|
|
|
let cfg = MagicLinkConfig {
|
|
|
|
|
allowed_email_domains: vec!["partner-a.com".to_string(), "partner-b.io".to_string()],
|
2026-06-02 14:23:31 +02:00
|
|
|
..MagicLinkConfig::default()
|
2026-06-02 00:09:19 +02:00
|
|
|
};
|
|
|
|
|
assert!(cfg.is_email_allowed("alice@partner-a.com"));
|
|
|
|
|
// Uppercase domain in the email — must still match.
|
|
|
|
|
assert!(cfg.is_email_allowed("alice@PARTNER-A.COM"));
|
|
|
|
|
assert!(cfg.is_email_allowed("eve@partner-b.io"));
|
|
|
|
|
// Unlisted domain — rejected.
|
|
|
|
|
assert!(!cfg.is_email_allowed("mallory@other.com"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn allowlist_does_not_match_subdomains_implicitly() {
|
|
|
|
|
let cfg = MagicLinkConfig {
|
|
|
|
|
allowed_email_domains: vec!["partner.com".to_string()],
|
2026-06-02 14:23:31 +02:00
|
|
|
..MagicLinkConfig::default()
|
2026-06-02 00:09:19 +02:00
|
|
|
};
|
|
|
|
|
assert!(cfg.is_email_allowed("alice@partner.com"));
|
|
|
|
|
// Subdomain must be listed explicitly — exact match only.
|
|
|
|
|
assert!(!cfg.is_email_allowed("alice@eng.partner.com"));
|
|
|
|
|
// Suffix match is not enough — different domain.
|
|
|
|
|
assert!(!cfg.is_email_allowed("alice@evilpartner.com"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn malformed_email_fails_closed() {
|
|
|
|
|
let cfg = MagicLinkConfig {
|
|
|
|
|
allowed_email_domains: vec!["partner.com".to_string()],
|
2026-06-02 14:23:31 +02:00
|
|
|
..MagicLinkConfig::default()
|
2026-06-02 00:09:19 +02:00
|
|
|
};
|
|
|
|
|
// No `@` — rejected even though allowlist is set.
|
|
|
|
|
assert!(!cfg.is_email_allowed("not-an-email"));
|
|
|
|
|
assert!(!cfg.is_email_allowed(""));
|
|
|
|
|
}
|
|
|
|
|
}
|