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:
1 parent
42897990d8
commit
fbc9882364
1 file changed
+70
-4
+70
-4
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user