api.rs
⎇
Raw
1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use wasm_bindgen::JsCast;
4use wasm_bindgen::JsValue;
5use wasm_bindgen_futures::JsFuture;
6
7#[derive(Debug, thiserror::Error)]
8pub enum ApiError {
9 /// Non-2xx response. `skipped` carries the server's conflict file list
10 /// when present (upload conflicts).
11 #[error("{message}")]
12 Http {
13 #[allow(dead_code)]
14 status: u16,
15 message: String,
16 skipped: Option<Vec<String>>,
17 },
18 /// The file changed on disk since it was read (save conflict, HTTP 409).
19 #[error("the file was changed on disk")]
20 Conflict,
21 #[error("network error: {0}")]
22 Net(String),
23}
24
25impl ApiError {
26 pub fn skipped(&self) -> Option<&[String]> {
27 match self {
28 ApiError::Http {
29 skipped: Some(s), ..
30 } => Some(s),
31 _ => None,
32 }
33 }
34
35 /// The HTTP status code, when this was an HTTP (non-2xx) error.
36 pub fn status(&self) -> Option<u16> {
37 match self {
38 ApiError::Http { status, .. } => Some(*status),
39 _ => None,
40 }
41 }
42}
43
44#[derive(Deserialize, Clone)]
45pub struct Me {
46 pub first_boot: bool,
47 #[serde(default)]
48 pub user: Option<UserInfo>,
49 #[serde(default)]
50 pub roots: Vec<RootInfo>,
51 /// Whether the server allows users to create writable (read-write) shares.
52 #[serde(default)]
53 pub allow_writable_shares: bool,
54}
55
56#[derive(Deserialize, Clone)]
57pub struct UserInfo {
58 #[allow(dead_code)] // used from milestone 7 onwards
59 pub id: i64,
60 pub name: String,
61 pub is_admin: bool,
62}
63
64#[derive(Deserialize, Clone)]
65pub struct RootInfo {
66 pub id: i64,
67 pub name: String,
68 pub path: String,
69 pub mode: String,
70}
71
72#[derive(Deserialize, Clone, Debug, PartialEq)]
73pub struct Entry {
74 pub name: String,
75 pub is_dir: bool,
76 pub size: u64,
77 pub mtime: String,
78}
79
80#[derive(Deserialize)]
81pub struct FilesResp {
82 pub entries: Vec<Entry>,
83}
84
85#[derive(Deserialize)]
86pub struct UploadResp {
87 #[allow(dead_code)]
88 pub uploaded: usize,
89}
90
91/// Acknowledges a successful 2xx response whose body we don't care about.
92/// Accepts any JSON value (the server sends `{"ok": true}`).
93pub struct OkResp;
94
95impl<'de> Deserialize<'de> for OkResp {
96 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
97 serde::de::IgnoredAny::deserialize(d)?;
98 Ok(OkResp)
99 }
100}
101
102#[derive(Serialize)]
103struct CredentialsBody {
104 name: String,
105 password: String,
106}
107
108/// Server error body: `{"error": "...", "skipped": [...]?}`.
109#[derive(Deserialize, Default)]
110struct ErrBody {
111 #[serde(default)]
112 error: Option<String>,
113 #[serde(default)]
114 skipped: Option<Vec<String>>,
115}
116
117// ---------------------------------------------------------------------------
118// Auth
119// ---------------------------------------------------------------------------
120
121pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
122 request("GET", "/api/auth/me".to_string(), None::<()>)
123}
124
125pub fn login(
126 name: String,
127 password: String,
128) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
129 request(
130 "POST",
131 "/api/auth/login".to_string(),
132 Some(CredentialsBody { name, password }),
133 )
134}
135
136pub fn setup(
137 name: String,
138 password: String,
139) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
140 request(
141 "POST",
142 "/api/auth/setup".to_string(),
143 Some(CredentialsBody { name, password }),
144 )
145}
146
147pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
148 request("POST", "/api/auth/logout".to_string(), Some(()))
149}
150
151// ---------------------------------------------------------------------------
152// Files
153// ---------------------------------------------------------------------------
154
155/// The public share token while the app is showing a share page. Every file
156/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
157/// shown per page, so a plain static suffices (wasm is single-threaded).
158use std::sync::Mutex;
159static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
160
161/// Set (or clear, with `None`) the share token used by file API calls.
162pub fn set_share_token(token: Option<&str>) {
163 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
164}
165
166fn share_suffix() -> String {
167 SHARE_TOKEN
168 .lock()
169 .unwrap()
170 .as_deref()
171 .map(|t| format!("?share={t}"))
172 .unwrap_or_default()
173}
174
175/// Append `key=value` to a URL, using `&` when a query string already exists.
176fn append_query(url: &str, kv: &str) -> String {
177 if url.contains('?') {
178 format!("{url}&{kv}")
179 } else {
180 format!("{url}?{kv}")
181 }
182}
183
184fn files_url(root_id: i64, path: &str) -> String {
185 let base = if path.is_empty() {
186 format!("/api/files/{root_id}")
187 } else {
188 let encoded: Vec<String> = path
189 .split('/')
190 .map(|s| js_sys::encode_uri_component(s).into())
191 .collect();
192 format!("/api/files/{root_id}/{}", encoded.join("/"))
193 };
194 format!("{base}{}", share_suffix())
195}
196
197pub fn list_files(
198 root_id: i64,
199 path: &str,
200) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
201 request("GET", files_url(root_id, path), None::<()>)
202}
203
204/// Create a folder. `path` is relative to the root (may contain subfolders).
205pub fn mkdir(
206 root_id: i64,
207 path: &str,
208) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
209 request_no_body("POST", files_url(root_id, path))
210}
211
212pub fn rename_item(
213 root_id: i64,
214 path: &str,
215 new_name: String,
216 overwrite: bool,
217) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
218 request(
219 "POST",
220 files_url(root_id, path),
221 Some(Mutation {
222 op: "rename".to_string(),
223 new_name: Some(new_name),
224 dst_root_id: None,
225 dst: None,
226 overwrite,
227 }),
228 )
229}
230
231pub fn move_item(
232 root_id: i64,
233 path: &str,
234 dst_root_id: i64,
235 dst: &str,
236 overwrite: bool,
237) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
238 mutation(root_id, path, "move", dst_root_id, dst, overwrite)
239}
240
241pub fn copy_item(
242 root_id: i64,
243 path: &str,
244 dst_root_id: i64,
245 dst: &str,
246 overwrite: bool,
247) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
248 mutation(root_id, path, "copy", dst_root_id, dst, overwrite)
249}
250
251fn mutation(
252 root_id: i64,
253 path: &str,
254 op: &str,
255 dst_root_id: i64,
256 dst: &str,
257 overwrite: bool,
258) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
259 request(
260 "POST",
261 files_url(root_id, path),
262 Some(Mutation {
263 op: op.to_string(),
264 new_name: None,
265 dst_root_id: Some(dst_root_id),
266 dst: Some(dst.to_string()),
267 overwrite,
268 }),
269 )
270}
271
272#[derive(Serialize)]
273struct Mutation {
274 op: String,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 new_name: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 dst_root_id: Option<i64>,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 dst: Option<String>,
281 overwrite: bool,
282}
283
284pub fn delete_item(
285 root_id: i64,
286 path: &str,
287) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
288 request_no_body("DELETE", files_url(root_id, path))
289}
290
291// ---------------------------------------------------------------------------
292// Download / preview / content (milestone 4)
293// ---------------------------------------------------------------------------
294
295/// `...?action=download` — a single file as-is, or a folder as `format`.
296pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
297 let mut base = append_query(&files_url(root_id, path), "action=download");
298 if let Some(f) = format {
299 base = append_query(&base, &format!("format={f}"));
300 }
301 base
302}
303
304/// `...?action=preview` — a single file, inline (native media).
305pub fn preview_url(root_id: i64, path: &str) -> String {
306 append_query(&files_url(root_id, path), "action=preview")
307}
308
309/// `...?action=content` — raw file bytes for the text preview/editor.
310pub fn content_url(root_id: i64, path: &str) -> String {
311 append_query(&files_url(root_id, path), "action=content")
312}
313
314/// Fetch a file's raw text content (for the CodeMirror preview/editor).
315pub async fn fetch_content(root_id: i64, path: &str) -> Result<String, ApiError> {
316 let window =
317 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
318 let opts = web_sys::RequestInit::new();
319 opts.set_method("GET");
320 opts.set_mode(web_sys::RequestMode::SameOrigin);
321 let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
322 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
323 let promise = window.fetch_with_request(&req);
324 let resp_val = JsFuture::from(promise)
325 .await
326 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
327 let resp: web_sys::Response = resp_val
328 .dyn_into()
329 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
330 let status = resp.status();
331 if !(200..300).contains(&status) {
332 let body = parse_error_body(&resp).await;
333 return Err(ApiError::Http {
334 status,
335 message: body
336 .error
337 .unwrap_or_else(|| "could not read file".to_string()),
338 skipped: None,
339 });
340 }
341 let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
342 let js = JsFuture::from(tp)
343 .await
344 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
345 js.as_string()
346 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))
347}
348
349/// Fetch a file's raw text content plus its mtime (unix seconds), for the
350/// editor. The mtime anchors the save-time conflict check.
351pub async fn fetch_content_meta(
352 root_id: i64,
353 path: &str,
354) -> Result<(String, Option<i64>), ApiError> {
355 let window =
356 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
357 let opts = web_sys::RequestInit::new();
358 opts.set_method("GET");
359 opts.set_mode(web_sys::RequestMode::SameOrigin);
360 let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
361 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
362 let promise = window.fetch_with_request(&req);
363 let resp_val = JsFuture::from(promise)
364 .await
365 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
366 let resp: web_sys::Response = resp_val
367 .dyn_into()
368 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
369 let status = resp.status();
370 if !(200..300).contains(&status) {
371 let body = parse_error_body(&resp).await;
372 return Err(ApiError::Http {
373 status,
374 message: body
375 .error
376 .unwrap_or_else(|| "could not read file".to_string()),
377 skipped: None,
378 });
379 }
380 let mtime: Option<i64> = resp
381 .headers()
382 .get("x-file-mtime")
383 .ok()
384 .flatten()
385 .and_then(|s| s.parse::<i64>().ok());
386 let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
387 let js = JsFuture::from(tp)
388 .await
389 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
390 let text = js
391 .as_string()
392 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
393 Ok((text, mtime))
394}
395
396/// Save a file's text content (the editor's write path).
397///
398/// When `force` is false, `expected_mtime` is sent and the server rejects the
399/// save with [`ApiError::Conflict`] if the file changed on disk since it was
400/// read. When `force` is true the check is skipped (overwrite). Returns the
401/// file's new mtime (unix seconds) to anchor the next check.
402pub async fn save_content(
403 root_id: i64,
404 path: &str,
405 text: &str,
406 expected_mtime: Option<i64>,
407 force: bool,
408) -> Result<i64, ApiError> {
409 let window =
410 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
411 let url = append_query(&files_url(root_id, path), "action=content");
412 let opts = web_sys::RequestInit::new();
413 opts.set_method("PUT");
414 opts.set_mode(web_sys::RequestMode::SameOrigin);
415 opts.set_body_opt_str(Some(text));
416 let headers = web_sys::Headers::new()
417 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
418 headers
419 .set("Content-Type", "text/plain; charset=utf-8")
420 .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
421 if !force {
422 if let Some(m) = expected_mtime {
423 headers
424 .set("X-Expected-Mtime", &m.to_string())
425 .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
426 }
427 }
428 opts.set_headers_headers(&headers);
429 let req = web_sys::Request::new_with_str_and_init(&url, &opts)
430 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
431 let promise = window.fetch_with_request(&req);
432 let resp_val = JsFuture::from(promise)
433 .await
434 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
435 let resp: web_sys::Response = resp_val
436 .dyn_into()
437 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
438 let status = resp.status();
439 if status == 409 {
440 return Err(ApiError::Conflict);
441 }
442 if !(200..300).contains(&status) {
443 let body = parse_error_body(&resp).await;
444 return Err(ApiError::Http {
445 status,
446 message: body
447 .error
448 .unwrap_or_else(|| "could not save file".to_string()),
449 skipped: None,
450 });
451 }
452 let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
453 let js: JsValue = JsFuture::from(js)
454 .await
455 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
456 #[derive(Deserialize)]
457 struct SaveResp {
458 #[serde(default)]
459 mtime: Option<i64>,
460 }
461 let save: SaveResp =
462 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?;
463 Ok(save.mtime.unwrap_or(0))
464}
465
466/// Trigger a browser download of a same-origin URL via a temporary anchor.
467/// No data is pulled into JS memory — the browser streams it.
468pub fn trigger_download(url: &str, filename: &str) {
469 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
470 return;
471 };
472 let Ok(el) = doc.create_element("a") else {
473 return;
474 };
475 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
476 return;
477 };
478 a.set_href(url);
479 a.set_download(filename);
480 if let Some(body) = doc.body() {
481 let _ = body.append_child(&a);
482 }
483 a.click();
484 a.remove();
485}
486
487/// Upload files into a directory. Each part is `(relative_path, file)`;
488/// the relative path may contain subfolders (created on the server).
489///
490/// The multipart body is assembled by hand into a `Blob` (instead of using
491/// `FormData` directly as the fetch body): a FormData body makes Chrome send
492/// the request as a *streaming* body, which forces the HTTP/2-cleartext
493/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
494/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
495/// it goes out as a regular length-prefixed HTTP/1.1 request.
496pub async fn upload(
497 root_id: i64,
498 dir: &str,
499 overwrite: bool,
500 parts: Vec<(String, web_sys::File)>,
501) -> Result<UploadResp, ApiError> {
502 let window =
503 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
504
505 let boundary = format!("----fbng{}", random_boundary_suffix());
506 let segments = js_sys::Array::new();
507 for (name, file) in &parts {
508 let basename = name.rsplit('/').next().unwrap_or(name);
509 segments.push(&JsValue::from_str(&format!(
510 "--{boundary}\r\n\
511 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
512 Content-Type: application/octet-stream\r\n\r\n"
513 )));
514 let f: JsValue = file.clone().unchecked_into();
515 segments.push(&f);
516 segments.push(&JsValue::from_str("\r\n"));
517 }
518 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
519 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
520 .map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
521
522 let url = append_query(
523 &files_url(root_id, dir),
524 &format!("overwrite={}", if overwrite { "true" } else { "false" }),
525 );
526
527 let headers = web_sys::Headers::new()
528 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
529 headers
530 .set(
531 "Content-Type",
532 &format!("multipart/form-data; boundary={boundary}"),
533 )
534 .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
535
536 let opts = web_sys::RequestInit::new();
537 opts.set_method("POST");
538 opts.set_mode(web_sys::RequestMode::SameOrigin);
539 opts.set_headers_headers(&headers);
540 opts.set_body_opt_blob(Some(&body));
541
542 let promise = window.fetch_with_str_and_init(&url, &opts);
543 let resp_val = JsFuture::from(promise)
544 .await
545 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
546 let resp: web_sys::Response = resp_val
547 .dyn_into()
548 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
549
550 let status = resp.status();
551 if !(200..300).contains(&status) {
552 let body = parse_error_body(&resp).await;
553 return Err(ApiError::Http {
554 status,
555 message: body.error.unwrap_or_else(|| "upload failed".to_string()),
556 skipped: body.skipped,
557 });
558 }
559 let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
560 let js: JsValue = JsFuture::from(js)
561 .await
562 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
563 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
564}
565
566// ---------------------------------------------------------------------------
567// Shares (milestone 6)
568// ---------------------------------------------------------------------------
569
570#[derive(Deserialize, Clone)]
571pub struct ShareInfo {
572 pub id: i64,
573 pub token: String,
574 pub name: String,
575 pub is_file: bool,
576 pub writable: bool,
577 pub target: String,
578 pub created_at: String,
579 #[serde(default)]
580 pub expires_at: Option<String>,
581 /// The synthetic root id to use in file API calls.
582 #[serde(default)]
583 pub root_id: Option<i64>,
584}
585
586#[derive(Serialize)]
587struct CreateShareBody {
588 root_id: i64,
589 path: String,
590 writable: bool,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 expires_at: Option<String>,
593}
594
595pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
596 request("GET", "/api/shares".to_string(), None::<()>)
597}
598
599pub fn create_share(
600 root_id: i64,
601 path: &str,
602 writable: bool,
603 expires_at: Option<&str>,
604) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
605 request(
606 "POST",
607 "/api/shares".to_string(),
608 Some(CreateShareBody {
609 root_id,
610 path: path.to_string(),
611 writable,
612 expires_at: expires_at.map(|s| s.to_string()),
613 }),
614 )
615}
616
617pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
618 request_no_body("DELETE", format!("/api/shares/{id}"))
619}
620
621/// Public: resolve a share (no session required).
622pub fn resolve_share(
623 token: &str,
624) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
625 request("GET", format!("/api/share/{token}"), None::<()>)
626}
627
628// ---------------------------------------------------------------------------
629// Admin (milestone 7): user management + settings
630// ---------------------------------------------------------------------------
631
632#[derive(Deserialize, Clone)]
633pub struct AdminUser {
634 pub id: i64,
635 pub name: String,
636 pub is_admin: bool,
637 pub active: bool,
638 pub roots: Vec<RootInfo>,
639}
640
641#[derive(Serialize)]
642struct AdminRootBody {
643 path: String,
644 mode: String,
645}
646
647#[derive(Serialize)]
648struct CreateAdminUserBody {
649 name: String,
650 password: String,
651 is_admin: bool,
652 roots: Vec<AdminRootBody>,
653}
654
655#[derive(Serialize)]
656struct UpdateAdminUserBody {
657 #[serde(skip_serializing_if = "Option::is_none")]
658 password: Option<String>,
659 #[serde(skip_serializing_if = "Option::is_none")]
660 is_admin: Option<bool>,
661 #[serde(skip_serializing_if = "Option::is_none")]
662 active: Option<bool>,
663 #[serde(skip_serializing_if = "Option::is_none")]
664 roots: Option<Vec<AdminRootBody>>,
665}
666
667#[derive(Serialize, Deserialize, Clone)]
668pub struct AdminSettings {
669 pub allow_writable_shares: bool,
670}
671
672fn roots_to_bodies(roots: &[(String, String)]) -> Vec<AdminRootBody> {
673 roots
674 .iter()
675 .map(|(path, mode)| AdminRootBody {
676 path: path.clone(),
677 mode: mode.clone(),
678 })
679 .collect()
680}
681
682pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
683 request("GET", "/api/admin/users".to_string(), None::<()>)
684}
685
686pub fn create_admin_user(
687 name: &str,
688 password: &str,
689 is_admin: bool,
690 roots: &[(String, String)],
691) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
692 request(
693 "POST",
694 "/api/admin/users".to_string(),
695 Some(CreateAdminUserBody {
696 name: name.to_string(),
697 password: password.to_string(),
698 is_admin,
699 roots: roots_to_bodies(roots),
700 }),
701 )
702}
703
704pub fn update_admin_user(
705 id: i64,
706 password: Option<String>,
707 is_admin: Option<bool>,
708 active: Option<bool>,
709 roots: Option<Vec<(String, String)>>,
710) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
711 request(
712 "PUT",
713 format!("/api/admin/users/{id}"),
714 Some(UpdateAdminUserBody {
715 password,
716 is_admin,
717 active,
718 roots: roots.map(|r| roots_to_bodies(&r)),
719 }),
720 )
721}
722
723pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
724 request_no_body("DELETE", format!("/api/admin/users/{id}"))
725}
726
727pub fn get_admin_settings() -> impl std::future::Future<Output = Result<AdminSettings, ApiError>> {
728 request("GET", "/api/admin/settings".to_string(), None::<()>)
729}
730
731pub fn update_admin_settings(
732 allow_writable_shares: bool,
733) -> impl std::future::Future<Output = Result<AdminSettings, ApiError>> {
734 request(
735 "PUT",
736 "/api/admin/settings".to_string(),
737 Some(AdminSettings {
738 allow_writable_shares,
739 }),
740 )
741}
742
743// ---------------------------------------------------------------------------
744// File picker (imperative, one at a time)
745// ---------------------------------------------------------------------------
746
747fn random_boundary_suffix() -> String {
748 let mut s = String::with_capacity(16);
749 for _ in 0..16 {
750 let n = (js_sys::Math::random() * 36.0) as u32;
751 s.push(char::from_digit(n, 36).unwrap_or('a'));
752 }
753 s
754}
755
756use wasm_bindgen::closure::Closure;
757
758/// Open the native file dialog and run `on_files` with the picked files once
759/// the user confirms. `directory` uses webkitdirectory (folder upload).
760fn webkit_relative_path(file: &web_sys::File) -> String {
761 let js: JsValue = file.into();
762 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
763 .ok()
764 .and_then(|v| v.as_string())
765 .filter(|s| !s.is_empty())
766 .unwrap_or_default()
767}
768
769pub fn pick_files(
770 multiple: bool,
771 directory: bool,
772 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
773) {
774 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
775 return;
776 };
777 let Ok(el) = doc.create_element("input") else {
778 return;
779 };
780 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
781 return;
782 };
783 input.set_type("file");
784 if multiple {
785 input.set_multiple(true);
786 }
787 if directory {
788 let _ = input.set_attribute("webkitdirectory", "");
789 }
790
791 // The listener keeps the JS callback alive while the input exists;
792 // detach from Rust (the input is removed after the dialog is used).
793 // A cancelled dialog leaks the hidden input until reload — acceptable.
794 let input2 = input.clone();
795 let closure = Closure::<dyn FnMut()>::new(move || {
796 let mut files: Vec<(String, web_sys::File)> = Vec::new();
797 if let Some(list) = input.files() {
798 for i in 0..list.length() {
799 if let Some(f) = list.get(i) {
800 let rel = webkit_relative_path(&f);
801 let name = if rel.is_empty() { f.name() } else { rel };
802 files.push((name, f));
803 }
804 }
805 }
806 input.remove();
807 if !files.is_empty() {
808 on_files(files);
809 }
810 });
811 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
812 let _ = input2.add_event_listener_with_callback("change", listener);
813 closure.forget();
814 if let Some(body) = doc.body() {
815 let _ = body.append_child(&input2);
816 }
817 input2.click();
818}
819
820// ---------------------------------------------------------------------------
821// Low-level request helpers
822// ---------------------------------------------------------------------------
823
824async fn request<T: DeserializeOwned>(
825 method: &str,
826 url: String,
827 body: Option<impl Serialize>,
828) -> Result<T, ApiError> {
829 let window =
830 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
831
832 let opts = web_sys::RequestInit::new();
833 opts.set_method(method);
834 opts.set_mode(web_sys::RequestMode::SameOrigin);
835 if let Some(body) = body {
836 let json = serde_json_to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
837 opts.set_body_opt_str(Some(&json));
838 let headers = web_sys::Headers::new().expect("Headers constructor failed");
839 let _ = headers.set("Content-Type", "application/json");
840 opts.set_headers_headers(&headers);
841 }
842
843 do_fetch(window, url, opts).await
844}
845
846/// Request without a body (mkdir / delete).
847async fn request_no_body<T: DeserializeOwned>(method: &str, url: String) -> Result<T, ApiError> {
848 let window =
849 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
850 let opts = web_sys::RequestInit::new();
851 opts.set_method(method);
852 opts.set_mode(web_sys::RequestMode::SameOrigin);
853 do_fetch(window, url, opts).await
854}
855
856async fn do_fetch<T: DeserializeOwned>(
857 window: web_sys::Window,
858 url: String,
859 opts: web_sys::RequestInit,
860) -> Result<T, ApiError> {
861 let req = web_sys::Request::new_with_str_and_init(&url, &opts)
862 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
863
864 let promise = window.fetch_with_request(&req);
865 let resp_val = JsFuture::from(promise)
866 .await
867 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
868 let resp: web_sys::Response = resp_val
869 .dyn_into()
870 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
871
872 let status = resp.status();
873 if !(200..300).contains(&status) {
874 let body = parse_error_body(&resp).await;
875 return Err(ApiError::Http {
876 status,
877 message: body.error.unwrap_or_else(|| "request failed".to_string()),
878 skipped: body.skipped,
879 });
880 }
881
882 let json_promise = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
883 let js: JsValue = JsFuture::from(json_promise)
884 .await
885 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
886
887 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
888}
889
890/// Parse the server's error JSON (message + optional conflict list).
891async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
892 let Ok(promise) = resp.text() else {
893 return ErrBody::default();
894 };
895 let Ok(js) = JsFuture::from(promise).await else {
896 return ErrBody::default();
897 };
898 let Some(text) = js.as_string() else {
899 return ErrBody::default();
900 };
901 if let Ok(v) = js_sys::JSON::parse(&text) {
902 if let Ok(body) = serde_wasm_bindgen::from_value::<ErrBody>(v) {
903 return body;
904 }
905 }
906 // Fallback: naive extraction of the "error" string.
907 const MARKER: &str = "\"error\":\"";
908 let error = text.find(MARKER).and_then(|start| {
909 let rest = &text[start + MARKER.len()..];
910 rest.find('"').map(|end| rest[..end].replace("\\\"", "\""))
911 });
912 ErrBody {
913 error,
914 skipped: None,
915 }
916}
917
918/// Read a form input's value by element id.
919pub fn input_value(id: &str) -> String {
920 web_sys::window()
921 .and_then(|w| w.document())
922 .and_then(|d| {
923 d.get_element_by_id(id)
924 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
925 })
926 .map(|i| i.value())
927 .unwrap_or_default()
928}
929
930fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
931 // Reuse the wasm-bindgen JSON serializer; the body must be a plain string
932 // so we convert via JSON text.
933 let value = serde_wasm_bindgen::to_value(v)?;
934 let s = js_sys::JSON::stringify(&value)
935 .map_err(|e| serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}")))?;
936 s.as_string()
937 .ok_or_else(|| serde_wasm_bindgen::Error::new("stringify returned a non-string"))
938}
939