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.
This commit is contained in:
Jared Wolff
2026-03-04 18:03:17 -05:00
parent 56d73d1fc6
commit cf6d87d805
3 changed files with 95 additions and 6 deletions
+2
View File
@@ -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;
@@ -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<u8> {
// 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/<hash>.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");
}
+27 -6
View File
@@ -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);
});