api_shares.rs
⎇
Raw
1//! Token-based shares: creation (incl. writable gating), public resolve,
2//! anonymous access via `?share=`, expiry, scoping, deletion.
3
4mod common;
5
6use axum::http::StatusCode;
7use common::*;
8use serde_json::json;
9
10/// Create a share via the admin (who has the whole root).
11async fn share(
12 admin: &Client,
13 path: &str,
14 writable: bool,
15 expires_at: Option<&str>,
16) -> serde_json::Value {
17 let r = admin
18 .post_json(
19 "/api/shares",
20 &json!({
21 "root_id": 1,
22 "path": path,
23 "writable": writable,
24 "expires_at": expires_at,
25 }),
26 )
27 .await;
28 assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
29 r.json()
30}
31
32#[tokio::test]
33async fn create_list_and_delete_shares() {
34 let env = Env::new().await;
35 let admin = env.admin().await;
36
37 let s = share(&admin, "docs", false, None).await;
38 assert_eq!(s["is_file"], false);
39 assert_eq!(s["writable"], false);
40 assert_eq!(s["target"], "docs");
41 let token = s["token"].as_str().unwrap();
42 assert_eq!(token.len(), 32);
43 let root_id = s["root_id"].as_i64().unwrap();
44
45 // Shows up in the list.
46 let r = admin.get("/api/shares").await;
47 let j = r.json();
48 let list = j.as_array().unwrap();
49 assert_eq!(list.len(), 1);
50 assert_eq!(list[0]["id"], s["id"]);
51
52 // Delete by id works for the owner.
53 let id = s["id"].as_i64().unwrap();
54 let r = admin.delete(&format!("/api/shares/{id}")).await;
55 assert_eq!(r.status, StatusCode::OK);
56 // The token is now dead.
57 let r = admin.get(&format!("/api/share/{token}")).await;
58 assert_eq!(r.status, StatusCode::NOT_FOUND);
59 // Anonymous access dies too.
60 let anon = Client::new(env.app.clone());
61 let r = anon
62 .get(&format!("/api/files/{root_id}?share={token}"))
63 .await;
64 assert_eq!(r.status, StatusCode::NOT_FOUND);
65
66 // Deleting an unknown id → 404.
67 let r = admin.delete("/api/shares/99999").await;
68 assert_eq!(r.status, StatusCode::NOT_FOUND);
69}
70
71#[tokio::test]
72async fn share_of_a_single_file() {
73 let env = Env::new().await;
74 let admin = env.admin().await;
75 let s = share(&admin, "notes.md", false, None).await;
76 assert_eq!(s["is_file"], true);
77 let token = s["token"].as_str().unwrap();
78 let root_id = s["root_id"].as_i64().unwrap();
79
80 // Anonymous: the share root *is* the file — download it without any
81 // session cookie.
82 let anon = Client::new(env.app.clone());
83 let r = anon
84 .get(&format!(
85 "/api/files/{root_id}?share={token}&action=download"
86 ))
87 .await;
88 assert_eq!(r.status, StatusCode::OK);
89 assert_eq!(r.body, b"# notes");
90
91 // Preview and content work too.
92 let r = anon
93 .get(&format!(
94 "/api/files/{root_id}?share={token}&action=content"
95 ))
96 .await;
97 assert_eq!(r.body, b"# notes");
98 assert!(r.header("x-file-mtime").is_some());
99
100 // A path *below* the file share doesn't exist.
101 let r = anon
102 .get(&format!("/api/files/{root_id}/subdir?share={token}"))
103 .await;
104 assert!(matches!(
105 r.status,
106 StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST
107 ));
108}
109
110#[tokio::test]
111async fn share_folder_browsing_and_download_anonymously() {
112 let env = Env::new().await;
113 let admin = env.admin().await;
114 let s = share(&admin, "docs", false, None).await;
115 let token = s["token"].as_str().unwrap();
116 let root_id = s["root_id"].as_i64().unwrap();
117
118 let anon = Client::new(env.app.clone());
119 // List the share root.
120 let r = anon
121 .get(&format!("/api/files/{root_id}?share={token}"))
122 .await;
123 assert_eq!(r.status, StatusCode::OK);
124 let j = r.json();
125 let names: Vec<&str> = j
126 .get("entries")
127 .unwrap()
128 .as_array()
129 .unwrap()
130 .iter()
131 .map(|e| e["name"].as_str().unwrap())
132 .collect();
133 assert_eq!(names, vec!["inner", "a.txt"]);
134
135 // Navigate into a subfolder via the token.
136 let r = anon
137 .get(&format!("/api/files/{root_id}/inner?share={token}"))
138 .await;
139 assert_eq!(r.status, StatusCode::OK);
140 assert_eq!(r.json()["entries"][0]["name"], "hello.txt");
141
142 // Folder download via the token.
143 let r = anon
144 .get(&format!(
145 "/api/files/{root_id}?share={token}&action=download&format=zip"
146 ))
147 .await;
148 assert_eq!(r.status, StatusCode::OK);
149 let map = zip_map(&r.body);
150 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
151
152 // The token also works as a header.
153 let r = anon
154 .raw(
155 axum::http::Method::GET,
156 &format!("/api/files/{root_id}/a.txt?action=download"),
157 &[("x-share-token", token)],
158 Vec::new(),
159 )
160 .await;
161 assert_eq!(r.status, StatusCode::OK);
162 assert_eq!(r.body, b"file a");
163}
164
165#[tokio::test]
166async fn share_scopes_a_signed_in_user() {
167 let env = Env::new().await;
168 let admin = env.admin().await;
169 // Two shares so the share's synthetic root id (the share row id) is
170 // *different* from the admin's real root id (1).
171 let _distractor = share(&admin, "docs", false, None).await;
172 let s = share(&admin, "docs", false, None).await;
173 let token = s["token"].as_str().unwrap();
174 let share_root_id = s["root_id"].as_i64().unwrap();
175 assert_ne!(share_root_id, 1);
176
177 // A signed-in user who opens the share link sees the share scope.
178 let r = admin
179 .get(&format!("/api/files/{share_root_id}?share={token}"))
180 .await;
181 assert_eq!(r.status, StatusCode::OK);
182 // Without the token the same session sees their own roots.
183 let r = admin.get("/api/files/1").await;
184 assert_eq!(r.status, StatusCode::OK);
185 // …but through the share scope their own root id (1) is unreachable.
186 let r = admin.get(&format!("/api/files/1?share={token}")).await;
187 assert_eq!(
188 r.status,
189 StatusCode::FORBIDDEN,
190 "share scope hides own roots"
191 );
192}
193
194#[tokio::test]
195async fn read_only_share_blocks_writes_writable_share_allows() {
196 let env = Env::new().await;
197 let admin = env.admin().await;
198 admin
199 .put_json(
200 "/api/admin/settings",
201 &json!({ "allow_writable_shares": true }),
202 )
203 .await;
204
205 // Writable shares are gated behind the admin setting.
206 let ro = share(&admin, "docs", false, None).await;
207 let rw = share(&admin, "src", true, None).await;
208 assert_eq!(rw["writable"], true);
209
210 let anon = Client::new(env.app.clone());
211
212 // RO share: anonymous write → 403.
213 let ro_tok = ro["token"].as_str().unwrap();
214 let ro_id = ro["root_id"].as_i64().unwrap();
215 let r = anon
216 .raw(
217 axum::http::Method::POST,
218 &format!("/api/files/{ro_id}/x?share={ro_tok}"),
219 &[],
220 Vec::new(),
221 )
222 .await;
223 assert_eq!(r.status, StatusCode::FORBIDDEN);
224
225 // RW share: anonymous mkdir works (really on disk).
226 let rw_tok = rw["token"].as_str().unwrap();
227 let rw_id = rw["root_id"].as_i64().unwrap();
228 let r = anon
229 .raw(
230 axum::http::Method::POST,
231 &format!("/api/files/{rw_id}/made-by-share?share={rw_tok}"),
232 &[],
233 Vec::new(),
234 )
235 .await;
236 assert_eq!(r.status, StatusCode::OK);
237 assert!(env.file("src/made-by-share").is_dir());
238
239 // RW share: upload works.
240 let up_path = format!("/api/files/{rw_id}?share={rw_tok}");
241 let r = anon
242 .post_multipart(&up_path, &[("uploaded.txt", b"share-up")], "")
243 .await;
244 assert_eq!(r.status, StatusCode::OK);
245 assert_eq!(
246 std::fs::read(env.file("src/uploaded.txt")).unwrap(),
247 b"share-up"
248 );
249}
250
251#[tokio::test]
252async fn writable_shares_gated_by_setting() {
253 let env = Env::new().await;
254 let admin = env.admin().await;
255
256 // Default: off.
257 let r = admin
258 .post_json(
259 "/api/shares",
260 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
261 )
262 .await;
263 assert_eq!(r.status, StatusCode::FORBIDDEN);
264
265 // Enable, retry → ok.
266 let r = admin
267 .put_json(
268 "/api/admin/settings",
269 &json!({ "allow_writable_shares": true }),
270 )
271 .await;
272 assert_eq!(r.status, StatusCode::OK);
273 let r = admin
274 .post_json(
275 "/api/shares",
276 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
277 )
278 .await;
279 assert_eq!(r.status, StatusCode::OK);
280 assert_eq!(r.json()["writable"], true);
281}
282
283#[tokio::test]
284async fn share_expiry() {
285 let env = Env::new().await;
286 let admin = env.admin().await;
287
288 // Past expiry → 410 on resolve and on file access.
289 let s = share(&admin, "docs", false, Some("2000-01-01T00:00:00Z")).await;
290 let token = s["token"].as_str().unwrap();
291 let root_id = s["root_id"].as_i64().unwrap();
292 let anon = Client::new(env.app.clone());
293 assert_eq!(
294 admin.get(&format!("/api/share/{token}")).await.status,
295 StatusCode::GONE
296 );
297 assert_eq!(
298 anon.get(&format!("/api/files/{root_id}?share={token}"))
299 .await
300 .status,
301 StatusCode::GONE
302 );
303
304 // Far-future expiry → live.
305 let s = share(&admin, "docs", false, Some("2999-01-01T00:00:00Z")).await;
306 let token = s["token"].as_str().unwrap();
307 let root_id = s["root_id"].as_i64().unwrap();
308 assert_eq!(
309 admin.get(&format!("/api/share/{token}")).await.status,
310 StatusCode::OK
311 );
312 assert_eq!(
313 anon.get(&format!("/api/files/{root_id}?share={token}"))
314 .await
315 .status,
316 StatusCode::OK
317 );
318}
319
320#[tokio::test]
321async fn share_target_validation() {
322 let env = Env::new().await;
323 let admin = env.admin().await;
324
325 // Missing target → 404.
326 let r = admin
327 .post_json(
328 "/api/shares",
329 &json!({ "root_id": 1, "path": "no-such-folder", "writable": false, "expires_at": null }),
330 )
331 .await;
332 assert_eq!(r.status, StatusCode::NOT_FOUND);
333
334 // Escaping the root → 403.
335 let r = admin
336 .post_json(
337 "/api/shares",
338 &json!({ "root_id": 1, "path": "../../etc", "writable": false, "expires_at": null }),
339 )
340 .await;
341 assert_eq!(r.status, StatusCode::FORBIDDEN);
342
343 // Foreign root id → 403.
344 let r = admin
345 .post_json(
346 "/api/shares",
347 &json!({ "root_id": 999, "path": "docs", "writable": false, "expires_at": null }),
348 )
349 .await;
350 assert_eq!(r.status, StatusCode::FORBIDDEN);
351
352 // Public resolve of a garbage token → 404.
353 let r = admin
354 .get("/api/share/doesnotexist1234567890abcdef1234567890abcdef1234567890abcdef")
355 .await;
356 assert_eq!(r.status, StatusCode::NOT_FOUND);
357
358 // Share management requires auth.
359 let anon = Client::new(env.app.clone());
360 assert_eq!(
361 anon.get("/api/shares").await.status,
362 StatusCode::UNAUTHORIZED
363 );
364}
365
366#[tokio::test]
367async fn share_token_is_not_the_creators_identity() {
368 let env = Env::new().await;
369 let admin = env.admin().await;
370 // The admin owns two shares; the visitor holds the token of one.
371 let secret = share(&admin, "src", false, None).await;
372 let s = share(&admin, "docs", false, None).await;
373 let token = s["token"].as_str().unwrap();
374 let id = s["id"].as_i64().unwrap();
375 let root_id = s["root_id"].as_i64().unwrap();
376
377 let anon = Client::new(env.app.clone());
378
379 // Listing shares with a share token must not leak the creator's shares
380 // (which would include every token they own).
381 let r = anon.get(&format!("/api/shares?share={token}")).await;
382 assert!(
383 r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN,
384 "list shares with share token: {} {}",
385 r.status,
386 r.text()
387 );
388 assert!(!r.text().contains(secret["token"].as_str().unwrap()));
389
390 // Nor create shares as the creator.
391 let r = anon
392 .post_json(
393 &format!("/api/shares?share={token}"),
394 &json!({ "root_id": root_id, "path": ".", "writable": false, "expires_at": null }),
395 )
396 .await;
397 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
398
399 // Nor delete the creator's shares.
400 let r = anon
401 .delete(&format!("/api/shares/{id}?share={token}"))
402 .await;
403 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
404 assert_eq!(
405 admin
406 .get("/api/shares")
407 .await
408 .json()
409 .as_array()
410 .unwrap()
411 .len(),
412 2
413 );
414
415 // Nor reach the admin API. The header form is blocked as well.
416 for path in ["/api/admin/users", "/api/admin/settings"] {
417 let r = anon.get(&format!("{path}?share={token}")).await;
418 assert!(
419 r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN,
420 "{path}: {}",
421 r.status
422 );
423 let r = anon
424 .raw(
425 axum::http::Method::GET,
426 path,
427 &[("x-share-token", token)],
428 Vec::new(),
429 )
430 .await;
431 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
432 }
433
434 // A real session still manages its own shares, and the share still browses.
435 assert_eq!(admin.get("/api/shares").await.status, StatusCode::OK);
436 assert_eq!(
437 anon.get(&format!("/api/files/{root_id}?share={token}"))
438 .await
439 .status,
440 StatusCode::OK
441 );
442}
443
444#[tokio::test]
445async fn read_only_root_cannot_be_shared_writably() {
446 let env = Env::new().await;
447 let admin = env.admin().await;
448 admin
449 .put_json(
450 "/api/admin/settings",
451 &json!({ "allow_writable_shares": true }),
452 )
453 .await;
454 // Bob sees "docs" read-only and "src" read-write.
455 create_user(&admin, "bob", "bobpass1", &[("docs", "ro"), ("src", "rw")]).await;
456 let bob = login(&env, "bob", "bobpass1").await;
457
458 let roots = bob.get("/api/auth/me").await.json();
459 let roots = roots["roots"].as_array().unwrap().clone();
460 let root_id = |mode: &str| -> i64 {
461 roots.iter().find(|r| r["mode"] == mode).unwrap()["id"]
462 .as_i64()
463 .unwrap()
464 };
465
466 // Writable share of the read-only root → 403.
467 let r = bob
468 .post_json(
469 "/api/shares",
470 &json!({ "root_id": root_id("ro"), "path": ".", "writable": true, "expires_at": null }),
471 )
472 .await;
473 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
474
475 // A read-only share of the same root is fine.
476 let r = bob
477 .post_json(
478 "/api/shares",
479 &json!({ "root_id": root_id("ro"), "path": ".", "writable": false, "expires_at": null }),
480 )
481 .await;
482 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
483 assert_eq!(r.json()["writable"], false);
484
485 // And the read-write root can still be shared writably.
486 let r = bob
487 .post_json(
488 "/api/shares",
489 &json!({ "root_id": root_id("rw"), "path": ".", "writable": true, "expires_at": null }),
490 )
491 .await;
492 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
493 assert_eq!(r.json()["writable"], true);
494}
495