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