thumbs.rs
⎇
Raw
1//! Thumbnail endpoint: what gets one, what does not, and the cache.
2
3use crate::common::*;
4use axum::http::StatusCode;
5
6const ROOT: i64 = 1;
7
8fn 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.
14fn 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.
25fn 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]
31async 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]
44async 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]
55async 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]
79async 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]
93async 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]
121async 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]
148async 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]
176async 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]
213async 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]
239async 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]
247async 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]
255async 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]
279async 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]
292async 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]
317async 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]
335async 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.
373fn 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]
383async 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.
411fn 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