use sqlx::PgPool; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::application::ports::blob_storage_ports::BlobStorageBackend; use crate::common::config::StorageBackendType; use crate::infrastructure::db::DbPools; use crate::application::services::admin_settings_service::AdminSettingsService; use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::storage_settings_service::StorageSettingsService; use crate::infrastructure::services::migration_blob_backend::MigrationState; use crate::application::ports::file_ports::FileUseCaseFactory; use crate::application::services::favorites_service::FavoritesService; use crate::application::services::folder_service::FolderService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService; use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService; use crate::application::services::places_service::PlacesService; use crate::application::services::recent_service::RecentService; use crate::application::services::search_service::SearchService; use crate::application::services::share_browse_service::ShareBrowseService; use crate::application::services::share_service::ShareService; use crate::application::services::trash_service::TrashService; use crate::application::services::{ AppFileUseCaseFactory, FileManagementService, FileRetrievalService, FileUploadService, }; use crate::common::config::AppConfig; use crate::common::errors::DomainError; use crate::common::locale::LocaleRegistry; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::{ FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, TrashDbRepository, }; use crate::infrastructure::services::file_content_cache::{ FileContentCache, FileContentCacheConfig, }; use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService; use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::search_index::content_index_worker::ContentIndexWorker; use crate::infrastructure::services::search_index::tantivy_content_index::TantivyContentIndex; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::app_password_service::AppPasswordService; use crate::application::services::blob_lifecycle_service::BlobLifecycleService; use crate::application::services::calendar_service::CalendarService; use crate::application::services::device_auth_service::DeviceAuthService; use crate::application::services::file_lifecycle_service::FileLifecycleService; use crate::application::services::music_service::MusicService; use crate::application::services::storage_usage_service::StorageUsageService; use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_token_service::WopiTokenService; use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter; use crate::infrastructure::repositories::AppPasswordPgRepository; use crate::infrastructure::repositories::DeviceCodePgRepository; use crate::infrastructure::repositories::pg::{ AddressBookPgRepository, AudioMetadataPgRepository, CalendarEventPgRepository, CalendarPgRepository, ContactGroupPgRepository, ContactPgRepository, PlaylistItemPgRepository, PlaylistPgRepository, SessionPgRepository, UserPgRepository, }; use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::infrastructure::services::chunked_upload_service::ChunkedUploadService; use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::image_transcode_service::ImageTranscodeService; use crate::infrastructure::services::jwt_service::JwtTokenService; use crate::infrastructure::services::media_metadata_service::MediaMetadataService; use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; use crate::infrastructure::services::path_resolver_service::PathResolverService; use crate::infrastructure::services::thumbnail_service::{ThumbnailRefreshHook, ThumbnailService}; use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; use crate::infrastructure::services::zip_service::ZipService; /// Factory for the different application components /// /// This factory centralizes the creation of all application services, /// ensuring the correct initialization order and resolving circular dependencies. pub struct AppServiceFactory { storage_path: PathBuf, locales_path: PathBuf, config: AppConfig, /// Validated set of locales discovered under `locales_path`. Built /// once at factory construction time; consumed by the I18n service /// and the `Accept-Language` extractor. See /// [`crate::common::locale::LocaleRegistry`] for the discovery rules. locale_registry: Arc, } impl AppServiceFactory { /// Creates a new service factory pub fn new(storage_path: PathBuf, locales_path: PathBuf) -> Self { let config = AppConfig::default(); let locale_registry = Self::build_registry(&locales_path, &config); Self { storage_path, locales_path, config, locale_registry, } } /// Creates a new service factory with custom configuration pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self { let locale_registry = Self::build_registry(&locales_path, &config); Self { storage_path, locales_path, config, locale_registry, } } /// Discover locales from disk at boot. A misconfigured default or /// an empty locale directory is treated as a fatal config error — /// fail fast so the operator notices at startup rather than when /// the first magic-link mail is queued. fn build_registry(locales_path: &Path, config: &AppConfig) -> Arc { let registry = LocaleRegistry::discover(locales_path, &config.i18n.default_locale) .unwrap_or_else(|e| { panic!( "Failed to build locale registry from {}: {}. \ Check OXICLOUD_DEFAULT_LOCALE and that static/locales/ \ contains valid *.json files.", locales_path.display(), e ) }); Arc::new(registry) } /// Gets the configuration pub fn config(&self) -> &AppConfig { &self.config } /// Gets the storage path pub fn storage_path(&self) -> &PathBuf { &self.storage_path } /// Initializes the core system services. /// /// Requires a `PgPool` because `DedupService` stores its index in PostgreSQL. /// The `maintenance_pool` is given to `DedupService` for long-running /// operations (verify_integrity, garbage_collect) so they cannot starve /// the primary pool. pub async fn create_core_services( &self, db_pool: &Arc, maintenance_pool: &Arc, ) -> Result { // Path service (still needed for blob storage root + thumbnails) let path_service = Arc::new(PathService::new(self.storage_path.clone())); // File content cache for ultra-fast file serving (hot files in RAM) let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig { max_file_size: 10 * 1024 * 1024, // 10MB max per file max_total_size: 512 * 1024 * 1024, // 512MB total cache max_entries: 10000, // Up to 10k files })); tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries"); // Thumbnail service for thumbnail generation with timeout protection let thumbnail_service = Arc::new( crate::infrastructure::services::thumbnail_service::ThumbnailService::new( &self.storage_path, 5000, // max 5000 thumbnails in cache 100 * 1024 * 1024, // max 100MB cache Some(self.config.timeouts.thumbnail_timeout()), ), ); // Initialize thumbnail directories thumbnail_service.initialize().await?; // Chunked upload service for large files (>10MB). // Root for both REST (`/api/uploads/...`) and NC (`/dav/uploads/...`) // chunked sessions: honour `OXICLOUD_CHUNK_DIR` when set so sysadmins // can put session directories on fast storage (NVMe) or on the same // filesystem as `.blobs/` (turns the final blob promotion into an // atomic rename instead of a cross-FS copy). Falls back to // `{storage_path}/.uploads/` when unset — backwards-compatible with // every existing deployment. let chunk_root = self .config .storage .chunk_dir .clone() .unwrap_or_else(|| std::path::PathBuf::from(&self.storage_path).join(".uploads")); let chunked_upload_service = Arc::new( crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( chunk_root.clone(), ) .await, ); // Image transcoding service for automatic WebP conversion let image_transcode_service = Arc::new( crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( &self.storage_path, 2000, // max 2000 transcoded images in cache 50 * 1024 * 1024, // max 50MB in-memory cache ), ); image_transcode_service.initialize().await?; // Build blob storage backend based on configuration let base_backend: Arc = match self.config.storage.backend { StorageBackendType::S3 => { let s3_config = self .config .storage .s3 .as_ref() .expect("S3 config required when OXICLOUD_STORAGE_BACKEND=s3"); Arc::new( crate::infrastructure::services::s3_blob_backend::S3BlobBackend::new(s3_config), ) } StorageBackendType::Azure => { let az_config = self .config .storage .azure .as_ref() .expect("Azure config required when OXICLOUD_STORAGE_BACKEND=azure"); Arc::new( crate::infrastructure::services::azure_blob_backend::AzureBlobBackend::new( az_config, ), ) } StorageBackendType::Local => Arc::new( crate::infrastructure::services::local_blob_backend::LocalBlobBackend::new( &self.storage_path, ), ), }; // Stack decorators: retry → encryption → cache (inner-to-outer) let mut blob_backend: Arc = base_backend; // Retry decorator (for remote backends) if self.config.storage.retry.enabled && self.config.storage.backend != StorageBackendType::Local { use crate::infrastructure::services::retry_blob_backend::{ RetryBlobBackend, RetryPolicy, }; let policy = RetryPolicy { max_retries: self.config.storage.retry.max_retries, initial_backoff: std::time::Duration::from_millis( self.config.storage.retry.initial_backoff_ms, ), max_backoff: std::time::Duration::from_millis( self.config.storage.retry.max_backoff_ms, ), backoff_multiplier: self.config.storage.retry.backoff_multiplier, }; blob_backend = Arc::new(RetryBlobBackend::new(blob_backend, policy)); tracing::info!("Blob storage retry decorator enabled"); } // Encryption decorator if self.config.storage.encryption.enabled { use crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend; let key_b64 = self .config .storage .encryption .key_base64 .as_ref() .expect("OXICLOUD_STORAGE_ENCRYPTION_KEY required when encryption is enabled"); let key_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key_b64) .expect("OXICLOUD_STORAGE_ENCRYPTION_KEY must be valid base64"); let key: [u8; 32] = key_bytes.try_into().expect( "OXICLOUD_STORAGE_ENCRYPTION_KEY must be exactly 32 bytes (base64 of 32 bytes)", ); blob_backend = Arc::new(EncryptedBlobBackend::new(blob_backend, &key)); tracing::info!("Blob storage encryption decorator enabled (AES-256-GCM)"); } // Cache decorator (for remote backends only) if self.config.storage.cache.enabled && self.config.storage.backend != StorageBackendType::Local { use crate::infrastructure::services::cached_blob_backend::{ BlobCacheConfig as CacheCfg, CachedBlobBackend, }; let cache_path = self .config .storage .cache .cache_path .as_ref() .map(std::path::PathBuf::from) .unwrap_or_else(|| self.storage_path.join(".blob-cache")); let cfg = CacheCfg { cache_dir: cache_path, max_cache_bytes: self.config.storage.cache.max_size_bytes, }; blob_backend = Arc::new(CachedBlobBackend::new(blob_backend, &cfg)); tracing::info!("Blob storage LRU disk cache enabled"); } // Blob lifecycle — thumbnail disk-file cleanup when blob ref_count hits zero. // ThumbnailService (not ThumbnailRefreshHook) is used here to avoid a circular // Arc: DedupService→BlobLifecycleService→ThumbnailRefreshHook→DedupService. let blob_lifecycle = Arc::new(BlobLifecycleService::new().with_hook(thumbnail_service.clone())); // Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index) let dedup_service = Arc::new( crate::infrastructure::services::dedup_service::DedupService::new( blob_backend, db_pool.clone(), maintenance_pool.clone(), ) .with_blob_lifecycle(blob_lifecycle), ); dedup_service.initialize().await?; // One-time background migration: re-chunk pre-CDC whole-file blobs // into chunk manifests so Range reads (and, with encryption, partial // decrypts) stop paying for the entire blob. No-op once converged. if self.config.storage.legacy_rechunk_enabled { dedup_service.spawn_legacy_rechunk(); } else { tracing::info!( "Legacy re-chunk migration disabled (OXICLOUD_LEGACY_RECHUNK=false) — \ pre-CDC whole-file blobs, if any, will keep using the legacy read path" ); } tracing::info!( "Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage)" ); // Audio metadata service — created here so it can be wired into file_lifecycle. let audio_metadata_service = self.create_audio_metadata_service(db_pool); // Image/video capture-metadata service — extracts EXIF/container capture // dates so the Photos timeline groups by real capture time, not upload time. let media_metadata_service = self.create_media_metadata_service(db_pool); // ThumbnailRefreshHook: handles FileLifecycleHook events (create/update/delete). // Implemented on ThumbnailRefreshHook (not ThumbnailService) to avoid circular Arc: // DedupService → BlobLifecycleService → ThumbnailRefreshHook → DedupService. let thumbnail_refresh_hook = Arc::new(ThumbnailRefreshHook::new( thumbnail_service.clone(), dedup_service.clone(), )); // Build the unified FileLifecycleService dispatcher. let mut fls = FileLifecycleService::new().with_hook(thumbnail_refresh_hook); if let Some(audio) = &audio_metadata_service { fls = fls.with_hook(audio.clone()); } fls = fls.with_hook(media_metadata_service.clone()); let file_lifecycle = Arc::new(fls); Ok(CoreServices { path_service, file_content_cache, thumbnail_service, file_lifecycle, audio_metadata_service, media_metadata_service, chunked_upload_service, image_transcode_service, dedup_service, zip_service: None, // Placeholder - replaced after app services init config: self.config.clone(), }) } /// Initializes the repository services (blob-storage model). /// /// Requires a PgPool since all metadata lives in PostgreSQL. pub fn create_repository_services( &self, core: &CoreServices, db_pool: &Arc, ) -> RepositoryServices { // Folder repository — PostgreSQL-backed virtual folders let folder_repo_concrete = Arc::new(FolderDbRepository::new(db_pool.clone())); let folder_repository: Arc = folder_repo_concrete.clone(); // File repositories — PostgreSQL metadata + blob content via DedupService let file_read_repository: Arc = Arc::new(FileBlobReadRepository::new( db_pool.clone(), core.dedup_service.clone(), folder_repo_concrete.clone(), )); let file_write_repository: Arc = Arc::new(FileBlobWriteRepository::new( db_pool.clone(), core.dedup_service.clone(), folder_repo_concrete.clone(), // Shared blob-hash cache: the write side invalidates entries // on content swaps/deletes so reads never serve stale blobs. file_read_repository.blob_hash_cache(), )); // I18n repository — file-system backed, gated by the locale // registry built at factory construction. let i18n_repository = Arc::new(FileSystemI18nService::new( self.locales_path.clone(), self.locale_registry.clone(), )); // Trash repository — reads soft-delete flags from storage.files/folders let trash_repository = if core.config.features.enable_trash { Some(Arc::new(TrashDbRepository::new( db_pool.clone(), core.config.storage.trash_retention_days, )) as Arc) } else { None }; // File metadata repository — EXIF/media metadata for images let file_metadata_repository = Arc::new(FileMetadataRepository::new(db_pool.clone())); tracing::info!( "Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)" ); RepositoryServices { folder_repository, folder_repo_concrete, file_read_repository, file_write_repository, file_metadata_repository, i18n_repository, trash_repository, } } /// Initializes the application services #[allow(clippy::too_many_arguments)] // DI composition root — params are services, not a smell pub fn create_application_services( &self, core: &CoreServices, repos: &RepositoryServices, trash_service: Option>, authz: &Arc, storage_usage: &Arc, content_index: Option>, plugin_dispatch: Option< Arc, >, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new( repos.folder_repository.clone(), authz.clone(), )); // Built before the upload/management services so the plugin lifecycle // bridge (which looks file metadata up by id) can be wired into the // dispatcher they receive. It depends only on repos + core, never on // the upload service, so the reorder is safe. let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( repos.file_read_repository.clone(), core.file_content_cache.clone(), core.image_transcode_service.clone(), authz.clone(), )); // Effective lifecycle dispatcher: the core hooks (thumbnails, metadata) // plus, when the plugins feature is enabled, the WASM plugin bridge. let file_lifecycle = self.effective_file_lifecycle(core, &file_retrieval_service, plugin_dispatch); let file_upload_service = Arc::new( FileUploadService::new_with_read( repos.file_write_repository.clone(), repos.file_read_repository.clone(), ) .with_content_cache(core.file_content_cache.clone()) .with_file_lifecycle_hook(file_lifecycle.clone()) .with_instant_upload( authz.clone(), core.dedup_service.clone(), storage_usage.clone(), ), ); // Delta-upload protocol — chunk negotiation over the same dedup // store. Bounded by the same whole-file ceiling as byte uploads. let delta_upload_service = Arc::new( crate::application::services::delta_upload_service::DeltaUploadService::new( core.dedup_service.clone(), file_upload_service.clone(), repos.file_read_repository.clone(), storage_usage.clone(), authz.clone(), self.config.storage.max_upload_size as u64, self.config.storage.chunk_max_bytes as u64, ), ); // FileManagementService — ref_count handled by PG trigger, no dedup port needed let file_management_service = Arc::new( FileManagementService::with_trash( repos.file_write_repository.clone(), trash_service.clone(), Some(repos.file_read_repository.clone()), Some(repos.folder_repository.clone()), Some(core.file_content_cache.clone()), authz.clone(), ) .with_file_lifecycle_hook(file_lifecycle.clone()), ); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( repos.file_read_repository.clone(), repos.file_write_repository.clone(), authz.clone(), )); let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone())); // Search service with cache. The optional content index widens the // same `/api/search` endpoint to full-text content matches. let content_index_port: Option< Arc, > = content_index.map(|idx| idx as _); let search_service: Option> = Some(Arc::new(SearchService::new( repos.file_read_repository.clone(), repos.folder_repository.clone(), content_index_port, 300, // Cache TTL in seconds (5 minutes) 1000, // Maximum cache entries ))); tracing::info!("Application services initialized"); ApplicationServices { // Concrete types for handlers that need them folder_service_concrete: folder_service.clone(), // Traits for abstraction folder_service, file_upload_service, delta_upload_service, file_retrieval_service, file_management_service, file_use_case_factory, i18n_service, trash_service, // Already set via parameter search_service, share_service: None, // Configured later with create_share_service favorites_service: None, // Configured later with create_favorites_service recent_service: None, // Configured later with create_recent_service audio_metadata_service: core.audio_metadata_service.clone(), media_metadata_service: core.media_metadata_service.clone(), } } /// Builds the file lifecycle dispatcher handed to the upload/management /// services. By default this is just the core dispatcher (thumbnails, /// metadata). When a plugin dispatch is present, it wraps the core dispatcher /// together with the WASM plugin bridge so plugins observe `file.uploaded` /// events without any of the core hooks being aware of them. fn effective_file_lifecycle( &self, core: &CoreServices, file_retrieval: &Arc, plugin_dispatch: Option< Arc, >, ) -> Arc { if let Some(dispatch) = plugin_dispatch { use crate::application::adapters::plugin_lifecycle_hook::PluginLifecycleHook; let bridge = Arc::new(PluginLifecycleHook::new(dispatch, file_retrieval.clone())); let composite = FileLifecycleService::new() .with_hook(core.file_lifecycle.clone()) .with_hook(bridge); return Arc::new(composite); } core.file_lifecycle.clone() } /// The single plugin manager, exposed as the two ports it serves: the /// dispatch port (shared by every event bridge — file, user, …) and the /// management port (stored on `AppState` for the admin API). Both wrap the /// *same* `Arc`, so an install or toggle through the management port takes /// effect on the live dispatch path with no restart. /// /// Returns trait objects so call sites stay feature-agnostic; the /// `#[cfg(feature = "plugins")]` is confined to this body. Both are `None` /// when the feature is off or `OXICLOUD_ENABLE_PLUGINS` is false. #[allow(clippy::type_complexity)] fn create_plugin_ports( &self, ) -> ( Option>, Option>, ) { #[cfg(feature = "plugins")] { if self.config.plugins.enabled { let dir = self .config .plugins .plugins_dir .clone() .unwrap_or_else(|| self.config.storage_path.join(".plugins")); // Resolve the log root to a sibling of the plugins dir by default // so an uninstall never wipes another plugin's logs, and pass it // into the manager via the config it owns. let mut plugin_config = self.config.plugins.clone(); if plugin_config.log_dir.is_none() { plugin_config.log_dir = Some(self.config.storage_path.join(".plugin-logs")); } let manager = Arc::new( crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir( plugin_config, &dir, ), ); tracing::info!( target: "oxicloud::plugins", loaded = manager.loaded_count(), "plugin manager initialized" ); let dispatch: Arc = manager.clone(); let management: Arc< dyn crate::application::ports::plugin_ports::PluginManagementPort, > = manager.clone(); // Background maintenance: prune each plugin's rotated log // segments by age + aggregate size on a schedule. Depends only on // the log store + the management port, so no special ordering. crate::infrastructure::services::plugins::PluginLogMaintenanceService::new( manager.log_store(), management.clone(), 6, // hours between sweeps ) .start(); // Periodic idle-eviction of cached compiled modules: frees the // memory of plugins not invoked within the configured TTL; the // next event recompiles transparently. Cheap, so it ticks often. { let evictor = manager.clone(); tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); loop { tick.tick().await; evictor.evict_idle_compiled(); } }); } return (Some(dispatch), Some(management)); } } (None, None) } /// Creates the audio metadata service (extracts ID3 tags from audio files) pub fn create_audio_metadata_service( &self, db_pool: &Arc, ) -> Option> { if !self.config.features.enable_music { tracing::info!("Audio metadata service is disabled (music feature disabled)"); return None; } let blob_root = self.storage_path.join(".blobs"); Some(Arc::new(AudioMetadataService::new( db_pool.clone(), blob_root, ))) } /// Creates the image/video capture-metadata service (EXIF + container /// creation dates). Always enabled — the Photos timeline relies on it. pub fn create_media_metadata_service( &self, db_pool: &Arc, ) -> Arc { let blob_root = self.storage_path.join(".blobs"); Arc::new(MediaMetadataService::new(db_pool.clone(), blob_root)) } /// Creates the trash service pub async fn create_trash_service( &self, repos: &RepositoryServices, core: &CoreServices, authz: &Arc, ) -> Option> { if !self.config.features.enable_trash { tracing::info!("Trash service is disabled in configuration"); return None; } let trash_repo = repos.trash_repository.as_ref()?; // Wire ports directly to TrashService — no adapter layer needed let service = Arc::new( TrashService::new( trash_repo.clone(), repos.file_read_repository.clone(), repos.file_write_repository.clone(), repos.folder_repository.clone(), self.config.storage.trash_retention_days, core.dedup_service.clone(), Some(core.file_content_cache.clone()), authz.clone(), ) .with_file_deleted_hook(core.file_lifecycle.clone()), ); // Initialize cleanup service (bulk-deletes expired items in 2 SQL // queries, then GCs zero-reference blobs — including chunks orphaned // by aborted streaming uploads). let cleanup_service = TrashCleanupService::new( trash_repo.clone(), core.dedup_service.clone(), 24, // Run cleanup every 24 hours ); cleanup_service.start_cleanup_job().await; tracing::info!("Trash service initialized with daily cleanup schedule"); Some(service as Arc) } /// Creates the sharing service pub fn create_share_service( &self, repos: &RepositoryServices, db_pool: &Arc, authorization: &Arc, ) -> Option> { if !self.config.features.enable_file_sharing { tracing::info!("File sharing service is disabled in configuration"); return None; } let share_repository = Arc::new(SharePgRepository::new(db_pool.clone())); // Build a password hasher for share password verification let password_hasher: Arc = Arc::new( crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( self.config.auth.hash_memory_cost, self.config.auth.hash_time_cost, self.config.auth.hash_parallelism, ), ); let service = Arc::new(ShareService::new( Arc::new(self.config.clone()), share_repository, repos.file_read_repository.clone(), repos.folder_repository.clone(), password_hasher, authorization.clone(), )); tracing::info!("File sharing service initialized"); Some(service) } /// Creates the favorites service (requires database) pub fn create_favorites_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()), ); let service = Arc::new(FavoritesService::new(repo)); tracing::info!("Favorites service initialized"); service } /// Creates the recent items service (requires database) pub fn create_recent_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()), ); let service = Arc::new(RecentService::new( repo, 50, // Maximum recent items per user )); tracing::info!("Recent items service initialized"); service } /// Creates the Places (photo map) service. Reuses the existing file-read /// repository — the data is the caller's own geotagged photos. pub fn create_places_service( &self, file_read: &Arc, ) -> Arc { let service = Arc::new(PlacesService::new(file_read.clone())); tracing::info!("Places service initialized"); service } /// Preloads translations for every locale in the registry. Build /// the registry at startup via `LocaleRegistry::discover` and pass /// the resulting list here. pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) { let locales = i18n_service.available_locales().await; for locale in locales { let code = locale.as_str().to_string(); if let Err(e) = i18n_service.load_translations(locale).await { tracing::warn!("Failed to load translations for {}: {}", code, e); } } tracing::info!("Translations preloaded"); } /// Creates the storage usage service (requires database) /// /// Uses the `maintenance_pool` for batch operations /// (`update_all_users_storage_usage`) to avoid starving user requests. pub fn create_storage_usage_service( &self, _repos: &RepositoryServices, db_pool: &Arc, maintenance_pool: &Arc, ) -> Arc { let user_repository = Arc::new( crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()), ); let service = Arc::new( crate::application::services::storage_usage_service::StorageUsageService::new( maintenance_pool.clone(), user_repository, ), ); // Keep cached storage usage fresh off the request path: GET /api/auth/me // no longer recomputes the O(N) SUM per call; a periodic sweep does it // instead (on the maintenance pool). service.start_reconciliation_job(self.config.storage.usage_reconcile_secs); tracing::info!("Storage usage service initialized"); service } /// Starts the tree-ETag flush job (requires database). /// /// The statement triggers on `storage.files`/`storage.folders` only /// enqueue bump requests into `storage.tree_etag_dirty` (so user-facing /// writes take no folder-row locks); this job is the single drainer that /// turns them into `tree_modified_at` updates. It must run whenever the /// database is up — the triggers are always installed, and an undrained /// queue grows unboundedly while folder ETags freeze. Fire-and-forget on /// the maintenance pool, like the trash cleanup job. fn start_tree_etag_flush_job(&self, maintenance_pool: &Arc) { let service = crate::infrastructure::services::tree_etag_flush_service::TreeEtagFlushService::new( maintenance_pool.clone(), self.config.storage.tree_etag_flush_ms, ); service.start_flush_job(); tracing::info!("Tree-ETag flush service initialized"); } /// Opens (or rebuilds) the embedded Tantivy content index. Returns the /// index plus a reseed flag (true when the on-disk index was missing or /// version-stale and must be repopulated from `storage.files`). Any /// failure degrades to name-only search instead of failing startup. fn create_content_index(&self) -> Option<(Arc, bool)> { if !self.config.content_search.enabled { tracing::info!("Content search is disabled in configuration"); return None; } let dir = self .config .content_search .index_dir .clone() .unwrap_or_else(|| self.storage_path.join(".search-index")); match TantivyContentIndex::open_or_rebuild(&dir) { Ok((index, needs_reseed)) => { tracing::info!( "Content index ready at {} ({} doc(s), reseed: {})", dir.display(), index.num_docs(), needs_reseed ); Some((Arc::new(index), needs_reseed)) } Err(e) => { tracing::error!("Content index unavailable — search will be name-only: {e}"); None } } } /// Starts the content-index pipeline on the maintenance pool. The /// `storage.files` triggers enqueue unconditionally, so when the feature /// is off (or the index failed to open) a discard-only janitor keeps the /// dirty queue bounded instead. fn start_content_index_job( &self, maintenance_pool: &Arc, core: &CoreServices, content_index: Option<(Arc, bool)>, ) { match content_index { Some((index, needs_reseed)) => { ContentIndexWorker::new( maintenance_pool.clone(), core.dedup_service.clone(), index, self.config.content_search.flush_interval_ms, self.config.content_search.max_extract_file_bytes, self.config.content_search.max_text_bytes, ) .start(needs_reseed); tracing::info!("Content-index worker initialized"); } None => ContentIndexWorker::start_drain_only_janitor(maintenance_pool.clone()), } } /// Builds the complete AppState using all factory services. /// /// This is the main entry point that replaces all manual logic in `main.rs`. pub async fn build_app_state( &self, db_pools: Option, ) -> Result { // Database is REQUIRED in 100% blob storage model let pools = db_pools.ok_or_else(|| { DomainError::internal_error( "Database", "PostgreSQL database is required for blob storage model", ) })?; let pool = Arc::new(pools.primary); let maintenance_pool = Arc::new(pools.maintenance); // 1. Core services (PgPool needed for DedupService index) let core = self.create_core_services(&pool, &maintenance_pool).await?; // 2. Repository services (requires PgPool for all metadata) let repos = self.create_repository_services(&core, &pool); // 3a. Authorization engine — must exist before application services // because services hold an Arc for ReBAC checks. // SubjectGroupPgRepository is constructed here too so the engine can // expand a user's transitive group set on cache misses. let subject_group_repo = Arc::new( crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()), ); let authorization = build_authorization_engine( pool.clone(), repos.folder_repository.clone(), repos.file_read_repository.clone(), subject_group_repo.clone(), ); // 3b. Trash service (needed before application services) let trash_service = self .create_trash_service(&repos, &core, &authorization) .await; // 3c. Storage usage / quota service (needed by the instant-upload // path inside the application services, and re-exposed on AppState // for the handler-side quota checks of the byte-upload paths). let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool); // 3d. Content index (embedded Tantivy) — opened before application // services so SearchService can hold the query port; the feeding // worker starts further down with the maintenance pool. let content_index = self.create_content_index(); // Single plugin manager, surfaced as its two ports: the dispatch port // (shared by every event bridge — file uploads here, user logins at the // auth-services wiring below) and the management port (stored on // AppState for the admin API). Created once so plugins load exactly once // regardless of how many events they observe. let (plugin_dispatch, plugin_management) = self.create_plugin_ports(); // 4. Application services (with trash + authz already wired) let mut apps = self.create_application_services( &core, &repos, trash_service.clone(), &authorization, &storage_usage, content_index.as_ref().map(|(idx, _)| idx.clone()), plugin_dispatch.clone(), ); // 5. Share service let share_service = self.create_share_service(&repos, &pool, &authorization); apps.share_service = share_service.clone(); let share_browse_service = share_service.as_ref().map(|s| { Arc::new(ShareBrowseService::new( s.clone(), apps.folder_service.clone(), apps.file_retrieval_service.clone(), repos.folder_repository.clone(), )) }); // 6. Database-dependent services (PgPool always available in blob model) let favorites_service: Option>; let recent_service: Option>; let places_service: Option>; let storage_usage_service: Option>; let mut auth_services: Option = None; let mut nextcloud_services: Option = None; // Lifted out of the database-services block so PR 9's invite // orchestrator (built at AppState-assembly time below) can share // the same lifecycle dispatcher. The inner block at line ~682 // is unconditional and always assigns; the `#[allow]` silences // the rustc warning that the `None` initialiser is never read. #[allow(unused_assignments)] let mut user_lifecycle_handle: Option< Arc, > = None; { let favs = self.create_favorites_service(&pool); favorites_service = Some(favs.clone()); apps.favorites_service = Some(favs); let recent = self.create_recent_service(&pool); recent_service = Some(recent.clone()); apps.recent_service = Some(recent); places_service = if core.config.features.enable_places { Some(self.create_places_service(&repos.file_read_repository)) } else { None }; storage_usage_service = Some(storage_usage.clone()); self.start_tree_etag_flush_job(&maintenance_pool); self.start_content_index_job(&maintenance_pool, &core, content_index); // User-lifecycle dispatcher. Hook order is registration order; // document dependencies inline if/when any arise. Today: // 1. AuditLifecycleHook — fires first so the // audit event is recorded // even if a later hook // errors out. // 2. HomeFolderLifecycleHook — provisions the user's // home folder on // created/login (no-op // for external users). // 3. AuthzCacheLifecycleHook — invalidates the // Moka group-expansion // cache on logout/delete // so a re-login sees fresh // membership immediately. // 4. SessionRevocationLifecycleHook — explicit per-user // session revocation on // delete (with audit) — // replaces the silent FK // CASCADE. // 5. ExternalIdentityLifecycleHook — audit + magic-link // token cleanup. Logs an // audit event for any // external user that gets // created or logs in; // transactionally clears // outstanding magic-link // tokens on delete (so a // new user reusing the // same id can never // inherit an old token). // Last in the chain so it // observes the latest // user state before the // chain commits. let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone())); let magic_link_repo: Arc< dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository, > = Arc::new( crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new( pool.clone(), ), ); let mut user_lifecycle_builder = crate::application::services::user_lifecycle_service::UserLifecycleService::new() .with_hook(Arc::new( crate::application::services::user_lifecycle_service::AuditLifecycleHook, )) .with_hook(Arc::new( crate::application::services::folder_service::HomeFolderLifecycleHook::new( apps.folder_service_concrete.clone(), ), )) .with_hook(Arc::new( crate::infrastructure::services::pg_acl_engine::AuthzCacheLifecycleHook::new( authorization.clone(), ), )) .with_hook(Arc::new( crate::application::services::user_lifecycle_service::SessionRevocationLifecycleHook::new( session_repo_for_hook, ), )) .with_hook(Arc::new( crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new() .with_magic_link_repo(magic_link_repo.clone()), )); // Plugin user.login bridge — shares the single plugin dispatch with // the file-upload bridge. Registered only when plugins are active; // inert until auth is enabled (the dispatcher is never fired otherwise). if let Some(dispatch) = &plugin_dispatch { user_lifecycle_builder = user_lifecycle_builder.with_hook(Arc::new( crate::application::adapters::plugin_user_lifecycle_hook::PluginUserLifecycleHook::new( dispatch.clone(), ), )); } let user_lifecycle = Arc::new(user_lifecycle_builder); // Auth services. Folder service no longer threaded here — // PR 3 moved home-folder provisioning into // HomeFolderLifecycleHook, which already holds an Arc to the // folder service via the user_lifecycle dispatcher. if self.config.features.enable_auth { let services = crate::infrastructure::auth_factory::create_auth_services( &self.config, pool.clone(), user_lifecycle.clone(), ) .await .map_err(|e| { // SECURITY: fail-closed. If auth is required but the auth // services cannot be created, propagate the error so the // server refuses to start — never degrade to public mode. tracing::error!( "FATAL: enable_auth=true but auth services failed to initialize: {}", e ); DomainError::internal_error( "AuthInit", format!( "Authentication is enabled but auth services failed: {}. \ Refusing to start without authentication.", e ), ) })?; tracing::info!("Authentication services initialized successfully"); auth_services = Some(services); } user_lifecycle_handle = Some(user_lifecycle); } // Shared App Password service — created once, used by both NC routes and native API let shared_app_pw_svc: Option> = if self.config.nextcloud.enabled || self.config.features.enable_auth { let app_pw_repo: Arc = Arc::new(AppPasswordPgRepository::new(pool.clone())); let hasher: Arc = Arc::new( crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( self.config.auth.hash_memory_cost, self.config.auth.hash_time_cost, self.config.auth.hash_parallelism, ), ); let user_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()), ); let svc = Arc::new(AppPasswordService::new( app_pw_repo, hasher, user_repo, self.config.base_url(), )); tracing::info!("App Password service initialized (shared)"); Some(svc) } else { None }; // Nextcloud compatibility services if self.config.nextcloud.enabled { if !self.config.features.enable_auth { tracing::warn!( "Nextcloud compatibility enabled but auth is disabled; Nextcloud routes will be unusable" ); } // NC chunked-upload sessions root. Honour `OXICLOUD_CHUNK_DIR` // (same env var that the REST chunked service uses) so a single // value covers both surfaces and they stay co-located on one // filesystem; fall back to `{storage_path}/.uploads/` to match // the legacy layout. let chunk_root = self .config .storage .chunk_dir .clone() .unwrap_or_else(|| self.storage_path.join(".uploads")); let chunk_base = chunk_root.join("nextcloud"); let chunked_uploads = Arc::new(NextcloudChunkedUploadService::new(chunk_base)); let file_id_repo = Arc::new( crate::infrastructure::repositories::pg::NextcloudObjectIdRepository::new( pool.clone(), ), ); let file_ids = Arc::new(NextcloudFileIdService::new( file_id_repo, self.config.nextcloud.instance_id.clone(), )); nextcloud_services = Some(NextcloudServices { login_flow: Arc::new(NextcloudLoginFlowService::new( std::time::Duration::from_secs(self.config.nextcloud.login_flow_ttl_secs), )), app_passwords: shared_app_pw_svc .clone() .expect("AppPasswordService must be available when NC is enabled"), file_ids, chunked_uploads, }); } // 7. Preload translations self.preload_translations(&apps.i18n_service).await; // 8. Build the ZipService with real application services let zip_service: Arc = Arc::new( crate::infrastructure::services::zip_service::ZipService::new( apps.file_retrieval_service.clone(), apps.folder_service.clone(), ), ); let mut core = core; core.zip_service = Some(zip_service); // 9. Assemble final AppState let mut app_state = AppState { core, repositories: repos, applications: apps, locale_registry: self.locale_registry.clone(), db_pool: Some(pool.clone()), maintenance_pool: Some(maintenance_pool), auth_service: auth_services, nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, plugin_management, migration_state: Arc::new(tokio::sync::RwLock::new(MigrationState::default())), trash_service, share_service, share_browse_service, favorites_service, recent_service, places_service, storage_usage_service, calendar_service: None, contact_service: None, calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, music_service: None, wopi_token_service: None, wopi_lock_service: None, wopi_discovery_service: None, device_auth_service: None, app_password_service: None, path_resolver: None, webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), authorization, subject_group_service: Some(Arc::new( crate::application::services::subject_group_service::SubjectGroupService::new( subject_group_repo.clone(), pool.clone(), Arc::new( crate::infrastructure::repositories::pg::UserPgRepository::new( pool.clone(), ), ), ), )), email_sender: None, // populated below mock_email_sender: None, // populated below magic_link_invite_service: None, // populated below recipient_notification_service: None, // populated below alongside magic_link_invite_service // 60 lookups / minute / caller; cap at 50 000 tracked // callers to bound memory. The same limiter instance is // shared by every clone of AppState since it lives in an // Arc. user_profile_rate_limiter: Arc::new( crate::interfaces::middleware::rate_limit::RateLimiter::new(60, 60, 50_000), ), // Delta upload: 240 requests / minute / caller. Generous for a // real client (chunk PUTs carry up to 100 MB each) while // stopping pin/negotiate floods; 50 000 tracked callers bound // the memory like the other limiters. delta_upload_rate_limiter: Arc::new( crate::interfaces::middleware::rate_limit::RateLimiter::new(240, 60, 50_000), ), // PR 12 — per-sharer email-invite ceiling: caller_id-keyed. // Defends against a compromised account spamming external // invites (each invite mints a new external user + email). // Limits come from MagicLinkConfig so tests / operators can // tune them via OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR. email_invite_rate_limiter: Arc::new( crate::interfaces::middleware::rate_limit::RateLimiter::new( self.config.magic_link.invite_per_caller_per_hour, 3_600, 50_000, ), ), // PR 12 — per-target-email send ceiling on // /api/auth/magic-link/send. Stops the endpoint from being // an email-bombing primitive against a known address. magic_link_send_per_email_rate_limiter: Arc::new( crate::interfaces::middleware::rate_limit::RateLimiter::new( self.config.magic_link.send_per_email_per_hour, 3_600, 50_000, ), ), // PR 12 — per-IP backstop on /api/auth/magic-link/send. // Bounds the damage if an attacker spreads a low per-email // rate across many target addresses. magic_link_send_per_ip_rate_limiter: Arc::new( crate::interfaces::middleware::rate_limit::RateLimiter::new( self.config.magic_link.send_per_ip_per_hour, 3_600, 50_000, ), ), }; let email_bundle = build_email_sender(&self.config.smtp); app_state.email_sender = email_bundle.sender; app_state.mock_email_sender = email_bundle.mock; // Magic-link invite orchestrator: only when SMTP wired AND the // user-lifecycle dispatcher exists (i.e. auth is enabled). if let (Some(email_sender), Some(lifecycle)) = ( app_state.email_sender.clone(), user_lifecycle_handle.clone(), ) { let invite_user_storage = Arc::new( crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()), ); let invite_magic_link_repo: Arc< dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository, > = Arc::new( crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new( pool.clone(), ), ); app_state.magic_link_invite_service = Some(Arc::new( crate::application::services::magic_link_invite_service::MagicLinkInviteService::new( invite_user_storage.clone(), invite_magic_link_repo, email_sender.clone(), lifecycle, app_state.applications.i18n_service.clone(), app_state.locale_registry.clone(), self.config.magic_link.clone(), self.config.base_url(), ), )); // PR N1: wire the unified RecipientNotificationService. // Only constructed when MagicLinkInviteService is also // available — the magic-link path delegates to it. // SubjectGroupService is built earlier in this factory; the // notification service needs it for the Group subject arm. if let (Some(magic_link_svc), Some(subject_groups)) = ( app_state.magic_link_invite_service.clone(), app_state.subject_group_service.clone(), ) { app_state.recipient_notification_service = Some(Arc::new( crate::application::services::recipient_notification_service::RecipientNotificationService::new( invite_user_storage, magic_link_svc, email_sender, app_state.applications.i18n_service.clone(), app_state.locale_registry.clone(), subject_groups, app_state.magic_link_send_per_email_rate_limiter.clone(), self.config.magic_link.clone(), self.config.base_url(), ), )); } } // 9b. Wire admin settings service when auth is available if let Some(auth_svc) = &app_state.auth_service { let settings_repo = Arc::new( crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone()), ); let server_base_url = self.config.base_url(); // Load OIDC config from env vars (the snapshot from startup) let env_oidc = crate::common::config::OidcConfig::from_env(); let admin_svc = Arc::new(AdminSettingsService::new( settings_repo.clone(), env_oidc, auth_svc.auth_application_service.clone(), server_base_url, )); // Hot-reload OIDC from DB settings if configured match admin_svc.load_effective_oidc_config().await { Ok(eff) if eff.enabled && !eff.issuer_url.is_empty() && !eff.client_id.is_empty() && !eff.client_secret.is_empty() => { let oidc_svc = Arc::new( crate::infrastructure::services::oidc_service::OidcService::new( eff.clone(), ), ); auth_svc.auth_application_service.reload_oidc(oidc_svc, eff); tracing::info!("OIDC config loaded from admin settings (database)"); } Ok(_) => { tracing::info!( "No active OIDC config in admin settings — using env vars or defaults" ); } Err(e) => { tracing::warn!( "Failed to load OIDC settings from database (table may not exist yet): {}", e ); } } app_state.admin_settings_service = Some(admin_svc.clone()); // 9b-1b. Wire storage settings service (reuses same settings_repo) let storage_settings_svc = Arc::new(StorageSettingsService::new( settings_repo.clone(), self.config.storage.clone(), app_state.core.dedup_service.clone(), )); app_state.storage_settings_service = Some(storage_settings_svc); tracing::info!("Storage settings service initialized"); // 9b-2. Log whether system needs first-time admin setup if !admin_svc.is_system_initialized().await { tracing::warn!("╔══════════════════════════════════════════════════════════╗"); tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║"); tracing::warn!("║ ║"); tracing::warn!("║ Open the web UI to create the first admin account. ║"); tracing::warn!("║ The setup page is available until an admin is created. ║"); tracing::warn!("╚══════════════════════════════════════════════════════════╝"); } else { tracing::info!("System already initialized — setup endpoint disabled"); } // 9c. Wire Device Authorization Grant (RFC 8628) service { let device_code_repo = Arc::new(DeviceCodePgRepository::new(pool.clone())); let user_repo: Arc = Arc::new( crate::infrastructure::repositories::UserPgRepository::new(pool.clone()), ); let session_repo: Arc = Arc::new( crate::infrastructure::repositories::SessionPgRepository::new(pool.clone()), ); let base_url = self.config.base_url(); let device_auth_svc = Arc::new(DeviceAuthService::new( device_code_repo, auth_svc.token_service.clone(), user_repo, session_repo, base_url, )); app_state.device_auth_service = Some(device_auth_svc); tracing::info!("Device Authorization Grant (RFC 8628) service initialized"); } // 9d. Wire App Password service (reuse shared instance) app_state.app_password_service = shared_app_pw_svc.clone(); } // 9e. Wire PathResolver for single-query WebDAV path resolution { app_state.path_resolver = Some(Arc::new(PathResolverService::new(pool.clone()))); tracing::info!("PathResolver service initialized"); } // 10. Wire CalDAV/CardDAV services { // CalDAV let calendar_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()), ); let event_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::CalendarEventPgRepository::new( pool.clone(), ), ); let calendar_storage = Arc::new( crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new( calendar_repo, event_repo, ) ); let calendar_service = Arc::new( crate::application::services::calendar_service::CalendarService::new( calendar_storage, ), ); app_state.calendar_use_case = Some(calendar_service as Arc); // CardDAV let address_book_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()), ); let contact_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()), ); let group_repo: Arc = Arc::new( crate::infrastructure::repositories::pg::ContactGroupPgRepository::new( pool.clone(), ), ); let contact_storage = Arc::new( crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( address_book_repo, contact_repo, group_repo, ) ); app_state.addressbook_use_case = Some(contact_storage.clone()); app_state.contact_use_case = Some(contact_storage); tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); } // Music service { let playlist_repo: Arc = Arc::new(PlaylistPgRepository::new(pool.clone())); let item_repo: Arc = Arc::new(PlaylistItemPgRepository::new(pool.clone())); let audio_metadata_repo: Arc = Arc::new(AudioMetadataPgRepository::new(pool.clone())); let music_storage = Arc::new( crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter::new( playlist_repo, item_repo, audio_metadata_repo, ), ); let music_svc = Arc::new(MusicService::new(music_storage)); app_state.music_service = Some(music_svc); tracing::info!("Music service initialized"); } // 11. Wire WOPI services if enabled if self.config.wopi.enabled { let discovery_url = &self.config.wopi.discovery_url; if discovery_url.is_empty() { tracing::error!( "WOPI is enabled but WOPI_DISCOVERY_URL is empty — WOPI services will NOT be available" ); } else { let wopi_secret = if self.config.wopi.secret.is_empty() { self.config.auth.jwt_secret.clone() } else { self.config.wopi.secret.clone() }; let wopi_token_service = Arc::new(WopiTokenService::new( wopi_secret, self.config.wopi.token_ttl_secs, )); let wopi_lock_service = Arc::new(WopiLockService::new(self.config.wopi.lock_ttl_secs)); wopi_lock_service.start_cleanup_task(); let wopi_discovery_service = Arc::new(WopiDiscoveryService::new( discovery_url.clone(), 86400, // 24 hour cache TTL )); app_state.wopi_token_service = Some(wopi_token_service); app_state.wopi_lock_service = Some(wopi_lock_service); app_state.wopi_discovery_service = Some(wopi_discovery_service); tracing::info!("WOPI services initialized (discovery: {})", discovery_url); } } Ok(app_state) } } /// Container for core services #[derive(Clone)] pub struct CoreServices { pub path_service: Arc, pub file_content_cache: Arc, pub thumbnail_service: Arc, /// Composite lifecycle dispatcher — wires thumbnails + audio metadata for all file events. pub file_lifecycle: Arc, pub audio_metadata_service: Option>, /// Image/video capture-metadata extractor (EXIF + container dates). pub media_metadata_service: Arc, pub chunked_upload_service: Arc, pub image_transcode_service: Arc, pub dedup_service: Arc, pub zip_service: Option>, pub config: AppConfig, } /// Container for repository services #[derive(Clone)] pub struct RepositoryServices { pub folder_repository: Arc, pub folder_repo_concrete: Arc, pub file_read_repository: Arc, pub file_write_repository: Arc, pub file_metadata_repository: Arc, pub i18n_repository: Arc, pub trash_repository: Option>, } /// Container for application services #[derive(Clone)] pub struct ApplicationServices { // Concrete types for compatibility with existing handlers pub folder_service_concrete: Arc, // Traits for abstraction pub folder_service: Arc, pub file_upload_service: Arc, pub delta_upload_service: Arc, pub file_retrieval_service: Arc, pub file_management_service: Arc, pub file_use_case_factory: Arc, pub i18n_service: Arc, pub trash_service: Option>, pub search_service: Option>, pub share_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, pub audio_metadata_service: Option>, pub media_metadata_service: Arc, } /// Container for authentication services #[derive(Clone)] pub struct AuthServices { pub token_service: Arc, pub auth_application_service: Arc, pub login_lockout: Arc, } /// Container for Nextcloud compatibility services #[derive(Clone)] pub struct NextcloudServices { pub login_flow: Arc, pub app_passwords: Arc, pub file_ids: Arc, pub chunked_uploads: Arc, } /// Global application state for dependency injection #[derive(Clone)] pub struct AppState { pub core: CoreServices, pub repositories: RepositoryServices, pub applications: ApplicationServices, /// Validated set of locales the server knows about. Surfaced to /// handlers so the `Accept-Language` extractor and any /// locale-validation code (OIDC JIT, profile-edit) can consult one /// canonical list. pub locale_registry: Arc, pub db_pool: Option>, /// Isolated pool for background / batch operations. pub maintenance_pool: Option>, pub auth_service: Option, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin /// Plugins tab. `None` when the `plugins` feature is compiled out or /// `OXICLOUD_ENABLE_PLUGINS` is false — the admin endpoints return 503 then. pub plugin_management: Option>, pub storage_settings_service: Option>, pub migration_state: Arc>, pub trash_service: Option>, pub share_service: Option>, pub share_browse_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, pub places_service: Option>, pub storage_usage_service: Option>, pub calendar_service: Option>, pub contact_service: Option>, pub calendar_use_case: Option>, pub addressbook_use_case: Option>, pub contact_use_case: Option>, pub music_service: Option>, pub wopi_token_service: Option>, pub wopi_lock_service: Option>, pub wopi_discovery_service: Option>, pub device_auth_service: Option>, pub app_password_service: Option>, pub path_resolver: Option>, pub webdav_lock_store: Arc, /// ReBAC authorization engine — all service-layer permission checks go /// through this. Concrete type today is `PgAclEngine`; the /// `AuthorizationEngine` trait describes the contract. When alternate /// implementations land (OpenFGA, cached decorator), swap this field for /// an enum dispatcher or `Arc` (with /// `async_trait` boxing). pub authorization: Arc, /// ReBAC subject-group management (CRUD + membership). `None` when the /// auth subsystem is not configured. pub subject_group_service: Option>, /// Outbound transactional email — `None` when `OXICLOUD_SMTP_HOST` is /// empty. Endpoints that need email (magic-link invite, login-via-email) /// must return 503 when this is `None` rather than silently dropping /// the message. pub email_sender: Option>, /// Set alongside `email_sender` when the test harness flag /// `OXICLOUD_SMTP_MOCK=true` is on. Used by the /// `GET /api/admin/smtp/test/captured` test-only endpoint to look up /// recently captured messages. Always `None` in production. pub mock_email_sender: Option>, /// Invite-by-email orchestrator — `None` when SMTP isn't configured /// (no `email_sender`). `POST /api/grants` with `subject.type=email` /// returns 503 when this is `None`. pub magic_link_invite_service: Option< Arc, >, /// Unified share-notification dispatcher (PR N1) — used by both /// `create_grant` and the future `POST /api/grants/{id}/notify` to /// route share emails through coalesce + rate-limit + per-recipient /// dispatch. `None` when SMTP / magic-link / subject-group services /// aren't all configured; callers degrade to silent no-op in that /// case (no mail sent, grant still created). pub recipient_notification_service: Option< Arc, >, /// Per-caller sliding-window limiter for `GET /api/users/{id}`. The /// endpoint's primary defense is the visibility check, but a stale /// JWT could in theory iterate UUIDs against the related-by-grant /// branch of that check. 60 lookups per minute keyed on the /// authenticated caller covers any legitimate UI rendering while /// throttling enumeration. pub user_profile_rate_limiter: Arc, /// Per-caller flood guard for the delta-upload endpoints /// (negotiate / chunks / commit share one budget). pub delta_upload_rate_limiter: Arc, /// Per-sharer ceiling on `POST /api/grants` invitations whose /// subject is `{ type: "email" }`. 50 per hour keyed on /// `caller_id`. Anonymous attackers can't reach this code path /// (the route is auth-protected); this defends against a /// compromised internal account or a malicious admin. pub email_invite_rate_limiter: Arc, /// Per-target-email ceiling on `POST /api/auth/magic-link/send`. 5 /// per hour keyed on the **normalised** target email. Exceeding /// the cap is silently absorbed: the handler still returns the /// uniform 200 anti-enumeration response, but no new mail is /// dispatched. Authenticated callers bypass this limit. pub magic_link_send_per_email_rate_limiter: Arc, /// Per-source-IP backstop on `POST /api/auth/magic-link/send`. 200 /// per hour keyed on the trusted client IP (respects /// `OXICLOUD_TRUST_PROXY_CIDR`). Bounds the cost of a single /// attacker spreading 5/hr requests over a wide email list. /// Authenticated callers bypass this limit. pub magic_link_send_per_ip_rate_limiter: Arc, } // All AppState construction is done via struct literal in build_app_state(). /// Builds the authorization engine. Today this only constructs `PgAclEngine`; /// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate /// implementations (e.g. `openfga`). fn build_authorization_engine( pool: Arc, folder_repo: Arc< crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository, >, file_repo: Arc< crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository, >, group_repo: Arc, ) -> Arc { use crate::infrastructure::services::pg_acl_engine::PgAclEngine; if let Ok(other) = std::env::var("OXICLOUD_AUTHZ_ENGINE") && other != "postgres" && !other.is_empty() { panic!( "OXICLOUD_AUTHZ_ENGINE={other:?} is not yet supported. Only 'postgres' is implemented; leave the variable unset to use the default." ); } Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo)) } /// Pair returned by [`build_email_sender`] when wiring DI: the /// `EmailSender` trait object used by the rest of the application, plus /// (in mock mode only) a typed handle to the same `MockEmailSender` so /// the test-only capture endpoint can introspect it without downcasting. struct EmailSenderBundle { sender: Option>, mock: Option>, } /// Construct the SMTP email sender from config, or return `None` when /// SMTP is disabled (`OXICLOUD_SMTP_HOST` empty). Construction errors /// (unparseable `From:` mailbox, bad TLS settings) downgrade to `None` /// with a `WARN` log — the server still starts, but every magic-link /// endpoint will return 503 until the operator fixes the config. /// /// When `OXICLOUD_SMTP_MOCK=true` (test harness only — never in /// production), construction returns an in-process `MockEmailSender` /// that captures every message instead of sending it. The harness /// retrieves captured messages via `GET /api/admin/smtp/test/captured`. fn build_email_sender(cfg: &crate::common::config::SmtpConfig) -> EmailSenderBundle { if std::env::var("OXICLOUD_SMTP_MOCK") .map(|v| v == "true" || v == "1") .unwrap_or(false) { tracing::warn!( target: "oxicloud", event = "smtp.mock_enabled", "OXICLOUD_SMTP_MOCK=true — outbound mail is being captured in-process. \ Test harness only; never set this in production.", ); let mock = Arc::new(crate::infrastructure::services::mock_email_sender::MockEmailSender::new()); return EmailSenderBundle { sender: Some(mock.clone()), mock: Some(mock), }; } if !cfg.is_enabled() { tracing::info!( "SMTP disabled (OXICLOUD_SMTP_HOST empty); magic-link endpoints will return 503" ); return EmailSenderBundle { sender: None, mock: None, }; } match crate::infrastructure::services::smtp_email_sender::SmtpEmailSender::new(cfg) { Ok(sender) => { tracing::info!( target: "oxicloud", event = "smtp.configured", host = %cfg.host, port = cfg.port, tls = ?cfg.tls, from = %cfg.from, user = if cfg.user.is_empty() { "" } else { "" }, "SMTP sender configured", ); EmailSenderBundle { sender: Some(Arc::new(sender)), mock: None, } } Err(e) => { tracing::warn!( target: "oxicloud", event = "smtp.config_invalid", error = %e, "SMTP configuration is invalid; magic-link endpoints will return 503", ); EmailSenderBundle { sender: None, mock: None, } } } }