diff --git a/src/application/services/file_lifecycle_service.rs b/src/application/services/file_lifecycle_service.rs new file mode 100644 index 00000000..50fece64 --- /dev/null +++ b/src/application/services/file_lifecycle_service.rs @@ -0,0 +1,47 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::application::ports::file_lifecycle::FileDeletedHook; + +/// Composite dispatcher for file lifecycle events. +/// +/// Aggregates all `FileDeletedHook` implementations and fans out each event to +/// every registered handler. Services hold a single `Arc` +/// pointing here — new handlers are added once, in DI, without touching the +/// services themselves. +pub struct FileLifecycleService { + deleted: Vec>, +} + +impl Default for FileLifecycleService { + fn default() -> Self { + Self::new() + } +} + +impl FileLifecycleService { + pub fn new() -> Self { + Self { + deleted: Vec::new(), + } + } + + pub fn with_deleted_hook(mut self, hook: Arc) -> Self { + self.deleted.push(hook); + self + } +} + +impl FileDeletedHook for FileLifecycleService { + fn on_file_deleted<'a>( + &'a self, + file_id: &'a str, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + for hook in &self.deleted { + hook.on_file_deleted(file_id).await; + } + }) + } +} diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 797a1434..117c4752 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -29,8 +29,8 @@ pub struct FileManagementService { trash_service: Option>, content_cache: Option>, authz: Arc, - /// Hooks fired after a file is permanently deleted. - file_deleted_hooks: Vec>, + /// Hook fired after a file is permanently deleted (typically the FileLifecycleService composite). + file_deleted_hook: Option>, } impl FileManagementService { @@ -52,13 +52,13 @@ impl FileManagementService { trash_service, content_cache, authz, - file_deleted_hooks: Vec::new(), + file_deleted_hook: None, } } - /// Registers a hook to fire after a file is permanently deleted. + /// Sets the lifecycle hook fired after a file is permanently deleted. pub fn with_file_deleted_hook(mut self, hook: Arc) -> Self { - self.file_deleted_hooks.push(hook); + self.file_deleted_hook = Some(hook); self } @@ -185,7 +185,7 @@ impl FileManagementService { if let Some(cc) = &self.content_cache { cc.invalidate(id).await; } - for hook in &self.file_deleted_hooks { + if let Some(hook) = &self.file_deleted_hook { hook.on_file_deleted(id).await; } info!("File permanently deleted: {}", id); diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 8423e07c..d3d1b072 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -6,6 +6,7 @@ pub mod calendar_service; pub mod contact_service; pub mod device_auth_service; pub mod favorites_service; +pub mod file_lifecycle_service; pub mod file_management_service; pub mod file_retrieval_service; pub mod file_upload_service; diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index c002c945..911004f7 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -7,6 +7,7 @@ use crate::application::dtos::display_helpers::{ }; use crate::application::dtos::trash_dto::TrashedItemDto; use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::file_lifecycle::FileDeletedHook; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::{DomainError, ErrorKind, Result}; @@ -21,7 +22,6 @@ use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbReposit use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; -use crate::infrastructure::services::thumbnail_service::ThumbnailService; /** * Application service for trash operations. @@ -53,8 +53,8 @@ pub struct TrashService { /// orphaned blob files and thumbnails that the PG trigger cannot reach. dedup_service: Arc, - /// Thumbnail service for cleaning up thumbnails on permanent delete - thumbnail_service: Option>, + /// Hook fired after a file is permanently deleted (typically the FileLifecycleService composite). + file_deleted_hook: Option>, /// Content cache — invalidated when files are permanently deleted from trash. content_cache: Option>, @@ -75,7 +75,6 @@ impl TrashService { folder_storage_port: Arc, retention_days: u32, dedup_service: Arc, - thumbnail_service: Option>, content_cache: Option>, authz: Arc, ) -> Self { @@ -85,13 +84,19 @@ impl TrashService { file_write_port, folder_storage_port, dedup_service, - thumbnail_service, + file_deleted_hook: None, content_cache, authz, retention_days, } } + /// Sets the lifecycle hook fired after a file is permanently deleted. + pub fn with_file_deleted_hook(mut self, hook: Arc) -> Self { + self.file_deleted_hook = Some(hook); + self + } + /// Converts a TrashedItem entity to a DTO fn to_dto(&self, item: TrashedItem) -> TrashedItemDto { // Calculate days_until_deletion before moving item fields @@ -130,46 +135,6 @@ impl TrashService { icon_special_class, } } - - /// Validates that the given user owns the trashed item. - /// Returns an error if the item does not exist or belongs to a different user. - #[instrument(skip(self))] - async fn _validate_user_ownership(&self, item_id: &str, user_id: &str) -> Result<()> { - let item_uuid = Uuid::parse_str(item_id) - .map_err(|e| DomainError::validation_error(format!("Invalid item ID: {}", e)))?; - let user_uuid = Uuid::parse_str(user_id) - .map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?; - - match self - .trash_repository - .get_trash_item(&item_uuid, &user_uuid) - .await? - { - Some(item) => { - if item.user_id() != user_uuid { - error!( - "User {} attempted to access trash item {} owned by {}", - user_id, - item_id, - item.user_id() - ); - return Err(DomainError::access_denied( - "TrashItem", - "You do not have permission to access this trash item", - )); - } - Ok(()) - } - None => { - // Item not found for this user — treat as authorization error - // to avoid leaking existence information - Err(DomainError::not_found( - "TrashItem", - format!("{} (user: {})", item_id, user_id), - )) - } - } - } } impl TrashUseCase for TrashService { @@ -617,12 +582,8 @@ impl TrashUseCase for TrashService { } } - // Best-effort thumbnail cleanup — thumbnails are cache - // artifacts, so failure must not block file deletion. - if let Some(thumb) = &self.thumbnail_service - && let Err(e) = thumb.delete_thumbnails(&file_id).await - { - warn!("Failed to delete thumbnails for file {}: {}", file_id, e); + if let Some(hook) = &self.file_deleted_hook { + hook.on_file_deleted(&file_id).await; } } TrashedItemType::Folder => { @@ -714,18 +675,20 @@ impl TrashUseCase for TrashService { async fn empty_trash(&self, user_id: Uuid) -> Result<()> { info!("Emptying trash for user {}", user_id); - // Collect trashed file IDs BEFORE bulk-deleting so we can clean up - // their thumbnails afterward. This is best-effort — if the query - // fails we still proceed with the bulk delete. - let trashed_file_ids: Vec = if self.thumbnail_service.is_some() { - match self.trash_repository.get_trash_items(&user_id).await { - Ok(items) => items - .iter() - .filter(|i| matches!(i.item_type(), TrashedItemType::File)) - .map(|i| i.original_id().to_string()) - .collect(), + // Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail + // cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not + // get_trash_items) because the trash_items view excludes files inside a + // trashed folder — those files will still be deleted by clear_trash via + // the folder CASCADE, but their hooks would otherwise be missed. + let trashed_file_ids: Vec = if self.file_deleted_hook.is_some() { + match self + .trash_repository + .get_all_trashed_file_ids(&user_id) + .await + { + Ok(ids) => ids, Err(e) => { - warn!("Could not list trashed items for thumbnail cleanup: {}", e); + warn!("Could not list trashed files for hook cleanup: {}", e); Vec::new() } } @@ -758,12 +721,9 @@ impl TrashUseCase for TrashService { } } - // Best-effort thumbnail cleanup for all deleted files - if let Some(thumb) = &self.thumbnail_service { + if let Some(hook) = &self.file_deleted_hook { for file_id in &trashed_file_ids { - if let Err(e) = thumb.delete_thumbnails(file_id).await { - warn!("Failed to delete thumbnails for file {}: {}", file_id, e); - } + hook.on_file_deleted(file_id).await; } } diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 9c3516ae..e0cf8bf0 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -402,6 +402,11 @@ impl TrashRepository for MockTrashRepository { Ok(()) } + async fn get_all_trashed_file_ids(&self, _user_id: &Uuid) -> Result> { + let files = self.trashed_files.lock().unwrap(); + Ok(files.keys().cloned().collect()) + } + async fn delete_expired_bulk(&self) -> Result<(u64, u64)> { let mut items = self.trash_items.lock().unwrap(); let now = Utc::now(); diff --git a/src/common/di.rs b/src/common/di.rs index d44e7baa..f67f5489 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -44,6 +44,7 @@ use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::app_password_service::AppPasswordService; 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; @@ -274,10 +275,14 @@ impl AppServiceFactory { "Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage)" ); + let file_lifecycle = + Arc::new(FileLifecycleService::new().with_deleted_hook(thumbnail_service.clone())); + Ok(CoreServices { path_service, file_content_cache, thumbnail_service, + file_lifecycle, chunked_upload_service, image_transcode_service, dedup_service, @@ -392,7 +397,7 @@ impl AppServiceFactory { Some(core.file_content_cache.clone()), authz.clone(), ) - .with_file_deleted_hook(core.thumbnail_service.clone()), + .with_file_deleted_hook(core.file_lifecycle.clone()), ); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( @@ -463,17 +468,19 @@ impl AppServiceFactory { 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.thumbnail_service.clone()), - Some(core.file_content_cache.clone()), - authz.clone(), - )); + 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) let cleanup_service = TrashCleanupService::new( @@ -1021,6 +1028,8 @@ pub struct CoreServices { pub path_service: Arc, pub file_content_cache: Arc, pub thumbnail_service: Arc, + /// Composite lifecycle dispatcher — register new permanent-delete hooks here only. + pub file_lifecycle: Arc, pub chunked_upload_service: Arc, pub image_transcode_service: Arc, pub dedup_service: Arc, diff --git a/src/domain/repositories/trash_repository.rs b/src/domain/repositories/trash_repository.rs index 7925c530..3a1cee2c 100644 --- a/src/domain/repositories/trash_repository.rs +++ b/src/domain/repositories/trash_repository.rs @@ -11,6 +11,11 @@ pub trait TrashRepository: Send + Sync { async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()>; async fn clear_trash(&self, user_id: &Uuid) -> Result<()>; + /// All trashed file IDs for this user, regardless of parent folder trash status. + /// Used by empty_trash for thumbnail cleanup — the view used by get_trash_items + /// excludes files inside trashed folders, which would miss their ext thumbnails. + async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result>; + /// Bulk-delete all expired trash items (files + folders) in a single /// transaction. Returns `(files_deleted, folders_deleted)`. async fn delete_expired_bulk(&self) -> Result<(u64, u64)>; diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index 9c0ed8a0..aee38c2b 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -157,6 +157,17 @@ impl TrashRepository for TrashDbRepository { Ok(()) } + async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result> { + let rows = sqlx::query_scalar::<_, String>( + "SELECT id::text FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE", + ) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("all_trashed_files: {e}")))?; + Ok(rows) + } + async fn delete_expired_bulk(&self) -> Result<(u64, u64)> { let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);