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

Merged
cameron merged 1 commits from fix/thumb-content-type into master 2026-10-16 19:55:23 +00:00
+70 -4
View File
@@ -203,7 +203,7 @@ pub async fn get_image(
} }
trace!("Thumbnail path: {:?}", thumb_path); 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); span.set_status(Status::Ok);
return file return file
.use_etag(true) .use_etag(true)
@@ -347,7 +347,7 @@ async fn serve_preview(
); );
let serve = |p: &Path| { let serve = |p: &Path| {
NamedFile::open(p).ok().map(|file| { open_derivative(p).map(|file| {
file.use_etag(true) file.use_etag(true)
.use_last_modified(true) .use_last_modified(true)
.prefer_utf8(true) .prefer_utf8(true)
@@ -434,8 +434,11 @@ async fn create_circular_thumbnail(
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
// Load the square thumbnail // Load the square thumbnail. Sniff the format: a legacy thumb keeps
let img = image::open(thumb_path)?; // 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(); let (width, height) = img.dimensions();
// Fixed output size for consistency // Fixed output size for consistency
@@ -1251,6 +1254,37 @@ pub async fn upload_image(
HttpResponse::Ok().finish() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1259,6 +1293,38 @@ mod tests {
use actix_web::test::TestRequest; use actix_web::test::TestRequest;
use image::GenericImageView; 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 { fn library(root: &Path) -> libraries::Library {
libraries::Library { libraries::Library {
id: libraries::PRIMARY_LIBRARY_ID, id: libraries::PRIMARY_LIBRARY_ID,