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