diff --git a/src/common/di.rs b/src/common/di.rs index 3334e63d..28a6a8e4 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -263,7 +263,8 @@ impl AppServiceFactory { blob_backend, db_pool.clone(), maintenance_pool.clone(), - ), + ) + .with_thumbnail_service(thumbnail_service.clone()), ); dedup_service.initialize().await?; @@ -981,6 +982,48 @@ pub struct CoreServices { pub config: AppConfig, } +impl CoreServices { + /// Invalidate a file's moka thumbnail cache and kick off background regeneration. + /// + /// Call this after any write that swaps the blob for an existing file. + /// Safe to call for new files too (no-op on empty cache). + /// Skips everything if the MIME type is not a supported image. + pub async fn refresh_thumbnails_after_update( + &self, + file_id: String, + blob_hash: String, + content_type: &str, + ) { + if !ThumbnailService::is_supported_image(content_type) { + return; + } + if let Err(e) = self.thumbnail_service.delete_thumbnails(&file_id).await { + tracing::warn!( + "Failed to invalidate thumbnail cache for {}: {}", + file_id, + e + ); + } + let ts = self.thumbnail_service.clone(); + let ds = self.dedup_service.clone(); + let hash = blob_hash.clone(); + tokio::spawn(async move { + match ds.read_blob_bytes(&hash).await { + Ok(bytes) => { + ts.generate_all_sizes_background_from_bytes(file_id, hash, bytes); + } + Err(e) => { + tracing::warn!( + "Failed to read blob for thumbnail regeneration {}: {}", + file_id, + e + ); + } + } + }); + } +} + /// Container for repository services #[derive(Clone)] pub struct RepositoryServices { diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 55f9d5f0..99c07d36 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -49,6 +49,7 @@ use crate::application::ports::dedup_ports::{ BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto, }; use crate::domain::errors::{DomainError, ErrorKind}; +use crate::infrastructure::services::thumbnail_service::ThumbnailService; // ── CDC Constants ──────────────────────────────────────────────────────────── @@ -83,6 +84,9 @@ pub struct DedupService { /// Isolated maintenance pool for long-running operations /// (verify_integrity, garbage_collect) that must never starve the primary. maintenance_pool: Arc, + /// Optional thumbnail service — when set, blob-hash thumbnails are deleted + /// from disk whenever a blob's ref_count reaches zero. + thumbnail_service: Option>, } impl DedupService { @@ -100,9 +104,17 @@ impl DedupService { backend, pool, maintenance_pool, + thumbnail_service: None, } } + /// Attach a thumbnail service so that disk thumbnails are cleaned up when + /// a blob's ref_count drops to zero. + pub fn with_thumbnail_service(mut self, svc: Arc) -> Self { + self.thumbnail_service = Some(svc); + self + } + /// Creates a stub instance for testing — never hits PG or the filesystem. #[cfg(any(test, feature = "integration_tests"))] pub fn new_stub() -> Self { @@ -117,6 +129,7 @@ impl DedupService { backend: Arc::new(LocalBlobBackend::new(Path::new("/tmp/oxicloud_stub_blobs"))), pool: stub_pool.clone(), maintenance_pool: stub_pool, + thumbnail_service: None, } } @@ -772,6 +785,11 @@ impl DedupService { } } + // Bug 4 fix: delete disk thumbnails keyed by file_hash (last reference gone) + if let Some(ts) = &self.thumbnail_service { + ts.delete_blob_thumbnails(file_hash).await; + } + tracing::info!( "MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)", &file_hash[..12], @@ -849,6 +867,11 @@ impl DedupService { tracing::warn!("Failed to delete blob file {}: {}", hash, e); } + // Bug 3 fix: delete disk thumbnails keyed by hash (last reference gone) + if let Some(ts) = &self.thumbnail_service { + ts.delete_blob_thumbnails(hash).await; + } + tracing::info!("BLOB DELETED: {} (no more references)", &hash[..12]); Ok(true) } else { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 826c8664..906a533b 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -972,6 +972,15 @@ async fn handle_put( ); } + state + .core + .refresh_thumbnails_after_update( + file_dto.id.clone(), + file_dto.etag.clone(), + &content_type, + ) + .await; + Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index a2c1efa5..5a2501bf 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -288,7 +288,19 @@ async fn put_file( let _ = tokio::fs::remove_file(&temp_path).await; match result { - Ok(_) => StatusCode::OK.into_response(), + Ok(file_dto) => { + state + .app_state + .core + .refresh_thumbnails_after_update( + file_dto.id.clone(), + file_dto.etag.clone(), + &content_type, + ) + .await; + + StatusCode::OK.into_response() + } Err(e) => { tracing::error!("WOPI PutFile failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index e5227ce6..1bde93d4 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -163,6 +163,12 @@ async fn handle_assemble( ) .await .map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?; + + state + .core + .refresh_thumbnails_after_update(dto.id.clone(), dto.etag.clone(), &content_type) + .await; + Some(dto.etag) } else { // For new files we still need to read the temp file since create_file takes &[u8]. diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index a383ad05..a7994a16 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -566,6 +566,16 @@ async fn handle_put( } } + // Bug 1 & 2 fix: invalidate stale thumbnail and regenerate from new blob. + state + .core + .refresh_thumbnails_after_update( + updated.id.clone(), + updated.etag.clone(), + &content_type, + ) + .await; + return Ok(Response::builder() .status(StatusCode::NO_CONTENT) .header(header::ETAG, format!("\"{}\"", updated.etag))