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