//! Thumbnail endpoint: what gets one, what does not, and the cache. mod common; use axum::http::StatusCode; use common::*; const ROOT: i64 = 1; fn thumb_path(rel: &str) -> String { format!("/api/files/{ROOT}/{rel}?action=thumb") } /// A small JPEG written into the fixture root. Encoded here rather than /// checked in as a binary, so the test reads as one piece. fn write_jpeg(env: &Env, rel: &str, w: u32, h: u32) { let mut img = image::RgbImage::new(w, h); // Structure, not a flat colour: a flat image resizes correctly by // accident. for (x, y, p) in img.enumerate_pixels_mut() { *p = image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]); } img.save(env.file(rel)).unwrap(); } /// Decode a WebP response back to its dimensions. fn webp_size(bytes: &[u8]) -> (u32, u32) { let img = image::load_from_memory_with_format(bytes, image::ImageFormat::WebP).unwrap(); (img.width(), img.height()) } #[tokio::test] async fn an_image_gets_a_webp_thumbnail() { let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 1200, 800); let r = admin.get(&thumb_path("photo.jpg")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.headers["content-type"], "image/webp"); // Longest edge capped, aspect ratio kept. assert_eq!(webp_size(&r.body), (256, 170)); } #[tokio::test] async fn an_image_smaller_than_the_cap_is_not_upscaled() { let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "small.jpg", 80, 40); let r = admin.get(&thumb_path("small.jpg")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(webp_size(&r.body), (80, 40)); } #[tokio::test] async fn a_transparent_png_is_flattened_onto_white() { // Dropping the alpha channel instead would leave the RGB behind it, which // for a cut-out background is black: the tile comes out a black square. let env = Env::with_thumbs().await; let admin = env.admin().await; let mut img = image::RgbaImage::new(64, 64); for p in img.pixels_mut() { *p = image::Rgba([0, 0, 0, 0]); // fully transparent, black underneath } img.save(env.file("cutout.png")).unwrap(); let r = admin.get(&thumb_path("cutout.png")).await; assert_eq!(r.status, StatusCode::OK); let out = image::load_from_memory_with_format(&r.body, image::ImageFormat::WebP) .unwrap() .to_rgb8(); let px = out.get_pixel(32, 32).0; assert!( px.iter().all(|c| *c > 240), "expected near-white, got {px:?}" ); } #[tokio::test] async fn the_response_is_immutable() { // The client varies the URL on the file's mtime, so the bytes behind one // URL never change. Without that this header would serve stale images. let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 300, 300); let r = admin.get(&thumb_path("photo.jpg")).await; let cc = r.headers["cache-control"].to_str().unwrap(); assert!(cc.contains("immutable"), "{cc}"); assert!(cc.contains("private"), "{cc}"); } #[tokio::test] async fn the_second_request_is_served_from_the_cache() { let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 600, 400); let first = admin.get(&thumb_path("photo.jpg")).await; assert_eq!(first.status, StatusCode::OK); assert_eq!(webp_size(&first.body), (256, 170)); let cached: Vec<_> = walk_cache(&env); assert_eq!(cached.len(), 1, "one entry expected: {cached:?}"); // Overwrite the entry with an obviously different image. The second // answer is that one, so it came off disk and not from a fresh decode. let marker = image::RgbImage::new(8, 8); let mut buf = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(marker) .write_to(&mut buf, image::ImageFormat::WebP) .unwrap(); std::fs::write(&cached[0], buf.into_inner()).unwrap(); let second = admin.get(&thumb_path("photo.jpg")).await; assert_eq!(second.status, StatusCode::OK); assert_eq!(webp_size(&second.body), (8, 8)); assert_eq!(walk_cache(&env).len(), 1, "no second entry made"); } #[tokio::test] async fn editing_the_file_makes_a_new_cache_entry() { let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 600, 400); assert_eq!( admin.get(&thumb_path("photo.jpg")).await.status, StatusCode::OK ); // Different size, so the key changes even if the mtime resolution is // coarser than the test is fast. write_jpeg(&env, "photo.jpg", 400, 600); let r = admin.get(&thumb_path("photo.jpg")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!( webp_size(&r.body), (170, 256), "the new shape, not the old one" ); assert_eq!( walk_cache(&env).len(), 2, "the old entry is left to age out" ); } #[tokio::test] async fn concurrent_requests_for_one_file_all_get_the_thumbnail() { // Exercises the double-check after the semaphore, where several requests // generate the same uncached file at once. The narrow write-vs-rename // race this guards is pinned deterministically by // `thumb::tests::two_writes_of_one_entry_use_different_scratch_names`. let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 1200, 800); let mut set = tokio::task::JoinSet::new(); for _ in 0..8 { let c = admin.clone(); set.spawn(async move { c.get(&thumb_path("photo.jpg")).await }); } while let Some(r) = set.join_next().await { let r = r.unwrap(); assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(webp_size(&r.body), (256, 170)); } for f in walk_cache(&env) { assert!( std::fs::metadata(&f).unwrap().len() > 0, "empty entry published: {f:?}" ); } } #[tokio::test] async fn a_video_without_ffmpeg_caches_nothing() { // The cache key says nothing about ffmpeg, so a marker written here would // still be a marker after ffmpeg is installed, and every 404 refreshes // its mtime so the sweeper never drops it. if !has_ffmpeg() { eprintln!("skipped: no ffmpeg to build the fixture"); return; } let env = Env::without_ffmpeg().await; let admin = env.admin().await; // A real clip, so the server sniffs it as a video. Only the server's view // of ffmpeg is forced off. let out = std::process::Command::new("ffmpeg") .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) .arg("testsrc2=size=160x120:rate=30:duration=1") .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) .arg(env.file("clip.mp4")) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); assert_eq!( admin.get(&thumb_path("clip.mp4")).await.status, StatusCode::NOT_FOUND ); assert!( walk_cache(&env).is_empty(), "nothing may be cached: {:?}", walk_cache(&env) ); } #[tokio::test] async fn a_small_video_is_not_upscaled() { if !has_ffmpeg() { eprintln!("skipped: no ffmpeg"); return; } let env = Env::with_thumbs().await; let admin = env.admin().await; let out = std::process::Command::new("ffmpeg") .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) .arg("testsrc2=size=160x120:rate=30:duration=1") .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) .arg(env.file("small.mp4")) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); let r = admin.get(&thumb_path("small.mp4")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(webp_size(&r.body), (160, 120), "the source size, not 256"); } #[tokio::test] async fn a_text_file_has_no_thumbnail() { let env = Env::with_thumbs().await; let admin = env.admin().await; let r = admin.get(&thumb_path("notes.md")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn a_folder_has_no_thumbnail() { let env = Env::with_thumbs().await; let admin = env.admin().await; let r = admin.get(&thumb_path("docs")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn a_broken_image_fails_once_and_is_remembered() { let env = Env::with_thumbs().await; let admin = env.admin().await; // A real JPEG header over garbage: sniffed as an image, refuses to decode. let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0]; bytes.extend(std::iter::repeat_n(0x7Fu8, 2048)); std::fs::write(env.file("broken.jpg"), &bytes).unwrap(); assert_eq!( admin.get(&thumb_path("broken.jpg")).await.status, StatusCode::NOT_FOUND ); // The empty marker file: the next visit to this folder does not decode // it again. let cached = walk_cache(&env); assert_eq!(cached.len(), 1, "{cached:?}"); assert_eq!(std::fs::metadata(&cached[0]).unwrap().len(), 0); assert_eq!( admin.get(&thumb_path("broken.jpg")).await.status, StatusCode::NOT_FOUND ); } #[tokio::test] async fn thumbnails_are_off_without_a_cache_folder() { let env = Env::new().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 300, 300); assert_eq!( admin.get(&thumb_path("photo.jpg")).await.status, StatusCode::NOT_FOUND ); let me = admin.get("/api/auth/me").await.json(); assert_eq!(me["thumbnails_available"], false); } #[tokio::test] async fn the_profile_setting_round_trips() { let env = Env::with_thumbs().await; let admin = env.admin().await; let me = admin.get("/api/auth/me").await.json(); assert_eq!(me["thumbnails_available"], true); assert_eq!(me["user"]["thumbnails"], true, "on by default"); let r = admin .put_json("/api/auth/me", &serde_json::json!({ "thumbnails": false })) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.json()["user"]["thumbnails"], false); // Still set on the next read, not only in the reply. let me = admin.get("/api/auth/me").await.json(); assert_eq!(me["user"]["thumbnails"], false); // A user preference, not an access rule: the endpoint still answers. write_jpeg(&env, "photo.jpg", 300, 300); assert_eq!( admin.get(&thumb_path("photo.jpg")).await.status, StatusCode::OK ); } #[tokio::test] async fn another_users_root_is_still_out_of_reach() { let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "photo.jpg", 300, 300); create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await; let bob = login(&env, "bob", "bobpass123").await; // Root 1 is the admin's whole-root folder, which Bob does not have. let r = bob.get("/api/files/1/photo.jpg?action=thumb").await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); // Root 2 is Bob's `docs`. The file sits above it, so `..` must not reach // out of the root even though the root itself is his. let r = bob.get("/api/files/2/../photo.jpg?action=thumb").await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); } #[tokio::test] async fn a_share_scopes_thumbnails_to_the_shared_folder() { let env = Env::with_thumbs().await; let admin = env.admin().await; write_jpeg(&env, "docs/inside.jpg", 300, 200); write_jpeg(&env, "outside.jpg", 300, 200); let s = admin .post_json( "/api/shares", &serde_json::json!({ "root_id": 1, "path": "docs", "writable": false }), ) .await .json(); let token = s["token"].as_str().unwrap(); let root_id = s["root_id"].as_i64().unwrap(); let anon = Client::new(env.app.clone()); // A file in the shared folder gets a thumbnail without signing in. let r = anon .get(&format!( "/api/files/{root_id}/inside.jpg?share={token}&action=thumb" )) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.headers["content-type"], "image/webp"); // A file outside it does not, and no thumbnail of it reaches the cache. let r = anon .get(&format!( "/api/files/{root_id}/../outside.jpg?share={token}&action=thumb" )) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert_eq!(walk_cache(&env).len(), 1); } /// Whether ffmpeg is on this machine. Video thumbnails need it and the /// server treats it as optional, so the test does too. fn has_ffmpeg() -> bool { std::process::Command::new("ffmpeg") .arg("-version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .is_ok_and(|s| s.success()) } #[tokio::test] async fn a_video_gets_a_thumbnail_of_one_frame() { if !has_ffmpeg() { eprintln!("skipped: no ffmpeg"); return; } let env = Env::with_thumbs().await; let admin = env.admin().await; // Three seconds of colour bars, long enough to survive the seek. let out = std::process::Command::new("ffmpeg") .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) .arg("testsrc2=size=640x360:rate=30:duration=3") .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) .arg(env.file("clip.mp4")) .output() .unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); let r = admin.get(&thumb_path("clip.mp4")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.headers["content-type"], "image/webp"); assert_eq!(webp_size(&r.body), (256, 144)); } /// Every file in the cache folder, buckets included. fn walk_cache(env: &Env) -> Vec { let dir = env.cache.as_ref().expect("cache env").path(); let mut out = Vec::new(); for bucket in std::fs::read_dir(dir).unwrap().flatten() { if bucket.path().is_dir() { for f in std::fs::read_dir(bucket.path()).unwrap().flatten() { out.push(f.path()); } } } out.sort(); out }