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