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