From fbc988236430ea887f04fdbc8c4d10059ea3b73c Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 16 Oct 2026 15:51:49 -0400 Subject: [PATCH] fix(image): label thumbnails by their bytes, not the source's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy thumbnail and preview paths mirror the source's relative path, so a video's JPEG thumbnail lives at `…/clip.mp4` and a RAW's at `…/shot.arw`. NamedFile guessed the content type from that name and served JPEG bytes as `video/mp4`. Sniff JPEG/PNG magic bytes instead. The circular-thumbnail path had the same root cause: `image::open` picks the decoder from the extension, so it failed on those thumbs and quietly fell back to the square one. Guess the format from the bytes. Co-Authored-By: Claude Opus 5.5 --- src/handlers/image.rs | 74 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/src/handlers/image.rs b/src/handlers/image.rs index 31b5f91..dbc8b91 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -203,7 +203,7 @@ pub async fn get_image( } trace!("Thumbnail path: {:?}", thumb_path); - if let Ok(file) = NamedFile::open(&thumb_path) { + if let Some(file) = open_derivative(&thumb_path) { span.set_status(Status::Ok); return file .use_etag(true) @@ -347,7 +347,7 @@ async fn serve_preview( ); let serve = |p: &Path| { - NamedFile::open(p).ok().map(|file| { + open_derivative(p).map(|file| { file.use_etag(true) .use_last_modified(true) .prefer_utf8(true) @@ -434,8 +434,11 @@ async fn create_circular_thumbnail( std::fs::create_dir_all(parent)?; } - // Load the square thumbnail - let img = image::open(thumb_path)?; + // Load the square thumbnail. Sniff the format: a legacy thumb keeps + // its source's name (`clip.mp4`, `shot.arw`) but holds JPEG bytes. + let img = image::ImageReader::open(thumb_path)? + .with_guessed_format()? + .decode()?; let (width, height) = img.dimensions(); // Fixed output size for consistency @@ -1251,6 +1254,37 @@ pub async fn upload_image( HttpResponse::Ok().finish() } +/// Open a generated derivative (thumbnail / preview) with a content type +/// taken from its bytes rather than its name. Legacy derivative paths +/// mirror the source's relative path, so a video's JPEG thumbnail lives +/// at `…/clip.mp4` and a RAW's at `…/shot.arw`; `NamedFile` would label +/// those `video/mp4` / `image/x-sony-arw`, and clients that trust the +/// header hand a JPEG to the wrong decoder. +fn open_derivative(path: &Path) -> Option { + let file = NamedFile::open(path).ok()?; + let mut magic = [0u8; 8]; + let sniffed = File::open(path) + .and_then(|mut f| f.read(&mut magic)) + .ok() + .and_then(|n| sniff_image_mime(&magic[..n])); + Some(match sniffed { + Some(mime) => file.set_content_type(mime), + None => file, + }) +} + +/// The image formats derivatives are written in (JPEG, or PNG for a PNG +/// source thumbnailed by the `image` crate), identified by magic bytes. +fn sniff_image_mime(head: &[u8]) -> Option { + if head.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some(actix_web::mime::IMAGE_JPEG) + } else if head.starts_with(b"\x89PNG\r\n\x1a\n") { + Some(actix_web::mime::IMAGE_PNG) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -1259,6 +1293,38 @@ mod tests { use actix_web::test::TestRequest; use image::GenericImageView; + #[test] + fn derivative_content_type_comes_from_bytes_not_name() { + let dir = tempfile::tempdir().unwrap(); + let req = TestRequest::default().to_http_request(); + let content_type = |name: &str, bytes: &[u8]| { + let p = dir.path().join(name); + std::fs::write(&p, bytes).unwrap(); + let resp = open_derivative(&p).unwrap().into_response(&req); + resp.headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap() + .to_string() + }; + // A video's legacy thumb: JPEG bytes under the source's name. + assert_eq!( + content_type("clip.mp4", &[0xFF, 0xD8, 0xFF, 0xE0, 0]), + "image/jpeg" + ); + assert_eq!( + content_type("shot.arw", &[0xFF, 0xD8, 0xFF, 0xDB]), + "image/jpeg" + ); + assert_eq!( + content_type("shot.png", b"\x89PNG\r\n\x1a\n...."), + "image/png" + ); + // Unrecognised bytes keep the extension-based guess. + assert_eq!(content_type("anim.gif", b"GIF89a"), "image/gif"); + } + fn library(root: &Path) -> libraries::Library { libraries::Library { id: libraries::PRIMARY_LIBRARY_ID,