fix(image): label thumbnails by their bytes, not the source's name #173
+70
-4
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user