diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 5ee10771..0d959eac 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -9,6 +9,7 @@ use crate::common::errors::DomainError; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; +use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::thumbnail_service::ThumbnailService; use tracing::{error, info, warn}; use uuid::Uuid; @@ -25,6 +26,7 @@ pub struct FileManagementService { folder_repo: Option>, trash_service: Option>, thumbnail_service: Option>, + content_cache: Option>, } impl FileManagementService { @@ -36,6 +38,7 @@ impl FileManagementService { folder_repo: None, trash_service: None, thumbnail_service: None, + content_cache: None, } } @@ -46,6 +49,7 @@ impl FileManagementService { file_read: Option>, folder_repo: Option>, thumbnail_service: Option>, + content_cache: Option>, ) -> Self { Self { file_repository, @@ -53,6 +57,7 @@ impl FileManagementService { folder_repo, trash_service, thumbnail_service, + content_cache, } } @@ -216,6 +221,10 @@ impl FileManagementUseCase for FileManagementService { async fn delete_file(&self, id: &str) -> Result<(), DomainError> { self.file_repository.delete_file(id).await?; + // Invalidate content cache — file no longer exists. + if let Some(cc) = &self.content_cache { + cc.invalidate(id).await; + } // Best-effort thumbnail cleanup if let Some(thumb) = &self.thumbnail_service && let Err(e) = thumb.delete_thumbnails(id).await @@ -243,6 +252,10 @@ impl FileManagementUseCase for FileManagementService { match trash.move_to_trash(id, "file", user_id).await { Ok(_) => { info!("File successfully moved to trash: {}", id); + // Invalidate content cache — trashed files must not be served. + if let Some(cc) = &self.content_cache { + cc.invalidate(id).await; + } // Do NOT decrement blob ref here — the file row still exists // (is_trashed = TRUE). The trigger will decrement when the // row is actually DELETEd during trash emptying. @@ -261,6 +274,10 @@ impl FileManagementUseCase for FileManagementService { // Step 2: Permanent delete — trigger handles blob ref_count warn!("Permanently deleting file: {}", id); self.file_repository.delete_file(id).await?; + // Invalidate content cache — file permanently removed. + if let Some(cc) = &self.content_cache { + cc.invalidate(id).await; + } // Best-effort thumbnail cleanup if let Some(thumb) = &self.thumbnail_service && let Err(e) = thumb.delete_thumbnails(id).await diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 8a9465a9..6965e9f4 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -8,6 +8,7 @@ use crate::application::services::storage_usage_service::StorageUsageService; use crate::common::errors::DomainError; use crate::infrastructure::repositories::pg::FileBlobReadRepository; use crate::infrastructure::repositories::pg::FileBlobWriteRepository; +use crate::infrastructure::services::file_content_cache::FileContentCache; use tracing::{debug, info, warn}; /// Helper function to extract username from folder path string. @@ -48,6 +49,8 @@ pub struct FileUploadService { file_read: Option>, /// Optional storage usage tracking storage_usage_service: Option>, + /// Content cache — invalidated on file update so stale content is never served. + content_cache: Option>, } impl FileUploadService { @@ -57,6 +60,7 @@ impl FileUploadService { file_write: file_repository, file_read: None, storage_usage_service: None, + content_cache: None, } } @@ -69,9 +73,16 @@ impl FileUploadService { file_write, file_read: Some(file_read), storage_usage_service: None, + content_cache: None, } } + /// Configures the content cache for invalidation on file updates. + pub fn with_content_cache(mut self, cache: Arc) -> Self { + self.content_cache = Some(cache); + self + } + /// Configures the storage usage service pub fn with_storage_usage_service( mut self, @@ -279,6 +290,10 @@ impl FileUploadUseCase for FileUploadService { modified_at, ) .await?; + // Invalidate content cache — file content has changed. + if let Some(cc) = &self.content_cache { + cc.invalidate(&file_id).await; + } // Re-read to get fresh DTO with updated etag and timestamps. let updated = file_read.get_file(&file_id).await?; return Ok(FileDto::from(updated)); diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 91fae81b..a4ba03fd 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -16,6 +16,7 @@ use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlob use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; +use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::thumbnail_service::ThumbnailService; /** @@ -47,6 +48,9 @@ pub struct TrashService { /// Thumbnail service for cleaning up thumbnails on permanent delete thumbnail_service: Option>, + /// Content cache — invalidated when files are permanently deleted from trash. + content_cache: Option>, + /// Number of days items should be kept in trash before automatic cleanup retention_days: u32, } @@ -59,6 +63,7 @@ impl TrashService { folder_storage_port: Arc, retention_days: u32, thumbnail_service: Option>, + content_cache: Option>, ) -> Self { Self { trash_repository, @@ -66,6 +71,7 @@ impl TrashService { file_write_port, folder_storage_port, thumbnail_service, + content_cache, retention_days, } } @@ -563,6 +569,10 @@ impl TrashUseCase for TrashService { match self.file_write_port.delete_file_permanently(&file_id).await { Ok(_) => { info!("Successfully deleted file permanently: {}", file_id); + // Invalidate content cache for the deleted file. + if let Some(cc) = &self.content_cache { + cc.invalidate(&file_id).await; + } } Err(e) => { // Check if the file is not found - in that case, we can continue @@ -715,6 +725,13 @@ impl TrashUseCase for TrashService { // Finally it clears the trash_items index for the user. self.trash_repository.clear_trash(&user_id).await?; + // Invalidate content cache for all permanently deleted files. + if let Some(cc) = &self.content_cache { + for file_id in &trashed_file_ids { + cc.invalidate(file_id).await; + } + } + // Best-effort thumbnail cleanup for all deleted files if let Some(thumb) = &self.thumbnail_service { for file_id in &trashed_file_ids { diff --git a/src/common/di.rs b/src/common/di.rs index 83ff9bbb..69c70a1e 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -250,10 +250,13 @@ impl AppServiceFactory { // Refactored services with all infrastructure ports // In blob model, dedup is handled by the repository — no separate write-behind needed - let file_upload_service = Arc::new(FileUploadService::new_with_read( - repos.file_write_repository.clone(), - repos.file_read_repository.clone(), - )); + 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()), + ); let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( repos.file_read_repository.clone(), @@ -268,6 +271,7 @@ impl AppServiceFactory { Some(repos.file_read_repository.clone()), Some(repos.folder_repository.clone()), Some(core.thumbnail_service.clone()), + Some(core.file_content_cache.clone()), )); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( @@ -343,6 +347,7 @@ impl AppServiceFactory { repos.folder_repository.clone(), self.config.storage.trash_retention_days, Some(core.thumbnail_service.clone()), + Some(core.file_content_cache.clone()), )); // Initialize cleanup service (bulk-deletes expired items in 2 SQL queries) diff --git a/src/infrastructure/services/file_content_cache.rs b/src/infrastructure/services/file_content_cache.rs index ebf36a02..1f0a733f 100644 --- a/src/infrastructure/services/file_content_cache.rs +++ b/src/infrastructure/services/file_content_cache.rs @@ -2,6 +2,7 @@ use bytes::Bytes; use moka::future::Cache; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; use tracing::{debug, info}; /// Configuration for the file content cache @@ -73,6 +74,8 @@ impl FileContentCache { // of weights exceeds max_capacity. value.content.len().min(u32::MAX as usize) as u32 }) + .time_to_live(Duration::from_secs(3600)) + .time_to_idle(Duration::from_secs(300)) .build(); Self {