thumbs.rs
| 1 | //! Thumbnail endpoint: what gets one, what does not, and the cache. |
| 2 | |
| 3 | use crate::common::*; |
| 4 | use axum::http::StatusCode; |
| 5 | |
| 6 | const ROOT: i64 = 1; |
| 7 | |
| 8 | fn thumb_path(rel: &str) -> String { |
| 9 | format!("/api/files/{ROOT}/{rel}?action=thumb") |
| 10 | } |
| 11 | |
| 12 | /// A small JPEG written into the fixture root. Encoded here rather than |
| 13 | /// checked in as a binary, so the test reads as one piece. |
| 14 | fn write_jpeg(env: &Env, rel: &str, w: u32, h: u32) { |
| 15 | let mut img = image::RgbImage::new(w, h); |
| 16 | // Structure, not a flat colour: a flat image resizes correctly by |
| 17 | // accident. |
| 18 | for (x, y, p) in img.enumerate_pixels_mut() { |
| 19 | *p = image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]); |
| 20 | } |
| 21 | img.save(env.file(rel)).unwrap(); |
| 22 | } |
| 23 | |
| 24 | /// Decode a WebP response back to its dimensions. |
| 25 | fn webp_size(bytes: &[u8]) -> (u32, u32) { |
| 26 | let img = image::load_from_memory_with_format(bytes, image::ImageFormat::WebP).unwrap(); |
| 27 | (img.width(), img.height()) |
| 28 | } |
| 29 | |
| 30 | #[tokio::test] |
| 31 | async fn an_image_gets_a_webp_thumbnail() { |
| 32 | let env = Env::with_thumbs().await; |
| 33 | let admin = env.admin().await; |
| 34 | write_jpeg(&env, "photo.jpg", 1200, 800); |
| 35 | |
| 36 | let r = admin.get(&thumb_path("photo.jpg")).await; |
| 37 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 38 | assert_eq!(r.headers["content-type"], "image/webp"); |
| 39 | // Longest edge capped, aspect ratio kept. |
| 40 | assert_eq!(webp_size(&r.body), (256, 170)); |
| 41 | } |
| 42 | |
| 43 | #[tokio::test] |
| 44 | async fn an_image_smaller_than_the_cap_is_not_upscaled() { |
| 45 | let env = Env::with_thumbs().await; |
| 46 | let admin = env.admin().await; |
| 47 | write_jpeg(&env, "small.jpg", 80, 40); |
| 48 | |
| 49 | let r = admin.get(&thumb_path("small.jpg")).await; |
| 50 | assert_eq!(r.status, StatusCode::OK); |
| 51 | assert_eq!(webp_size(&r.body), (80, 40)); |
| 52 | } |
| 53 | |
| 54 | #[tokio::test] |
| 55 | async fn a_transparent_png_is_flattened_onto_white() { |
| 56 | // Dropping the alpha channel instead would leave the RGB behind it, which |
| 57 | // for a cut-out background is black: the tile comes out a black square. |
| 58 | let env = Env::with_thumbs().await; |
| 59 | let admin = env.admin().await; |
| 60 | let mut img = image::RgbaImage::new(64, 64); |
| 61 | for p in img.pixels_mut() { |
| 62 | *p = image::Rgba([0, 0, 0, 0]); // fully transparent, black underneath |
| 63 | } |
| 64 | img.save(env.file("cutout.png")).unwrap(); |
| 65 | |
| 66 | let r = admin.get(&thumb_path("cutout.png")).await; |
| 67 | assert_eq!(r.status, StatusCode::OK); |
| 68 | let out = image::load_from_memory_with_format(&r.body, image::ImageFormat::WebP) |
| 69 | .unwrap() |
| 70 | .to_rgb8(); |
| 71 | let px = out.get_pixel(32, 32).0; |
| 72 | assert!( |
| 73 | px.iter().all(|c| *c > 240), |
| 74 | "expected near-white, got {px:?}" |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | #[tokio::test] |
| 79 | async fn the_response_is_immutable() { |
| 80 | // The client varies the URL on the file's mtime, so the bytes behind one |
| 81 | // URL never change. Without that this header would serve stale images. |
| 82 | let env = Env::with_thumbs().await; |
| 83 | let admin = env.admin().await; |
| 84 | write_jpeg(&env, "photo.jpg", 300, 300); |
| 85 | |
| 86 | let r = admin.get(&thumb_path("photo.jpg")).await; |
| 87 | let cc = r.headers["cache-control"].to_str().unwrap(); |
| 88 | assert!(cc.contains("immutable"), "{cc}"); |
| 89 | assert!(cc.contains("private"), "{cc}"); |
| 90 | } |
| 91 | |
| 92 | #[tokio::test] |
| 93 | async fn the_second_request_is_served_from_the_cache() { |
| 94 | let env = Env::with_thumbs().await; |
| 95 | let admin = env.admin().await; |
| 96 | write_jpeg(&env, "photo.jpg", 600, 400); |
| 97 | |
| 98 | let first = admin.get(&thumb_path("photo.jpg")).await; |
| 99 | assert_eq!(first.status, StatusCode::OK); |
| 100 | |
| 101 | assert_eq!(webp_size(&first.body), (256, 170)); |
| 102 | let cached: Vec<_> = walk_cache(&env); |
| 103 | assert_eq!(cached.len(), 1, "one entry expected: {cached:?}"); |
| 104 | |
| 105 | // Overwrite the entry with an obviously different image. The second |
| 106 | // answer is that one, so it came off disk and not from a fresh decode. |
| 107 | let marker = image::RgbImage::new(8, 8); |
| 108 | let mut buf = std::io::Cursor::new(Vec::new()); |
| 109 | image::DynamicImage::ImageRgb8(marker) |
| 110 | .write_to(&mut buf, image::ImageFormat::WebP) |
| 111 | .unwrap(); |
| 112 | std::fs::write(&cached[0], buf.into_inner()).unwrap(); |
| 113 | |
| 114 | let second = admin.get(&thumb_path("photo.jpg")).await; |
| 115 | assert_eq!(second.status, StatusCode::OK); |
| 116 | assert_eq!(webp_size(&second.body), (8, 8)); |
| 117 | assert_eq!(walk_cache(&env).len(), 1, "no second entry made"); |
| 118 | } |
| 119 | |
| 120 | #[tokio::test] |
| 121 | async fn editing_the_file_makes_a_new_cache_entry() { |
| 122 | let env = Env::with_thumbs().await; |
| 123 | let admin = env.admin().await; |
| 124 | write_jpeg(&env, "photo.jpg", 600, 400); |
| 125 | assert_eq!( |
| 126 | admin.get(&thumb_path("photo.jpg")).await.status, |
| 127 | StatusCode::OK |
| 128 | ); |
| 129 | |
| 130 | // Different size, so the key changes even if the mtime resolution is |
| 131 | // coarser than the test is fast. |
| 132 | write_jpeg(&env, "photo.jpg", 400, 600); |
| 133 | let r = admin.get(&thumb_path("photo.jpg")).await; |
| 134 | assert_eq!(r.status, StatusCode::OK); |
| 135 | assert_eq!( |
| 136 | webp_size(&r.body), |
| 137 | (170, 256), |
| 138 | "the new shape, not the old one" |
| 139 | ); |
| 140 | assert_eq!( |
| 141 | walk_cache(&env).len(), |
| 142 | 2, |
| 143 | "the old entry is left to age out" |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | #[tokio::test] |
| 148 | async fn concurrent_requests_for_one_file_all_get_the_thumbnail() { |
| 149 | // Exercises the double-check after the semaphore, where several requests |
| 150 | // generate the same uncached file at once. The narrow write-vs-rename |
| 151 | // race this guards is pinned deterministically by |
| 152 | // `thumb::tests::two_writes_of_one_entry_use_different_scratch_names`. |
| 153 | let env = Env::with_thumbs().await; |
| 154 | let admin = env.admin().await; |
| 155 | write_jpeg(&env, "photo.jpg", 1200, 800); |
| 156 | |
| 157 | let mut set = tokio::task::JoinSet::new(); |
| 158 | for _ in 0..8 { |
| 159 | let c = admin.clone(); |
| 160 | set.spawn(async move { c.get(&thumb_path("photo.jpg")).await }); |
| 161 | } |
| 162 | while let Some(r) = set.join_next().await { |
| 163 | let r = r.unwrap(); |
| 164 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 165 | assert_eq!(webp_size(&r.body), (256, 170)); |
| 166 | } |
| 167 | for f in walk_cache(&env) { |
| 168 | assert!( |
| 169 | std::fs::metadata(&f).unwrap().len() > 0, |
| 170 | "empty entry published: {f:?}" |
| 171 | ); |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | #[tokio::test] |
| 176 | async fn a_video_without_ffmpeg_caches_nothing() { |
| 177 | // The cache key says nothing about ffmpeg, so a marker written here would |
| 178 | // still be a marker after ffmpeg is installed, and every 404 refreshes |
| 179 | // its mtime so the sweeper never drops it. |
| 180 | if !has_ffmpeg() { |
| 181 | eprintln!("skipped: no ffmpeg to build the fixture"); |
| 182 | return; |
| 183 | } |
| 184 | let env = Env::without_ffmpeg().await; |
| 185 | let admin = env.admin().await; |
| 186 | // A real clip, so the server sniffs it as a video. Only the server's view |
| 187 | // of ffmpeg is forced off. |
| 188 | let out = std::process::Command::new("ffmpeg") |
| 189 | .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) |
| 190 | .arg("testsrc2=size=160x120:rate=30:duration=1") |
| 191 | .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) |
| 192 | .arg(env.file("clip.mp4")) |
| 193 | .output() |
| 194 | .unwrap(); |
| 195 | assert!( |
| 196 | out.status.success(), |
| 197 | "{}", |
| 198 | String::from_utf8_lossy(&out.stderr) |
| 199 | ); |
| 200 | |
| 201 | assert_eq!( |
| 202 | admin.get(&thumb_path("clip.mp4")).await.status, |
| 203 | StatusCode::NOT_FOUND |
| 204 | ); |
| 205 | assert!( |
| 206 | walk_cache(&env).is_empty(), |
| 207 | "nothing may be cached: {:?}", |
| 208 | walk_cache(&env) |
| 209 | ); |
| 210 | } |
| 211 | |
| 212 | #[tokio::test] |
| 213 | async fn a_small_video_is_not_upscaled() { |
| 214 | if !has_ffmpeg() { |
| 215 | eprintln!("skipped: no ffmpeg"); |
| 216 | return; |
| 217 | } |
| 218 | let env = Env::with_thumbs().await; |
| 219 | let admin = env.admin().await; |
| 220 | let out = std::process::Command::new("ffmpeg") |
| 221 | .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) |
| 222 | .arg("testsrc2=size=160x120:rate=30:duration=1") |
| 223 | .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) |
| 224 | .arg(env.file("small.mp4")) |
| 225 | .output() |
| 226 | .unwrap(); |
| 227 | assert!( |
| 228 | out.status.success(), |
| 229 | "{}", |
| 230 | String::from_utf8_lossy(&out.stderr) |
| 231 | ); |
| 232 | |
| 233 | let r = admin.get(&thumb_path("small.mp4")).await; |
| 234 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 235 | assert_eq!(webp_size(&r.body), (160, 120), "the source size, not 256"); |
| 236 | } |
| 237 | |
| 238 | #[tokio::test] |
| 239 | async fn a_text_file_has_no_thumbnail() { |
| 240 | let env = Env::with_thumbs().await; |
| 241 | let admin = env.admin().await; |
| 242 | let r = admin.get(&thumb_path("notes.md")).await; |
| 243 | assert_eq!(r.status, StatusCode::NOT_FOUND); |
| 244 | } |
| 245 | |
| 246 | #[tokio::test] |
| 247 | async fn a_folder_has_no_thumbnail() { |
| 248 | let env = Env::with_thumbs().await; |
| 249 | let admin = env.admin().await; |
| 250 | let r = admin.get(&thumb_path("docs")).await; |
| 251 | assert_eq!(r.status, StatusCode::NOT_FOUND); |
| 252 | } |
| 253 | |
| 254 | #[tokio::test] |
| 255 | async fn a_broken_image_fails_once_and_is_remembered() { |
| 256 | let env = Env::with_thumbs().await; |
| 257 | let admin = env.admin().await; |
| 258 | // A real JPEG header over garbage: sniffed as an image, refuses to decode. |
| 259 | let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0]; |
| 260 | bytes.extend(std::iter::repeat_n(0x7Fu8, 2048)); |
| 261 | std::fs::write(env.file("broken.jpg"), &bytes).unwrap(); |
| 262 | |
| 263 | assert_eq!( |
| 264 | admin.get(&thumb_path("broken.jpg")).await.status, |
| 265 | StatusCode::NOT_FOUND |
| 266 | ); |
| 267 | // The empty marker file: the next visit to this folder does not decode |
| 268 | // it again. |
| 269 | let cached = walk_cache(&env); |
| 270 | assert_eq!(cached.len(), 1, "{cached:?}"); |
| 271 | assert_eq!(std::fs::metadata(&cached[0]).unwrap().len(), 0); |
| 272 | assert_eq!( |
| 273 | admin.get(&thumb_path("broken.jpg")).await.status, |
| 274 | StatusCode::NOT_FOUND |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | #[tokio::test] |
| 279 | async fn thumbnails_are_off_without_a_cache_folder() { |
| 280 | let env = Env::new().await; |
| 281 | let admin = env.admin().await; |
| 282 | write_jpeg(&env, "photo.jpg", 300, 300); |
| 283 | assert_eq!( |
| 284 | admin.get(&thumb_path("photo.jpg")).await.status, |
| 285 | StatusCode::NOT_FOUND |
| 286 | ); |
| 287 | let me = admin.get("/api/auth/me").await.json(); |
| 288 | assert_eq!(me["thumbnails_available"], false); |
| 289 | } |
| 290 | |
| 291 | #[tokio::test] |
| 292 | async fn the_profile_setting_round_trips() { |
| 293 | let env = Env::with_thumbs().await; |
| 294 | let admin = env.admin().await; |
| 295 | |
| 296 | let me = admin.get("/api/auth/me").await.json(); |
| 297 | assert_eq!(me["thumbnails_available"], true); |
| 298 | assert_eq!(me["user"]["thumbnails"], true, "on by default"); |
| 299 | |
| 300 | let r = admin |
| 301 | .put_json("/api/auth/me", &serde_json::json!({ "thumbnails": false })) |
| 302 | .await; |
| 303 | assert_eq!(r.status, StatusCode::OK); |
| 304 | assert_eq!(r.json()["user"]["thumbnails"], false); |
| 305 | // Still set on the next read, not only in the reply. |
| 306 | let me = admin.get("/api/auth/me").await.json(); |
| 307 | assert_eq!(me["user"]["thumbnails"], false); |
| 308 | // A user preference, not an access rule: the endpoint still answers. |
| 309 | write_jpeg(&env, "photo.jpg", 300, 300); |
| 310 | assert_eq!( |
| 311 | admin.get(&thumb_path("photo.jpg")).await.status, |
| 312 | StatusCode::OK |
| 313 | ); |
| 314 | } |
| 315 | |
| 316 | #[tokio::test] |
| 317 | async fn another_users_root_is_still_out_of_reach() { |
| 318 | let env = Env::with_thumbs().await; |
| 319 | let admin = env.admin().await; |
| 320 | write_jpeg(&env, "photo.jpg", 300, 300); |
| 321 | create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await; |
| 322 | let bob = login(&env, "bob", "bobpass123").await; |
| 323 | |
| 324 | // Root 1 is the admin's whole-root folder, which Bob does not have. |
| 325 | let r = bob.get("/api/files/1/photo.jpg?action=thumb").await; |
| 326 | assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); |
| 327 | |
| 328 | // Root 2 is Bob's `docs`. The file sits above it, so `..` must not reach |
| 329 | // out of the root even though the root itself is his. |
| 330 | let r = bob.get("/api/files/2/../photo.jpg?action=thumb").await; |
| 331 | assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); |
| 332 | } |
| 333 | |
| 334 | #[tokio::test] |
| 335 | async fn a_share_scopes_thumbnails_to_the_shared_folder() { |
| 336 | let env = Env::with_thumbs().await; |
| 337 | let admin = env.admin().await; |
| 338 | write_jpeg(&env, "docs/inside.jpg", 300, 200); |
| 339 | write_jpeg(&env, "outside.jpg", 300, 200); |
| 340 | |
| 341 | let s = admin |
| 342 | .post_json( |
| 343 | "/api/shares", |
| 344 | &serde_json::json!({ "root_id": 1, "path": "docs", "writable": false }), |
| 345 | ) |
| 346 | .await |
| 347 | .json(); |
| 348 | let token = s["token"].as_str().unwrap(); |
| 349 | let root_id = s["id"].as_i64().unwrap(); |
| 350 | let anon = Client::new(env.app.clone()); |
| 351 | |
| 352 | // A file in the shared folder gets a thumbnail without signing in. |
| 353 | let r = anon |
| 354 | .get(&format!( |
| 355 | "/api/files/{root_id}/inside.jpg?share={token}&action=thumb" |
| 356 | )) |
| 357 | .await; |
| 358 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 359 | assert_eq!(r.headers["content-type"], "image/webp"); |
| 360 | |
| 361 | // A file outside it does not, and no thumbnail of it reaches the cache. |
| 362 | let r = anon |
| 363 | .get(&format!( |
| 364 | "/api/files/{root_id}/../outside.jpg?share={token}&action=thumb" |
| 365 | )) |
| 366 | .await; |
| 367 | assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); |
| 368 | assert_eq!(walk_cache(&env).len(), 1); |
| 369 | } |
| 370 | |
| 371 | /// Whether ffmpeg is on this machine. Video thumbnails need it and the |
| 372 | /// server treats it as optional, so the test does too. |
| 373 | fn has_ffmpeg() -> bool { |
| 374 | std::process::Command::new("ffmpeg") |
| 375 | .arg("-version") |
| 376 | .stdout(std::process::Stdio::null()) |
| 377 | .stderr(std::process::Stdio::null()) |
| 378 | .status() |
| 379 | .is_ok_and(|s| s.success()) |
| 380 | } |
| 381 | |
| 382 | #[tokio::test] |
| 383 | async fn a_video_gets_a_thumbnail_of_one_frame() { |
| 384 | if !has_ffmpeg() { |
| 385 | eprintln!("skipped: no ffmpeg"); |
| 386 | return; |
| 387 | } |
| 388 | let env = Env::with_thumbs().await; |
| 389 | let admin = env.admin().await; |
| 390 | // Three seconds of colour bars, long enough to survive the seek. |
| 391 | let out = std::process::Command::new("ffmpeg") |
| 392 | .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) |
| 393 | .arg("testsrc2=size=640x360:rate=30:duration=3") |
| 394 | .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) |
| 395 | .arg(env.file("clip.mp4")) |
| 396 | .output() |
| 397 | .unwrap(); |
| 398 | assert!( |
| 399 | out.status.success(), |
| 400 | "{}", |
| 401 | String::from_utf8_lossy(&out.stderr) |
| 402 | ); |
| 403 | |
| 404 | let r = admin.get(&thumb_path("clip.mp4")).await; |
| 405 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 406 | assert_eq!(r.headers["content-type"], "image/webp"); |
| 407 | assert_eq!(webp_size(&r.body), (256, 144)); |
| 408 | } |
| 409 | |
| 410 | /// Every file in the cache folder, buckets included. |
| 411 | fn walk_cache(env: &Env) -> Vec<std::path::PathBuf> { |
| 412 | let dir = env.cache.as_ref().expect("cache env").path(); |
| 413 | let mut out = Vec::new(); |
| 414 | for bucket in std::fs::read_dir(dir).unwrap().flatten() { |
| 415 | if bucket.path().is_dir() { |
| 416 | for f in std::fs::read_dir(bucket.path()).unwrap().flatten() { |
| 417 | out.push(f.path()); |
| 418 | } |
| 419 | } |
| 420 | } |
| 421 | out.sort(); |
| 422 | out |
| 423 | } |
| 424 |