fix(image): label thumbnails by their bytes, not the source's name

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 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-16 15:51:49 -04:00
1 parent 42897990d8
commit fbc9882364
1 file changed
+70 -4
+70 -4
View File
@@ -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<NamedFile> {
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<actix_web::mime::Mime> {
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,