From cf6d87d8058e8f09c97d9cf97c757e8c4df40071 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 18:03:17 -0500 Subject: [PATCH] fix(thumbnails): resolve blob path instead of logical path for thumbnail generation Since the blob storage migration (3c7c16f), thumbnail generation failed with "No such file or directory" because the handler constructed logical file paths that don't exist on disk. Resolve the actual blob path via get_blob_hash() + dedup_service.blob_path() in both upload and get thumbnail handlers. Add regression tests. --- src/infrastructure/services/mod.rs | 2 + .../services/thumbnail_service_test.rs | 66 +++++++++++++++++++ src/interfaces/api/handlers/file_handler.rs | 33 ++++++++-- 3 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 src/infrastructure/services/thumbnail_service_test.rs diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index fbaef4dd..ca55655e 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -11,6 +11,8 @@ pub mod password_hasher; pub mod path_resolver_service; pub mod path_service; pub mod thumbnail_service; +#[cfg(test)] +mod thumbnail_service_test; pub mod trash_cleanup_service; pub mod webdav_lock_service; pub mod wopi_discovery_service; diff --git a/src/infrastructure/services/thumbnail_service_test.rs b/src/infrastructure/services/thumbnail_service_test.rs new file mode 100644 index 00000000..b9e01eec --- /dev/null +++ b/src/infrastructure/services/thumbnail_service_test.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use super::thumbnail_service::{ThumbnailService, ThumbnailSize}; + +/// Minimal valid 1x1 red PNG (68 bytes). +fn tiny_png() -> Vec { + // Generated from a real 1×1 PNG — smallest valid RGBA image. + let mut img = image::RgbaImage::new(1, 1); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut buf = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut buf), + image::ImageFormat::Png, + ) + .expect("encode test PNG"); + buf +} + +/// Regression test: thumbnail generation must work when the source file lives +/// at a blob-style path (`.blobs/ab/ab1234…`) rather than a logical path +/// (`folder/image.png`). This broke after the blob storage migration +/// (commit 3c7c16f) because the handler passed the logical path — which +/// doesn't exist on disk — to the thumbnail service. +#[tokio::test] +async fn generate_thumbnail_from_blob_path() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let storage_root = tmp.path(); + + // Simulate a blob-store layout: .blobs/ab/.blob + let blob_dir = storage_root.join(".blobs").join("ab"); + std::fs::create_dir_all(&blob_dir).expect("create blob dir"); + let blob_path = blob_dir.join("ab1234567890.blob"); + std::fs::write(&blob_path, tiny_png()).expect("write test blob"); + + let svc = Arc::new(ThumbnailService::new(storage_root, 100, 10 * 1024 * 1024)); + svc.initialize().await.expect("init thumbnail dirs"); + + // The key assertion: the service can read from a blob path (not a logical path) + let result = svc + .get_thumbnail("test-file-id", ThumbnailSize::Icon, &blob_path) + .await; + + let thumb_bytes = result.expect("thumbnail generation should succeed from blob path"); + assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty"); + + // Verify it's valid WebP (starts with "RIFF" magic) + assert!( + thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF", + "output should be WebP format" + ); +} + +/// Verify that a non-existent path produces an error, not a panic. +#[tokio::test] +async fn generate_thumbnail_nonexistent_path_returns_error() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let svc = Arc::new(ThumbnailService::new(tmp.path(), 100, 10 * 1024 * 1024)); + svc.initialize().await.expect("init thumbnail dirs"); + + let bad_path = tmp.path().join("does-not-exist.png"); + let result = svc + .get_thumbnail("missing-id", ThumbnailSize::Icon, &bad_path) + .await; + + assert!(result.is_err(), "should fail for nonexistent file"); +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 66784b1b..0f523836 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -14,7 +14,7 @@ use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; -use crate::application::ports::storage_ports::StorageUsagePort; +use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::common::di::AppState; use crate::interfaces::errors::AppError; @@ -322,8 +322,20 @@ impl FileHandler { .into_response(); } - let storage_root = state.core.path_service.get_root_path(); - let file_path = storage_root.join(&file.path); + // Resolve the actual blob path on disk (not the logical file path). + let blob_hash = match state.repositories.file_read_repository.get_blob_hash(&id).await { + Ok(h) => h, + Err(err) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File content not found: {}", err) + })), + ) + .into_response(); + } + }; + let file_path = state.core.dedup_service.blob_path(&blob_hash); match thumbnail_service .get_thumbnail(&id, thumb_size.into(), &file_path) @@ -597,12 +609,21 @@ impl FileHandler { .is_supported_image(&file.mime_type) { let file_id = file.id.clone(); - let file_path_rel = file.path.clone(); let thumbnail_service = state.core.thumbnail_service.clone(); - let path_service = state.core.path_service.clone(); + let dedup_service = state.core.dedup_service.clone(); + let file_read = state.repositories.file_read_repository.clone(); tokio::spawn(async move { - let file_path = path_service.get_root_path().join(&file_path_rel); + // Resolve the actual blob path on disk (not the logical file path, + // which doesn't exist when using blob storage). + let blob_hash = match file_read.get_blob_hash(&file_id).await { + Ok(h) => h, + Err(e) => { + tracing::warn!("Skipping thumbnails for {}: {}", file_id, e); + return; + } + }; + let file_path = dedup_service.blob_path(&blob_hash); tracing::info!("🖼️ Generating thumbnails for: {}", file_id); thumbnail_service.generate_all_sizes_background(file_id, file_path); });