8fc9a50681
magic-link as now 2 modes:
- invitation: long TTL (24), no challenge
- passwordless login: short TTL (10min), cookie challenge to ensure that
user goes back to same browser (no man in the middle capturing email)
1542 lines
55 KiB
Rust
1542 lines
55 KiB
Rust
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<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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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::<bool>().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::<bool>().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::<bool>().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)
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
/// Magic-link authentication configuration. Knobs that are specific to
|
|
/// the invite-by-email / login-via-email flow.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MagicLinkConfig {
|
|
/// 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,
|
|
/// 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.
|
|
///
|
|
/// This is the coarser "turn it all off" switch; the fine-grained
|
|
/// version is [`allowed_email_domains`] below.
|
|
pub allow_external_users: bool,
|
|
/// 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>,
|
|
/// 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,
|
|
/// 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,
|
|
}
|
|
|
|
impl Default for MagicLinkConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
login_ttl_minutes: 10,
|
|
invite_ttl_hours: 24,
|
|
allow_external_users: true,
|
|
allowed_email_domains: Vec::new(),
|
|
invite_per_caller_per_hour: 50,
|
|
send_per_email_per_hour: 5,
|
|
send_per_ip_per_hour: 200,
|
|
open_to_password_users: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// 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,
|
|
}
|
|
|
|
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
|
|
expose_system_users: true, // Expose OxiCloud users as address book by default
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
|
pub smtp: SmtpConfig,
|
|
/// Magic-link authentication configuration (TTL, external-users kill switch)
|
|
pub magic_link: MagicLinkConfig,
|
|
}
|
|
|
|
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(),
|
|
smtp: SmtpConfig::default(),
|
|
magic_link: MagicLinkConfig::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::<u16>()
|
|
{
|
|
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::<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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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::<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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
if let Ok(v) =
|
|
env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::<u64>())
|
|
&& 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;
|
|
}
|
|
|
|
// Feature flags
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Storage limits
|
|
if let Ok(max_upload) = env::var("OXICLOUD_MAX_UPLOAD_SIZE").map(|v| v.parse::<usize>())
|
|
&& 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::<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;
|
|
}
|
|
|
|
// 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
|
|
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::<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");
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// 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."
|
|
);
|
|
}
|
|
|
|
// Magic-link configuration
|
|
// 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.
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_TTL_HOURS")
|
|
&& let Ok(h) = v.parse::<u64>()
|
|
&& h > 0
|
|
{
|
|
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;
|
|
}
|
|
if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") {
|
|
config.magic_link.allow_external_users = v.parse::<bool>().unwrap_or(true);
|
|
}
|
|
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();
|
|
}
|
|
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;
|
|
}
|
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
|
|
config.magic_link.open_to_password_users = v == "true" || v == "1";
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
#[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()],
|
|
..MagicLinkConfig::default()
|
|
};
|
|
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()],
|
|
..MagicLinkConfig::default()
|
|
};
|
|
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()],
|
|
..MagicLinkConfig::default()
|
|
};
|
|
// No `@` — rejected even though allowlist is set.
|
|
assert!(!cfg.is_email_allowed("not-an-email"));
|
|
assert!(!cfg.is_email_allowed(""));
|
|
}
|
|
}
|