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