api.rs
⎇
Raw
1//! Typed HTTP client for the filebrowser-ng API.
2//!
3//! Endpoint paths, query params and wire types all come from the shared
4//! `api_types` crate (the same one the server's route table and handlers
5//! use), so the two sides cannot drift apart.
6
7use leptos::prelude::Callable;
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use wasm_bindgen::JsCast;
11use wasm_bindgen::JsValue;
12use wasm_bindgen::closure::Closure;
13use wasm_bindgen_futures::JsFuture;
14
15use api_types::{
16 ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
17 ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare,
18 CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT,
19 P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings, UnlockShare,
20 UpdateUser,
21};
22pub use api_types::{
23 AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
24};
25
26#[derive(Debug, thiserror::Error)]
27pub enum ApiError {
28 /// Non-2xx response. `skipped` carries the server's conflict file list
29 /// when present (upload conflicts).
30 #[error("{message}")]
31 Http {
32 #[allow(dead_code)]
33 status: u16,
34 message: String,
35 skipped: Option<Vec<String>>,
36 },
37 /// The file changed on disk since it was read (save conflict, HTTP 409).
38 #[error("the file was changed on disk")]
39 Conflict,
40 /// The request never got a response (server down, connection lost) or
41 /// the response could not be used. Carries a message ready to display.
42 #[error("{0}")]
43 Net(String),
44}
45
46/// A JS error's `message` (the whole `Debug` output includes the stack).
47fn js_msg(e: &JsValue) -> String {
48 e.dyn_ref::<js_sys::Error>()
49 .map(|e| String::from(e.message()))
50 .unwrap_or_else(|| format!("{e:?}"))
51}
52
53impl ApiError {
54 pub fn skipped(&self) -> Option<&[String]> {
55 match self {
56 ApiError::Http {
57 skipped: Some(s), ..
58 } => Some(s),
59 _ => None,
60 }
61 }
62
63 /// The HTTP status code, when this was an HTTP (non-2xx) error.
64 pub fn status(&self) -> Option<u16> {
65 match self {
66 ApiError::Http { status, .. } => Some(*status),
67 _ => None,
68 }
69 }
70}
71
72/// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`.
73/// The message is already localized in [`fetch_checked`]; `code` carries the
74/// machine-readable identifier the server sends for known failures.
75#[derive(serde::Deserialize, Default)]
76struct ErrBody {
77 #[serde(default)]
78 error: Option<String>,
79 #[serde(default)]
80 code: Option<String>,
81 #[serde(default)]
82 skipped: Option<Vec<String>>,
83}
84
85// ---------------------------------------------------------------------------
86// Auth
87// ---------------------------------------------------------------------------
88
89pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
90 request("GET", AUTH_ME.to_string(), None::<()>)
91}
92
93/// PUT /api/auth/me — update the signed-in user's profile settings.
94/// Omitted fields are left unchanged; `language: Some(None)` means "follow
95/// the browser".
96#[derive(Serialize, Default)]
97struct ProfilePatch {
98 #[serde(skip_serializing_if = "Option::is_none")]
99 single_click_open: Option<bool>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 language: Option<Option<String>>,
102}
103
104pub fn update_profile(
105 single_click_open: Option<bool>,
106 language: Option<Option<String>>,
107) -> impl std::future::Future<Output = Result<Me, ApiError>> {
108 request(
109 "PUT",
110 AUTH_ME.to_string(),
111 Some(ProfilePatch {
112 single_click_open,
113 language,
114 }),
115 )
116}
117
118pub fn login(
119 name: String,
120 password: String,
121) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
122 request(
123 "POST",
124 AUTH_LOGIN.to_string(),
125 Some(Credentials { name, password }),
126 )
127}
128
129pub fn setup(
130 name: String,
131 password: String,
132) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
133 request(
134 "POST",
135 AUTH_SETUP.to_string(),
136 Some(Credentials { name, password }),
137 )
138}
139
140pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
141 request("POST", AUTH_LOGOUT.to_string(), Some(()))
142}
143
144// ---------------------------------------------------------------------------
145// Files
146// ---------------------------------------------------------------------------
147
148/// The public share token while the app is showing a share page. Every file
149/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
150/// shown per page, so a plain static suffices (wasm is single-threaded).
151use std::sync::Mutex;
152static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
153
154/// Set (or clear, with `None`) the share token used by file API calls.
155pub fn set_share_token(token: Option<&str>) {
156 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
157}
158
159fn share_suffix() -> String {
160 SHARE_TOKEN
161 .lock()
162 .unwrap()
163 .as_deref()
164 .map(|t| format!("?{P_SHARE}={t}"))
165 .unwrap_or_default()
166}
167
168/// Append `key=value` to a URL, using `&` when a query string already exists.
169fn append_query(url: &str, kv: &str) -> String {
170 if url.contains('?') {
171 format!("{url}&{kv}")
172 } else {
173 format!("{url}?{kv}")
174 }
175}
176
177fn files_url(root_id: i64, path: &str) -> String {
178 let base = if path.is_empty() {
179 format!("{FILES}/{root_id}")
180 } else {
181 let encoded: Vec<String> = path
182 .split('/')
183 .map(|s| js_sys::encode_uri_component(s).into())
184 .collect();
185 format!("{FILES}/{root_id}/{}", encoded.join("/"))
186 };
187 format!("{base}{}", share_suffix())
188}
189
190pub fn list_files(
191 root_id: i64,
192 path: &str,
193) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
194 request("GET", files_url(root_id, path), None::<()>)
195}
196
197/// Create an empty file. `path` is relative to the root (may contain
198/// subfolders); the file must not exist yet.
199pub fn create_file(
200 root_id: i64,
201 path: &str,
202) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
203 let url = append_query(
204 &files_url(root_id, path),
205 &format!("{P_ACTION}={ACTION_CREATE_FILE}"),
206 );
207 request("POST", url, None::<()>)
208}
209
210/// Create a folder. `path` is relative to the root (may contain subfolders).
211pub fn mkdir(
212 root_id: i64,
213 path: &str,
214) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
215 let url = append_query(
216 &files_url(root_id, path),
217 &format!("{P_ACTION}={ACTION_MKDIR}"),
218 );
219 request("POST", url, None::<()>)
220}
221
222pub fn rename_item(
223 root_id: i64,
224 path: &str,
225 new_name: String,
226 overwrite: bool,
227) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
228 request(
229 "POST",
230 files_url(root_id, path),
231 Some(Mutation {
232 op: Op::Rename,
233 new_name: Some(new_name),
234 dst_root_id: None,
235 dst: None,
236 overwrite,
237 }),
238 )
239}
240
241pub fn move_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, Op::Move, dst_root_id, dst, overwrite)
249}
250
251pub fn copy_item(
252 root_id: i64,
253 path: &str,
254 dst_root_id: i64,
255 dst: &str,
256 overwrite: bool,
257) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
258 mutation(root_id, path, Op::Copy, dst_root_id, dst, overwrite)
259}
260
261fn mutation(
262 root_id: i64,
263 path: &str,
264 op: Op,
265 dst_root_id: i64,
266 dst: &str,
267 overwrite: bool,
268) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
269 request(
270 "POST",
271 files_url(root_id, path),
272 Some(Mutation {
273 op,
274 new_name: None,
275 dst_root_id: Some(dst_root_id),
276 dst: Some(dst.to_string()),
277 overwrite,
278 }),
279 )
280}
281
282pub fn delete_item(
283 root_id: i64,
284 path: &str,
285) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
286 request("DELETE", files_url(root_id, path), None::<()>)
287}
288
289// ---------------------------------------------------------------------------
290// Download / preview / content (milestone 4)
291// ---------------------------------------------------------------------------
292
293/// `...?action=download` — a single file as-is, or a folder as `format`.
294pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
295 let mut base = append_query(
296 &files_url(root_id, path),
297 &format!("{P_ACTION}={ACTION_DOWNLOAD}"),
298 );
299 if let Some(f) = format {
300 base = append_query(&base, &format!("{P_FORMAT}={f}"));
301 }
302 base
303}
304
305/// `...?action=preview` — a single file, inline (native media).
306pub fn preview_url(root_id: i64, path: &str) -> String {
307 append_query(
308 &files_url(root_id, path),
309 &format!("{P_ACTION}={ACTION_PREVIEW}"),
310 )
311}
312
313/// `...?action=content` — raw file bytes for the text preview/editor.
314pub fn content_url(root_id: i64, path: &str) -> String {
315 append_query(
316 &files_url(root_id, path),
317 &format!("{P_ACTION}={ACTION_CONTENT}"),
318 )
319}
320
321/// Fetch a file's raw text content plus its mtime (unix seconds), for the
322/// preview and the editor. The mtime anchors the save-time conflict check.
323pub async fn fetch_content_meta(
324 root_id: i64,
325 path: &str,
326) -> Result<(String, Option<i64>), ApiError> {
327 let opts = web_sys::RequestInit::new();
328 opts.set_method("GET");
329 opts.set_mode(web_sys::RequestMode::SameOrigin);
330 let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
331 let mtime: Option<i64> = resp
332 .headers()
333 .get("x-file-mtime")
334 .ok()
335 .flatten()
336 .and_then(|s| s.parse::<i64>().ok());
337 let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?;
338 let js = JsFuture::from(tp)
339 .await
340 .map_err(|e| ApiError::Net(js_msg(&e)))?;
341 let text = js
342 .as_string()
343 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
344 Ok((text, mtime))
345}
346
347/// Save a file's text content (the editor's write path).
348///
349/// When `force` is false, `expected_mtime` is sent and the server rejects the
350/// save with [`ApiError::Conflict`] if the file changed on disk since it was
351/// read. When `force` is true the check is skipped (overwrite). Returns the
352/// file's new mtime (unix seconds) to anchor the next check.
353pub async fn save_content(
354 root_id: i64,
355 path: &str,
356 text: &str,
357 expected_mtime: Option<i64>,
358 force: bool,
359) -> Result<i64, ApiError> {
360 let url = content_url(root_id, path);
361 let opts = web_sys::RequestInit::new();
362 opts.set_method("PUT");
363 opts.set_mode(web_sys::RequestMode::SameOrigin);
364 opts.set_body_opt_str(Some(text));
365 let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
366 headers
367 .set("Content-Type", "text/plain; charset=utf-8")
368 .map_err(|e| ApiError::Net(js_msg(&e)))?;
369 if !force && let Some(m) = expected_mtime {
370 headers
371 .set("X-Expected-Mtime", &m.to_string())
372 .map_err(|e| ApiError::Net(js_msg(&e)))?;
373 }
374 opts.set_headers_headers(&headers);
375 // 409 is the server's "changed on disk" answer, not a generic HTTP error.
376 let resp = match fetch_checked(&url, &opts, "could not save file").await {
377 Ok(resp) => resp,
378 Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
379 Err(e) => return Err(e),
380 };
381 let save: SaveResp = read_json(&resp).await?;
382 Ok(save.mtime)
383}
384
385/// Open a URL in a new tab.
386///
387/// `noopener` severs the `window.opener` link, so the opened page cannot
388/// script this one. That matters here because the target is a *user file*:
389/// HTML and SVG render as real documents (under the server's sandbox CSP, see
390/// `FILE_CSP`), and this is the browser-side half of the same isolation.
391pub fn open_in_new_tab(url: &str) {
392 if let Some(w) = web_sys::window() {
393 let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener");
394 }
395}
396
397/// Trigger a browser download of a same-origin URL via a temporary anchor.
398/// No data is pulled into JS memory — the browser streams it.
399pub fn trigger_download(url: &str, filename: &str) {
400 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
401 return;
402 };
403 let Ok(el) = doc.create_element("a") else {
404 return;
405 };
406 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
407 return;
408 };
409 a.set_href(url);
410 a.set_download(filename);
411 if let Some(body) = doc.body() {
412 let _ = body.append_child(&a);
413 }
414 a.click();
415 a.remove();
416}
417
418/// Upload files into a directory. Each part is `(relative_path, file)`;
419/// the relative path may contain subfolders (created on the server).
420///
421/// The multipart body is assembled by hand into a `Blob` (instead of using
422/// `FormData` directly as the fetch body): a FormData body makes Chrome send
423/// the request as a *streaming* body, which forces the HTTP/2-cleartext
424/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
425/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
426/// it goes out as a regular length-prefixed HTTP/1.1 request.
427pub async fn upload(
428 root_id: i64,
429 dir: &str,
430 overwrite: bool,
431 parts: Vec<(String, web_sys::File)>,
432) -> Result<(), ApiError> {
433 let boundary = format!("----fbng{}", random_boundary_suffix());
434 let segments = js_sys::Array::new();
435 for (name, file) in &parts {
436 let basename = name.rsplit('/').next().unwrap_or(name);
437 segments.push(&JsValue::from_str(&format!(
438 "--{boundary}\r\n\
439 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
440 Content-Type: application/octet-stream\r\n\r\n"
441 )));
442 let f: JsValue = file.clone().unchecked_into();
443 segments.push(&f);
444 segments.push(&JsValue::from_str("\r\n"));
445 }
446 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
447 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
448 .map_err(|e| ApiError::Net(js_msg(&e)))?;
449
450 let url = append_query(
451 &files_url(root_id, dir),
452 &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
453 );
454
455 let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
456 headers
457 .set(
458 "Content-Type",
459 &format!("multipart/form-data; boundary={boundary}"),
460 )
461 .map_err(|e| ApiError::Net(js_msg(&e)))?;
462
463 let opts = web_sys::RequestInit::new();
464 opts.set_method("POST");
465 opts.set_mode(web_sys::RequestMode::SameOrigin);
466 opts.set_headers_headers(&headers);
467 opts.set_body_opt_blob(Some(&body));
468
469 // The error carries the server's `skipped` list on an upload conflict.
470 fetch_checked(&url, &opts, "upload failed").await?;
471 Ok(())
472}
473
474// ---------------------------------------------------------------------------
475// Shares (milestone 6)
476// ---------------------------------------------------------------------------
477
478pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
479 request("GET", SHARES.to_string(), None::<()>)
480}
481
482pub fn create_share(
483 root_id: i64,
484 path: &str,
485 writable: bool,
486 expires_at: Option<&str>,
487 password: Option<&str>,
488) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
489 request(
490 "POST",
491 SHARES.to_string(),
492 Some(CreateShare {
493 root_id,
494 path: path.to_string(),
495 writable,
496 expires_at: expires_at.map(|s| s.to_string()),
497 password: password.map(|s| s.to_string()),
498 }),
499 )
500}
501
502pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
503 request("DELETE", format!("{SHARES}/{id}"), None::<()>)
504}
505
506/// Public: resolve a share (no session required). A password-protected
507/// share answers 401 until [`unlock_share`] has run in this browser.
508pub fn resolve_share(
509 token: &str,
510) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
511 request("GET", format!("{SHARE}/{token}"), None::<()>)
512}
513
514/// Public: submit a protected share's password. The proof of the unlock is
515/// a cookie the server sets, so nothing has to be kept here.
516pub fn unlock_share(
517 token: &str,
518 password: &str,
519) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
520 request(
521 "POST",
522 format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
523 Some(UnlockShare {
524 password: password.to_string(),
525 }),
526 )
527}
528
529// ---------------------------------------------------------------------------
530// Admin (milestone 7): user management + settings
531// ---------------------------------------------------------------------------
532
533fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
534 roots
535 .iter()
536 .map(|(path, mode)| Root {
537 path: path.clone(),
538 mode: *mode,
539 })
540 .collect()
541}
542
543pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
544 request("GET", ADMIN_USERS.to_string(), None::<()>)
545}
546
547pub fn create_admin_user(
548 name: &str,
549 password: &str,
550 is_admin: bool,
551 roots: &[(String, Mode)],
552) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
553 request(
554 "POST",
555 ADMIN_USERS.to_string(),
556 Some(CreateUser {
557 name: name.to_string(),
558 password: password.to_string(),
559 is_admin,
560 roots: roots_to_bodies(roots),
561 }),
562 )
563}
564
565pub fn update_admin_user(
566 id: i64,
567 password: Option<String>,
568 is_admin: Option<bool>,
569 active: Option<bool>,
570 roots: Option<Vec<(String, Mode)>>,
571) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
572 request(
573 "PUT",
574 format!("{ADMIN_USERS}/{id}"),
575 Some(UpdateUser {
576 password,
577 is_admin,
578 active,
579 roots: roots.map(|r| roots_to_bodies(&r)),
580 }),
581 )
582}
583
584pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
585 request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
586}
587
588pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
589 request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
590}
591
592/// PUT replaces the whole settings object, so every setting has to be
593/// passed. Omitting one would reset it.
594pub fn update_admin_settings(
595 allow_writable_shares: bool,
596 search_excludes: Vec<String>,
597) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
598 request(
599 "PUT",
600 ADMIN_SETTINGS.to_string(),
601 Some(Settings {
602 allow_writable_shares,
603 search_excludes,
604 }),
605 )
606}
607
608// ---------------------------------------------------------------------------
609// File picker (imperative, one at a time)
610// ---------------------------------------------------------------------------
611
612fn random_boundary_suffix() -> String {
613 let mut s = String::with_capacity(16);
614 for _ in 0..16 {
615 let n = (js_sys::Math::random() * 36.0) as u32;
616 s.push(char::from_digit(n, 36).unwrap_or('a'));
617 }
618 s
619}
620
621/// Open the native file dialog and run `on_files` with the picked files once
622/// the user confirms. `directory` uses webkitdirectory (folder upload).
623fn webkit_relative_path(file: &web_sys::File) -> String {
624 let js: JsValue = file.into();
625 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
626 .ok()
627 .and_then(|v| v.as_string())
628 .filter(|s| !s.is_empty())
629 .unwrap_or_default()
630}
631
632pub fn pick_files(
633 multiple: bool,
634 directory: bool,
635 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
636) {
637 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
638 return;
639 };
640 let Ok(el) = doc.create_element("input") else {
641 return;
642 };
643 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
644 return;
645 };
646 input.set_type("file");
647 if multiple {
648 input.set_multiple(true);
649 }
650 if directory {
651 let _ = input.set_attribute("webkitdirectory", "");
652 }
653
654 // Known small leak, deliberate: the input holds the listener, the
655 // listener holds this closure, and the closure captures the input — a
656 // cycle that nothing frees. `forget()` detaches the Rust side, so the
657 // element + JS function + closure (~1 KB) survive until reload even
658 // when the dialog is used (not only when cancelled). Dropping the
659 // closure from Rust while the JS listener still references it would
660 // leave a dangling callback, and a closure cannot drop itself; a clean
661 // fix needs the caller to own it (e.g. a `StoredValue` released in
662 // `on_cleanup`), which is not worth it at this size.
663 let input2 = input.clone();
664 let closure = Closure::<dyn FnMut()>::new(move || {
665 let mut files: Vec<(String, web_sys::File)> = Vec::new();
666 if let Some(list) = input.files() {
667 for i in 0..list.length() {
668 if let Some(f) = list.get(i) {
669 let rel = webkit_relative_path(&f);
670 let name = if rel.is_empty() { f.name() } else { rel };
671 files.push((name, f));
672 }
673 }
674 }
675 input.remove();
676 if !files.is_empty() {
677 on_files(files);
678 }
679 });
680 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
681 let _ = input2.add_event_listener_with_callback("change", listener);
682 closure.forget();
683 if let Some(body) = doc.body() {
684 let _ = body.append_child(&input2);
685 }
686 input2.click();
687}
688
689// ---------------------------------------------------------------------------
690// Low-level request helpers
691// ---------------------------------------------------------------------------
692
693async fn request<T: DeserializeOwned>(
694 method: &str,
695 url: String,
696 body: Option<impl Serialize>,
697) -> Result<T, ApiError> {
698 let opts = web_sys::RequestInit::new();
699 opts.set_method(method);
700 opts.set_mode(web_sys::RequestMode::SameOrigin);
701 if let Some(body) = body {
702 let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
703 opts.set_body_opt_str(Some(&json));
704 let headers = web_sys::Headers::new().expect("Headers constructor failed");
705 let _ = headers.set("Content-Type", "application/json");
706 opts.set_headers_headers(&headers);
707 }
708
709 do_fetch(&url, &opts).await
710}
711
712/// Run one fetch and return the response, turning any non-2xx status into
713/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
714/// message. Callers that need the raw response (text, a header, or nothing at
715/// all) use this directly; JSON callers go through [`do_fetch`].
716async fn fetch_checked(
717 url: &str,
718 opts: &web_sys::RequestInit,
719 fallback_msg: &str,
720) -> Result<web_sys::Response, ApiError> {
721 let window =
722 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
723 let req = web_sys::Request::new_with_str_and_init(url, opts)
724 .map_err(|e| ApiError::Net(js_msg(&e)))?;
725
726 let promise = window.fetch_with_request(&req);
727 // `fetch` only rejects when no response arrived at all (server down,
728 // connection lost, blocked): one short localized line instead of the
729 // JS stack.
730 let resp_val = JsFuture::from(promise).await.map_err(|_| {
731 ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string())
732 })?;
733 let resp: web_sys::Response = resp_val
734 .dyn_into()
735 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
736
737 let status = resp.status();
738 if !(200..300).contains(&status) {
739 let body = parse_error_body(&resp).await;
740 let fallback = body
741 .error
742 .clone()
743 .unwrap_or_else(|| fallback_msg.to_string());
744 return Err(ApiError::Http {
745 status,
746 // Known server errors carry a code the client maps to a
747 // localized message; unknown ones fall back to the raw text.
748 message: crate::i18n::error_text(body.code.as_deref(), &fallback),
749 skipped: body.skipped,
750 });
751 }
752 Ok(resp)
753}
754
755async fn do_fetch<T: DeserializeOwned>(
756 url: &str,
757 opts: &web_sys::RequestInit,
758) -> Result<T, ApiError> {
759 let resp = fetch_checked(url, opts, "request failed").await?;
760 read_json(&resp).await
761}
762
763/// Read a response body as text and parse it with serde_json.
764///
765/// Going through text rather than `Response::json()` keeps one JSON
766/// implementation in play. It also keeps the server's 64-bit integers exact:
767/// a detour through a JS value would round file sizes through an f64.
768async fn read_json<T: DeserializeOwned>(resp: &web_sys::Response) -> Result<T, ApiError> {
769 let text = response_text(resp)
770 .await
771 .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
772 serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}")))
773}
774
775async fn response_text(resp: &web_sys::Response) -> Option<String> {
776 JsFuture::from(resp.text().ok()?).await.ok()?.as_string()
777}
778
779/// Parse the server's error JSON (message + optional conflict list).
780/// An unparseable body yields the default, and the caller's fallback message.
781async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
782 response_text(resp)
783 .await
784 .and_then(|t| serde_json::from_str(&t).ok())
785 .unwrap_or_default()
786}
787
788// ---------------------------------------------------------------------------
789// Search (streamed)
790// ---------------------------------------------------------------------------
791
792/// Start a search and stream its results as they are found.
793///
794/// [`web_sys::EventSource`] is the platform's SSE client: it frames the
795/// stream and parses the events. `on_event` runs once per event on the main
796/// thread; `.close()` on the returned source stops the search (the server
797/// notices the dropped connection and unwinds its walk).
798///
799/// A stream that ends or dies mid-way simply stops, without a `done` event.
800/// An `EventSource` cannot read the server's JSON error body, so a rejected
801/// request reports one generic message.
802pub fn search_stream(
803 q: String,
804 scope: &str,
805 root: i64,
806 // `path`: folder inside the root to start in ("" = the whole root).
807 path: &str,
808 on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
809 // A plain closure, not a `Callback`: the caller may run from a render
810 // closure whose owner is disposed on the next re-render, which would
811 // dispose a `Callback` created there before the stream fails.
812 on_error: impl Fn(String) + 'static,
813) -> Result<web_sys::EventSource, ApiError> {
814 let url = format!(
815 "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}",
816 js_sys::encode_uri_component(&q),
817 js_sys::encode_uri_component(path),
818 );
819 let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?;
820
821 // The server always ends a search with `Done`. `error` fires after that
822 // for the normal end of the stream too (a reconnect pending), so the flag
823 // tells a finished search from a dropped or refused connection.
824 let done = std::rc::Rc::new(std::cell::Cell::new(false));
825 let done2 = done.clone();
826 let on_msg =
827 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
828 let Some(data) = ev.data().as_string() else {
829 return;
830 };
831 if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(&data) {
832 if matches!(ev, api_types::SearchEvent::Done { .. }) {
833 done2.set(true);
834 }
835 on_event.run(ev);
836 }
837 });
838 src.set_onmessage(Some(on_msg.as_ref().unchecked_ref()));
839 on_msg.forget();
840
841 // A reconnect would re-run the whole search, so close the source either
842 // way. `CLOSED` means the server answered and rejected the request (401,
843 // 403, 400); anything else with no `Done` is a lost connection.
844 let s = src.clone();
845 let on_err = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
846 let rejected = s.ready_state() == web_sys::EventSource::CLOSED;
847 s.close();
848 if done.get() {
849 return;
850 }
851 let key = if rejected {
852 crate::i18n::k::SEARCH_FAILED
853 } else {
854 crate::i18n::k::SERVER_UNREACHABLE
855 };
856 on_error(crate::i18n::t(key).to_string());
857 });
858 src.set_onerror(Some(on_err.as_ref().unchecked_ref()));
859 on_err.forget();
860
861 Ok(src)
862}
863
864/// Read a form input's value by element id.
865pub fn input_value(id: &str) -> String {
866 web_sys::window()
867 .and_then(|w| w.document())
868 .and_then(|d| {
869 d.get_element_by_id(id)
870 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
871 })
872 .map(|i| i.value())
873 .unwrap_or_default()
874}
875