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_EXISTS, ACTION_MKDIR,
17 ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN,
18 AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER,
19 AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateShare, CreateUser, Credentials, ExistsReq,
20 ExistsResp, FILES, FINISH_SUFFIX, LoginReq, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH,
21 P_Q, P_ROOT, P_SCOPE, P_SHARE, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish,
22 Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, UnlockShare,
23 UpdateUser,
24};
25pub use api_types::{
26 AdminShare, AdminUser, AuthMode, Entry, Existing, FilesResp, LoginResp, Me, Mode, OkResp, Op,
27 PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo, SaveResp, ShareInfo, UserInfo,
28};
29
30#[derive(Debug, thiserror::Error)]
31pub enum ApiError {
32 /// Non-2xx response. `skipped` carries the server's conflict file list
33 /// when present (upload conflicts).
34 #[error("{message}")]
35 Http {
36 #[allow(dead_code)]
37 status: u16,
38 message: String,
39 skipped: Option<Vec<String>>,
40 },
41 /// The file changed on disk since it was read (save conflict, HTTP 409).
42 #[error("the file was changed on disk")]
43 Conflict,
44 /// The request never got a response (server down, connection lost) or
45 /// the response could not be used. Carries a message ready to display.
46 #[error("{0}")]
47 Net(String),
48}
49
50/// A JS error's `message` (the whole `Debug` output includes the stack).
51fn js_msg(e: &JsValue) -> String {
52 e.dyn_ref::<js_sys::Error>()
53 .map(|e| String::from(e.message()))
54 .unwrap_or_else(|| format!("{e:?}"))
55}
56
57impl ApiError {
58 pub fn skipped(&self) -> Option<&[String]> {
59 match self {
60 ApiError::Http {
61 skipped: Some(s), ..
62 } => Some(s),
63 _ => None,
64 }
65 }
66
67 /// The HTTP status code, when this was an HTTP (non-2xx) error.
68 pub fn status(&self) -> Option<u16> {
69 match self {
70 ApiError::Http { status, .. } => Some(*status),
71 _ => None,
72 }
73 }
74}
75
76/// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`.
77/// The message is already localized in [`fetch_checked`]; `code` carries the
78/// machine-readable identifier the server sends for known failures.
79#[derive(serde::Deserialize, Default)]
80struct ErrBody {
81 #[serde(default)]
82 error: Option<String>,
83 #[serde(default)]
84 code: Option<String>,
85 #[serde(default)]
86 skipped: Option<Vec<String>>,
87}
88
89// ---------------------------------------------------------------------------
90// Auth
91// ---------------------------------------------------------------------------
92
93pub async fn me() -> Result<Me, ApiError> {
94 let me: Me = request("GET", AUTH_ME.to_string(), None::<()>).await?;
95 crate::router::set_public_url(me.public_url.clone());
96 Ok(me)
97}
98
99/// PUT /api/auth/me — update the signed-in user's profile settings.
100/// Omitted fields are left unchanged; `language: Some(None)` means "follow
101/// the browser".
102#[derive(Serialize, Default)]
103struct ProfilePatch {
104 #[serde(skip_serializing_if = "Option::is_none")]
105 single_click_open: Option<bool>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 thumbnails: Option<bool>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 language: Option<Option<String>>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 default_root_id: Option<Option<i64>>,
112}
113
114pub fn update_profile(
115 single_click_open: Option<bool>,
116 thumbnails: Option<bool>,
117 language: Option<Option<String>>,
118 default_root_id: Option<Option<i64>>,
119) -> impl std::future::Future<Output = Result<Me, ApiError>> {
120 request(
121 "PUT",
122 AUTH_ME.to_string(),
123 Some(ProfilePatch {
124 single_click_open,
125 thumbnails,
126 language,
127 default_root_id,
128 }),
129 )
130}
131
132/// The password leg of signing in.
133///
134/// `state_id` names a passkey leg that already identified the account, which
135/// is how an account requiring both factors finishes when the user starts
136/// with the passkey. Then `name` is not needed and is ignored.
137pub fn login(
138 name: Option<String>,
139 password: String,
140 state_id: Option<String>,
141) -> impl std::future::Future<Output = Result<LoginResp, ApiError>> {
142 request(
143 "POST",
144 AUTH_LOGIN.to_string(),
145 Some(LoginReq {
146 name,
147 password,
148 state_id,
149 }),
150 )
151}
152
153// ---------------------------------------------------------------------------
154// Credentials: password, sign-in mode, passkeys
155// ---------------------------------------------------------------------------
156
157pub fn change_password(
158 new_password: String,
159) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
160 request(
161 "POST",
162 AUTH_PASSWORD.to_string(),
163 Some(ChangePassword { new_password }),
164 )
165}
166
167pub fn delete_password() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
168 request("DELETE", AUTH_PASSWORD.to_string(), None::<()>)
169}
170
171pub fn set_auth_mode(
172 mode: AuthMode,
173) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
174 request("PUT", AUTH_MODE.to_string(), Some(SetAuthMode { mode }))
175}
176
177pub fn list_passkeys() -> impl std::future::Future<Output = Result<Vec<PasskeyInfo>, ApiError>> {
178 request("GET", AUTH_PASSKEYS.to_string(), None::<()>)
179}
180
181pub fn delete_passkey(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
182 request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>)
183}
184
185/// Register a passkey end to end: ask for a challenge, hand it to the
186/// browser, send the answer back.
187///
188/// One function rather than two calls at the view layer, because the two legs
189/// are useless apart and the handle between them is not the view's business.
190pub async fn add_passkey(name: String) -> Result<PasskeyInfo, ApiError> {
191 let challenge: PasskeyChallenge =
192 request("POST", AUTH_PASSKEYS_REGISTER.to_string(), None::<()>).await?;
193 let credential = crate::passkey::create(&challenge.options)
194 .await
195 .map_err(ApiError::Net)?;
196 request(
197 "POST",
198 format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"),
199 Some(PasskeyRegisterFinish {
200 state_id: challenge.state_id,
201 name,
202 credential,
203 }),
204 )
205 .await
206}
207
208/// Sign in with a passkey.
209///
210/// `Ok(None)` means the browser request was cancelled to make room for
211/// another one — nothing happened, and nothing should be shown.
212pub async fn passkey_login(
213 name: Option<String>,
214 conditional: bool,
215) -> Result<Option<LoginResp>, ApiError> {
216 let challenge: PasskeyChallenge = request(
217 "POST",
218 AUTH_PASSKEY_LOGIN.to_string(),
219 Some(PasskeyLoginBegin { name, conditional }),
220 )
221 .await?;
222 passkey_finish(challenge, conditional).await
223}
224
225/// Answer a challenge the server already handed out: the second factor of a
226/// password sign-in arrives inside the login response, so that leg has no
227/// begin call of its own.
228pub async fn passkey_finish(
229 challenge: PasskeyChallenge,
230 conditional: bool,
231) -> Result<Option<LoginResp>, ApiError> {
232 let Some(credential) = crate::passkey::get(&challenge.options, conditional)
233 .await
234 .map_err(ApiError::Net)?
235 else {
236 return Ok(None);
237 };
238 request(
239 "POST",
240 format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"),
241 Some(PasskeyLoginFinish {
242 state_id: challenge.state_id,
243 credential,
244 }),
245 )
246 .await
247 .map(Some)
248}
249
250pub fn setup(
251 name: String,
252 password: String,
253) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
254 request(
255 "POST",
256 AUTH_SETUP.to_string(),
257 Some(Credentials { name, password }),
258 )
259}
260
261pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
262 request("POST", AUTH_LOGOUT.to_string(), Some(()))
263}
264
265// ---------------------------------------------------------------------------
266// Files
267// ---------------------------------------------------------------------------
268
269/// The public share token while the app is showing a share page. Every file
270/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
271/// shown per page, so a plain static suffices (wasm is single-threaded).
272use std::sync::Mutex;
273static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
274
275/// Set (or clear, with `None`) the share token used by file API calls.
276pub fn set_share_token(token: Option<&str>) {
277 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
278}
279
280fn share_suffix() -> String {
281 SHARE_TOKEN
282 .lock()
283 .unwrap()
284 .as_deref()
285 .map(|t| format!("?{P_SHARE}={t}"))
286 .unwrap_or_default()
287}
288
289/// Append `key=value` to a URL, using `&` when a query string already exists.
290fn append_query(url: &str, kv: &str) -> String {
291 if url.contains('?') {
292 format!("{url}&{kv}")
293 } else {
294 format!("{url}?{kv}")
295 }
296}
297
298fn files_url(root_id: i64, path: &str) -> String {
299 let base = if path.is_empty() {
300 format!("{FILES}/{root_id}")
301 } else {
302 let encoded: Vec<String> = path
303 .split('/')
304 .map(|s| js_sys::encode_uri_component(s).into())
305 .collect();
306 format!("{FILES}/{root_id}/{}", encoded.join("/"))
307 };
308 format!("{base}{}", share_suffix())
309}
310
311pub fn list_files(
312 root_id: i64,
313 path: &str,
314) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
315 request("GET", files_url(root_id, path), None::<()>)
316}
317
318/// Create an empty file. `path` is relative to the root (may contain
319/// subfolders); the file must not exist yet.
320pub fn create_file(
321 root_id: i64,
322 path: &str,
323) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
324 let url = append_query(
325 &files_url(root_id, path),
326 &format!("{P_ACTION}={ACTION_CREATE_FILE}"),
327 );
328 request("POST", url, None::<()>)
329}
330
331/// Create a folder. `path` is relative to the root (may contain subfolders).
332pub fn mkdir(
333 root_id: i64,
334 path: &str,
335) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
336 let url = append_query(
337 &files_url(root_id, path),
338 &format!("{P_ACTION}={ACTION_MKDIR}"),
339 );
340 request("POST", url, None::<()>)
341}
342
343pub fn rename_item(
344 root_id: i64,
345 path: &str,
346 new_name: String,
347 overwrite: bool,
348) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
349 request(
350 "POST",
351 files_url(root_id, path),
352 Some(Mutation {
353 op: Op::Rename,
354 new_name: Some(new_name),
355 dst_root_id: None,
356 dst: None,
357 overwrite,
358 }),
359 )
360}
361
362pub fn move_item(
363 root_id: i64,
364 path: &str,
365 dst_root_id: i64,
366 dst: &str,
367 overwrite: bool,
368) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
369 mutation(root_id, path, Op::Move, dst_root_id, dst, overwrite)
370}
371
372pub fn copy_item(
373 root_id: i64,
374 path: &str,
375 dst_root_id: i64,
376 dst: &str,
377 overwrite: bool,
378) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
379 mutation(root_id, path, Op::Copy, dst_root_id, dst, overwrite)
380}
381
382fn mutation(
383 root_id: i64,
384 path: &str,
385 op: Op,
386 dst_root_id: i64,
387 dst: &str,
388 overwrite: bool,
389) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
390 request(
391 "POST",
392 files_url(root_id, path),
393 Some(Mutation {
394 op,
395 new_name: None,
396 dst_root_id: Some(dst_root_id),
397 dst: Some(dst.to_string()),
398 overwrite,
399 }),
400 )
401}
402
403pub fn delete_item(
404 root_id: i64,
405 path: &str,
406) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
407 request("DELETE", files_url(root_id, path), None::<()>)
408}
409
410// ---------------------------------------------------------------------------
411// Download / preview / content (milestone 4)
412// ---------------------------------------------------------------------------
413
414/// `...?action=download` — a single file as-is, or a folder as `format`.
415pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
416 let mut base = append_query(
417 &files_url(root_id, path),
418 &format!("{P_ACTION}={ACTION_DOWNLOAD}"),
419 );
420 if let Some(f) = format {
421 base = append_query(&base, &format!("{P_FORMAT}={f}"));
422 }
423 base
424}
425
426/// `...?action=preview` — a single file, inline (native media).
427pub fn preview_url(root_id: i64, path: &str) -> String {
428 append_query(
429 &files_url(root_id, path),
430 &format!("{P_ACTION}={ACTION_PREVIEW}"),
431 )
432}
433
434/// `...?action=thumb` — a small WebP for the grid.
435///
436/// `mtime` is in the query so a changed file is a new URL. The server marks
437/// the response immutable, which depends on that.
438pub fn thumb_url(root_id: i64, path: &str, mtime: &str) -> String {
439 append_query(
440 &files_url(root_id, path),
441 &format!(
442 "{P_ACTION}={ACTION_THUMB}&v={}",
443 js_sys::encode_uri_component(mtime)
444 ),
445 )
446}
447
448/// `...?action=content` — raw file bytes for the text preview/editor.
449pub fn content_url(root_id: i64, path: &str) -> String {
450 append_query(
451 &files_url(root_id, path),
452 &format!("{P_ACTION}={ACTION_CONTENT}"),
453 )
454}
455
456/// Fetch a file's raw text content plus its mtime (unix seconds), for the
457/// preview and the editor. The mtime anchors the save-time conflict check.
458pub async fn fetch_content_meta(
459 root_id: i64,
460 path: &str,
461) -> Result<(String, Option<i64>), ApiError> {
462 let opts = web_sys::RequestInit::new();
463 opts.set_method("GET");
464 opts.set_mode(web_sys::RequestMode::SameOrigin);
465 let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
466 let mtime: Option<i64> = resp
467 .headers()
468 .get("x-file-mtime")
469 .ok()
470 .flatten()
471 .and_then(|s| s.parse::<i64>().ok());
472 let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?;
473 let js = JsFuture::from(tp)
474 .await
475 .map_err(|e| ApiError::Net(js_msg(&e)))?;
476 let text = js
477 .as_string()
478 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
479 Ok((text, mtime))
480}
481
482/// Save a file's text content (the editor's write path).
483///
484/// When `force` is false, `expected_mtime` is sent and the server rejects the
485/// save with [`ApiError::Conflict`] if the file changed on disk since it was
486/// read. When `force` is true the check is skipped (overwrite). Returns the
487/// file's new mtime (unix seconds) to anchor the next check.
488pub async fn save_content(
489 root_id: i64,
490 path: &str,
491 text: &str,
492 expected_mtime: Option<i64>,
493 force: bool,
494) -> Result<i64, ApiError> {
495 let url = content_url(root_id, path);
496 let opts = web_sys::RequestInit::new();
497 opts.set_method("PUT");
498 opts.set_mode(web_sys::RequestMode::SameOrigin);
499 opts.set_body_opt_str(Some(text));
500 let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
501 headers
502 .set("Content-Type", "text/plain; charset=utf-8")
503 .map_err(|e| ApiError::Net(js_msg(&e)))?;
504 if !force && let Some(m) = expected_mtime {
505 headers
506 .set("X-Expected-Mtime", &m.to_string())
507 .map_err(|e| ApiError::Net(js_msg(&e)))?;
508 }
509 opts.set_headers_headers(&headers);
510 // 409 is the server's "changed on disk" answer, not a generic HTTP error.
511 let resp = match fetch_checked(&url, &opts, "could not save file").await {
512 Ok(resp) => resp,
513 Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
514 Err(e) => return Err(e),
515 };
516 let save: SaveResp = read_json(&resp).await?;
517 Ok(save.mtime)
518}
519
520/// Open a URL in a new tab.
521///
522/// `noopener` severs the `window.opener` link, so the opened page cannot
523/// script this one. That matters here because the target is a *user file*:
524/// HTML and SVG render as real documents (under the server's sandbox CSP, see
525/// `FILE_CSP`), and this is the browser-side half of the same isolation.
526pub fn open_in_new_tab(url: &str) {
527 if let Some(w) = web_sys::window() {
528 let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener");
529 }
530}
531
532/// Trigger a browser download of a same-origin URL via a temporary anchor.
533/// No data is pulled into JS memory — the browser streams it.
534pub fn trigger_download(url: &str, filename: &str) {
535 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
536 return;
537 };
538 let Ok(el) = doc.create_element("a") else {
539 return;
540 };
541 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
542 return;
543 };
544 a.set_href(url);
545 a.set_download(filename);
546 if let Some(body) = doc.body() {
547 let _ = body.append_child(&a);
548 }
549 a.click();
550 a.remove();
551}
552
553/// Build a multipart body by hand into a `Blob` (instead of using
554/// `FormData` directly as the request body): a FormData body makes Chrome
555/// send the request as a *streaming* body, which forces the HTTP/2-cleartext
556/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
557/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
558/// it goes out as a regular length-prefixed HTTP/1.1 request.
559///
560/// Returns the body and its `Content-Type` header value.
561fn multipart_blob(parts: &[(String, web_sys::File)]) -> Result<(web_sys::Blob, String), ApiError> {
562 let boundary = format!("----fbng{}", random_boundary_suffix());
563 let segments = js_sys::Array::new();
564 for (name, file) in parts {
565 let basename = escape_cd(name.rsplit('/').next().unwrap_or(name));
566 let name = escape_cd(name);
567 segments.push(&JsValue::from_str(&format!(
568 "--{boundary}\r\n\
569 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
570 Content-Type: application/octet-stream\r\n\r\n"
571 )));
572 let f: JsValue = file.clone().unchecked_into();
573 segments.push(&f);
574 segments.push(&JsValue::from_str("\r\n"));
575 }
576 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
577 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
578 .map_err(|e| ApiError::Net(js_msg(&e)))?;
579 Ok((body, format!("multipart/form-data; boundary={boundary}")))
580}
581
582/// Escape a multipart part name per the WHATWG form-data rules. The three
583/// characters that would break out of the quoted `Content-Disposition`
584/// value are percent-encoded; the server decodes them back.
585fn escape_cd(s: &str) -> String {
586 s.replace('"', "%22")
587 .replace('\r', "%0D")
588 .replace('\n', "%0A")
589}
590
591/// Read-only upload pre-check: which of `paths` (relative to `dir`, may
592/// contain subfolders) already exist, and whether each is a folder.
593pub async fn check_exists(
594 root_id: i64,
595 dir: &str,
596 paths: Vec<String>,
597) -> Result<Vec<Existing>, ApiError> {
598 let url = append_query(
599 &files_url(root_id, dir),
600 &format!("{P_ACTION}={ACTION_EXISTS}"),
601 );
602 // The server caps one request at 10 000 paths, the JSON body at 1 MB.
603 // A folder upload can bring far more (a kernel tree is ~80 000 files).
604 // Path length varies a lot, so the chunks are cut by bytes as well.
605 const CHUNK_BYTES: usize = 900_000;
606 const CHUNK_PATHS: usize = 10_000;
607 let mut existing = Vec::new();
608 let mut chunk: Vec<String> = Vec::new();
609 let mut bytes = 0usize;
610 for p in paths {
611 // Quotes, comma and escaping headroom around each path in the JSON.
612 bytes += p.len() + 8;
613 chunk.push(p);
614 if bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_PATHS {
615 existing.extend(exists_chunk(&url, std::mem::take(&mut chunk)).await?);
616 bytes = 0;
617 }
618 }
619 if !chunk.is_empty() {
620 existing.extend(exists_chunk(&url, chunk).await?);
621 }
622 Ok(existing)
623}
624
625async fn exists_chunk(url: &str, paths: Vec<String>) -> Result<Vec<Existing>, ApiError> {
626 let resp: ExistsResp = request("POST", url.to_string(), Some(ExistsReq { paths })).await?;
627 Ok(resp.existing)
628}
629
630/// Upload `files` into `dir` in one request, each as `(relative path,
631/// file)`; subfolders are created on the server. The returned request can be
632/// `abort()`ed; the future then resolves to [`ApiError::Net`], the same as a
633/// lost connection. `on_progress` gets the file bytes sent so far (multipart
634/// framing excluded). `overwrite=false` makes the server skip files that
635/// exist and list them in a 409 after writing the rest; those are the files
636/// that appeared during the transfer, since the pre-check covered the ones
637/// that existed before.
638pub fn start_upload(
639 root_id: i64,
640 dir: &str,
641 files: Vec<(String, web_sys::File)>,
642 overwrite: bool,
643 on_progress: impl Fn(f64) + 'static,
644) -> Result<
645 (
646 web_sys::XmlHttpRequest,
647 impl std::future::Future<Output = Result<(), ApiError>>,
648 ),
649 ApiError,
650> {
651 let size: f64 = files.iter().map(|(_, f)| f.size()).sum();
652 let (body, content_type) = multipart_blob(&files)?;
653 let url = append_query(
654 &files_url(root_id, dir),
655 &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
656 );
657 let xhr = web_sys::XmlHttpRequest::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
658 xhr.open_with_async("POST", &url, true)
659 .map_err(|e| ApiError::Net(js_msg(&e)))?;
660 xhr.set_request_header("Content-Type", &content_type)
661 .map_err(|e| ApiError::Net(js_msg(&e)))?;
662
663 let progress =
664 Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |ev: web_sys::ProgressEvent| {
665 // `loaded` counts the whole multipart body, boundaries included.
666 // Scale it to file bytes so a tiny file never reads as 600 %.
667 let total = ev.total();
668 let file_bytes = if total > 0.0 {
669 (ev.loaded() / total * size).min(size)
670 } else {
671 ev.loaded().min(size)
672 };
673 on_progress(file_bytes);
674 });
675 if let Ok(up) = xhr.upload() {
676 up.set_onprogress(Some(progress.as_ref().unchecked_ref()));
677 }
678 // `loadend` fires for success, error and abort alike; the status tells
679 // them apart afterwards.
680 let done = js_sys::Promise::new(&mut |resolve, _reject| {
681 xhr.set_onloadend(Some(&resolve));
682 });
683 let req = xhr.clone();
684 xhr.send_with_opt_blob(Some(&body))
685 .map_err(|e| ApiError::Net(js_msg(&e)))?;
686
687 let fut = async move {
688 let _ = JsFuture::from(done).await;
689 // Keep the progress listener alive until here.
690 drop(progress);
691 let status = xhr.status().unwrap_or(0);
692 if status == 0 {
693 // Our own abort and a dead network are indistinguishable here:
694 // both give status 0 and an empty status text. Report the
695 // network error; the caller knows whether it aborted.
696 return Err(ApiError::Net(
697 crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string(),
698 ));
699 }
700 if (200..300).contains(&status) {
701 return Ok(());
702 }
703 let body: ErrBody = xhr
704 .response_text()
705 .ok()
706 .flatten()
707 .and_then(|t| serde_json::from_str(&t).ok())
708 .unwrap_or_default();
709 let fallback = body
710 .error
711 .clone()
712 .unwrap_or_else(|| "upload failed".to_string());
713 Err(ApiError::Http {
714 status,
715 message: crate::i18n::error_text(body.code.as_deref(), &fallback),
716 skipped: body.skipped,
717 })
718 };
719 Ok((req, fut))
720}
721
722// ---------------------------------------------------------------------------
723// Shares (milestone 6)
724// ---------------------------------------------------------------------------
725
726pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
727 request("GET", SHARES.to_string(), None::<()>)
728}
729
730pub fn create_share(
731 root_id: i64,
732 path: &str,
733 writable: bool,
734 expires_at: Option<&str>,
735 password: Option<&str>,
736) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
737 request(
738 "POST",
739 SHARES.to_string(),
740 Some(CreateShare {
741 root_id,
742 path: path.to_string(),
743 writable,
744 expires_at: expires_at.map(|s| s.to_string()),
745 password: password.map(|s| s.to_string()),
746 }),
747 )
748}
749
750pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
751 request("DELETE", format!("{SHARES}/{id}"), None::<()>)
752}
753
754/// Admin only: every share on the server with its creator.
755pub fn list_all_shares() -> impl std::future::Future<Output = Result<Vec<AdminShare>, ApiError>> {
756 request("GET", ADMIN_SHARES.to_string(), None::<()>)
757}
758
759/// Admin only: end a share whoever created it.
760pub fn admin_delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
761 request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>)
762}
763
764/// Public: resolve a share (no session required). A password-protected
765/// share answers 401 until [`unlock_share`] has run in this browser.
766pub fn resolve_share(
767 token: &str,
768) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
769 request("GET", format!("{SHARE}/{token}"), None::<()>)
770}
771
772/// Public: submit a protected share's password. The proof of the unlock is
773/// a cookie the server sets, so nothing has to be kept here.
774pub fn unlock_share(
775 token: &str,
776 password: &str,
777) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
778 request(
779 "POST",
780 format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
781 Some(UnlockShare {
782 password: password.to_string(),
783 }),
784 )
785}
786
787// ---------------------------------------------------------------------------
788// Admin (milestone 7): user management + settings
789// ---------------------------------------------------------------------------
790
791fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
792 roots
793 .iter()
794 .map(|(path, mode)| Root {
795 path: path.clone(),
796 mode: *mode,
797 })
798 .collect()
799}
800
801pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
802 request("GET", ADMIN_USERS.to_string(), None::<()>)
803}
804
805pub fn create_admin_user(
806 name: &str,
807 password: &str,
808 is_admin: bool,
809 roots: &[(String, Mode)],
810) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
811 request(
812 "POST",
813 ADMIN_USERS.to_string(),
814 Some(CreateUser {
815 name: name.to_string(),
816 password: password.to_string(),
817 is_admin,
818 roots: roots_to_bodies(roots),
819 }),
820 )
821}
822
823pub fn update_admin_user(
824 id: i64,
825 password: Option<String>,
826 is_admin: Option<bool>,
827 active: Option<bool>,
828 roots: Option<Vec<(String, Mode)>>,
829) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
830 request(
831 "PUT",
832 format!("{ADMIN_USERS}/{id}"),
833 Some(UpdateUser {
834 password,
835 is_admin,
836 active,
837 roots: roots.map(|r| roots_to_bodies(&r)),
838 }),
839 )
840}
841
842pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
843 request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
844}
845
846pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
847 request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
848}
849
850/// PUT replaces the whole settings object, so every setting has to be
851/// passed. Omitting one would reset it.
852pub fn update_admin_settings(
853 allow_writable_shares: bool,
854 search_excludes: Vec<String>,
855) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
856 request(
857 "PUT",
858 ADMIN_SETTINGS.to_string(),
859 Some(Settings {
860 allow_writable_shares,
861 search_excludes,
862 }),
863 )
864}
865
866// ---------------------------------------------------------------------------
867// File picker (imperative, one at a time)
868// ---------------------------------------------------------------------------
869
870fn random_boundary_suffix() -> String {
871 let mut s = String::with_capacity(16);
872 for _ in 0..16 {
873 let n = (js_sys::Math::random() * 36.0) as u32;
874 s.push(char::from_digit(n, 36).unwrap_or('a'));
875 }
876 s
877}
878
879/// Open the native file dialog and run `on_files` with the picked files once
880/// the user confirms. `directory` uses webkitdirectory (folder upload).
881fn webkit_relative_path(file: &web_sys::File) -> String {
882 let js: JsValue = file.into();
883 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
884 .ok()
885 .and_then(|v| v.as_string())
886 .filter(|s| !s.is_empty())
887 .unwrap_or_default()
888}
889
890pub fn pick_files(
891 multiple: bool,
892 directory: bool,
893 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
894) {
895 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
896 return;
897 };
898 let Ok(el) = doc.create_element("input") else {
899 return;
900 };
901 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
902 return;
903 };
904 input.set_type("file");
905 // Off screen: the browser renders a bare "Choose files" control for an
906 // input in the body, and `click()` still works on a hidden one.
907 let _ = input.set_attribute("hidden", "");
908 if multiple {
909 input.set_multiple(true);
910 }
911 if directory {
912 let _ = input.set_attribute("webkitdirectory", "");
913 }
914
915 // Known small leak, deliberate: the input holds the listener, the
916 // listener holds this closure, and the closure captures the input — a
917 // cycle that nothing frees. `forget()` detaches the Rust side, so the
918 // element + JS function + closure (~1 KB) survive until reload even
919 // when the dialog is used (not only when cancelled). Dropping the
920 // closure from Rust while the JS listener still references it would
921 // leave a dangling callback, and a closure cannot drop itself; a clean
922 // fix needs the caller to own it (e.g. a `StoredValue` released in
923 // `on_cleanup`), which is not worth it at this size.
924 let input2 = input.clone();
925 let closure = Closure::<dyn FnMut()>::new(move || {
926 let mut files: Vec<(String, web_sys::File)> = Vec::new();
927 if let Some(list) = input.files() {
928 for i in 0..list.length() {
929 if let Some(f) = list.get(i) {
930 let rel = webkit_relative_path(&f);
931 let name = if rel.is_empty() { f.name() } else { rel };
932 files.push((name, f));
933 }
934 }
935 }
936 input.remove();
937 if !files.is_empty() {
938 on_files(files);
939 }
940 });
941 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
942 let _ = input2.add_event_listener_with_callback("change", listener);
943 closure.forget();
944 if let Some(body) = doc.body() {
945 let _ = body.append_child(&input2);
946 }
947 input2.click();
948}
949
950/// True when a drag carries files (not text or a link from the page).
951pub fn drag_has_files(ev: &web_sys::DragEvent) -> bool {
952 ev.data_transfer()
953 .map(|dt| dt.types().includes(&JsValue::from_str("Files"), 0))
954 .unwrap_or(false)
955}
956
957/// The top-level items of a drop, read out of the `DataTransfer` while the
958/// drop handler still runs. A `DataTransfer` is only readable during its own
959/// event, so an async task that reads it later finds it empty. [`read_drop`]
960/// therefore copies the entries and files out first.
961pub struct Dropped {
962 entries: Vec<web_sys::FileSystemEntry>,
963 /// Flat list, for browsers without the entries API.
964 files: Vec<web_sys::File>,
965}
966
967impl Dropped {
968 /// Names of the top-level items, for a job label before the folders are
969 /// walked. A dropped folder shows up as one name here.
970 pub fn names(&self) -> Vec<String> {
971 if self.entries.is_empty() {
972 self.files.iter().map(|f| f.name()).collect()
973 } else {
974 self.entries.iter().map(|e| e.name()).collect()
975 }
976 }
977}
978
979/// Read a drop synchronously. See [`Dropped`].
980pub fn read_drop(ev: &web_sys::DragEvent) -> Dropped {
981 let Some(dt) = ev.data_transfer() else {
982 return Dropped {
983 entries: Vec::new(),
984 files: Vec::new(),
985 };
986 };
987 let items = dt.items();
988 let mut entries = Vec::new();
989 for i in 0..items.length() {
990 if let Some(item) = items.get(i)
991 && item.kind() == "file"
992 && let Ok(Some(entry)) = item.webkit_get_as_entry()
993 {
994 entries.push(entry);
995 }
996 }
997 let mut files = Vec::new();
998 if let Some(list) = dt.files() {
999 for i in 0..list.length() {
1000 if let Some(f) = list.get(i) {
1001 files.push(f);
1002 }
1003 }
1004 }
1005 Dropped { entries, files }
1006}
1007
1008/// The files of a drop as `(relative path, file)`. Dropped folders are
1009/// walked through the entries API, which is what gives them a path; the
1010/// plain `files` list flattens them to nothing. Browsers without that API
1011/// get the flat list.
1012///
1013/// Each directory's files are resolved in one `Promise.all`: `entry.file()`
1014/// is a round trip into the browser process, and 80 000 of them in a row
1015/// take minutes.
1016pub async fn files_from_drop(dropped: Dropped) -> Vec<(String, web_sys::File)> {
1017 let Dropped { entries, files } = dropped;
1018 let mut out = Vec::new();
1019 if entries.is_empty() {
1020 return files.into_iter().map(|f| (f.name(), f)).collect();
1021 }
1022 // Iterative walk: a stack instead of recursion keeps the future `Sized`.
1023 // Top-level files are one batch; then each directory is one batch.
1024 let mut dirs: Vec<(String, web_sys::FileSystemDirectoryEntry)> = Vec::new();
1025 let mut files: Vec<(String, web_sys::FileSystemFileEntry)> = Vec::new();
1026 for e in entries {
1027 let name = e.name();
1028 if e.is_directory() {
1029 dirs.push((name, e.unchecked_into()));
1030 } else if e.is_file() {
1031 files.push((name, e.unchecked_into()));
1032 }
1033 }
1034 out.extend(resolve_files(files).await);
1035 while let Some((path, dir)) = dirs.pop() {
1036 let reader = dir.create_reader();
1037 let mut files = Vec::new();
1038 // `readEntries` hands out batches (Chrome: 100) until an empty one.
1039 loop {
1040 let batch = read_entries(&reader).await;
1041 if batch.is_empty() {
1042 break;
1043 }
1044 for e in batch {
1045 let sub = format!("{path}/{}", e.name());
1046 if e.is_directory() {
1047 dirs.push((sub, e.unchecked_into()));
1048 } else if e.is_file() {
1049 files.push((sub, e.unchecked_into()));
1050 }
1051 }
1052 }
1053 out.extend(resolve_files(files).await);
1054 }
1055 out
1056}
1057
1058/// `entry.file()` for every entry at once. An entry that fails (vanished
1059/// mid-drop) is left out.
1060async fn resolve_files(
1061 entries: Vec<(String, web_sys::FileSystemFileEntry)>,
1062) -> Vec<(String, web_sys::File)> {
1063 if entries.is_empty() {
1064 return Vec::new();
1065 }
1066 let promises = js_sys::Array::new();
1067 for (_, entry) in &entries {
1068 let p = js_sys::Promise::new(&mut |resolve, _reject| {
1069 let resolve2 = resolve.clone();
1070 let ok = Closure::once_into_js(move |f: web_sys::File| {
1071 let _ = resolve2.call1(&JsValue::NULL, &f);
1072 });
1073 let err = Closure::once_into_js(move |_e: JsValue| {
1074 let _ = resolve.call1(&JsValue::NULL, &JsValue::NULL);
1075 });
1076 entry.file_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
1077 });
1078 promises.push(&p);
1079 }
1080 let all = match wasm_bindgen_futures::JsFuture::from(js_sys::Promise::all(&promises)).await {
1081 Ok(a) => a,
1082 Err(_) => return Vec::new(),
1083 };
1084 entries
1085 .into_iter()
1086 .zip(js_sys::Array::from(&all).iter())
1087 .filter_map(|((path, _), v)| v.dyn_into::<web_sys::File>().ok().map(|f| (path, f)))
1088 .collect()
1089}
1090
1091async fn read_entries(
1092 reader: &web_sys::FileSystemDirectoryReader,
1093) -> Vec<web_sys::FileSystemEntry> {
1094 let p = js_sys::Promise::new(&mut |resolve, reject| {
1095 let ok = Closure::once_into_js(move |arr: JsValue| {
1096 let _ = resolve.call1(&JsValue::NULL, &arr);
1097 });
1098 let err = Closure::once_into_js(move |e: JsValue| {
1099 let _ = reject.call1(&JsValue::NULL, &e);
1100 });
1101 let _ =
1102 reader.read_entries_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
1103 });
1104 match wasm_bindgen_futures::JsFuture::from(p).await {
1105 Ok(arr) => js_sys::Array::from(&arr)
1106 .iter()
1107 // `unchecked_into`: Chromium has no global `FileSystemEntry`,
1108 // so an `instanceof` check (`dyn_into`) fails for every entry.
1109 .map(|v| v.unchecked_into())
1110 .collect(),
1111 Err(_) => Vec::new(),
1112 }
1113}
1114
1115// ---------------------------------------------------------------------------
1116// Low-level request helpers
1117// ---------------------------------------------------------------------------
1118
1119async fn request<T: DeserializeOwned>(
1120 method: &str,
1121 url: String,
1122 body: Option<impl Serialize>,
1123) -> Result<T, ApiError> {
1124 let opts = web_sys::RequestInit::new();
1125 opts.set_method(method);
1126 opts.set_mode(web_sys::RequestMode::SameOrigin);
1127 if let Some(body) = body {
1128 let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
1129 opts.set_body_opt_str(Some(&json));
1130 let headers = web_sys::Headers::new().expect("Headers constructor failed");
1131 let _ = headers.set("Content-Type", "application/json");
1132 opts.set_headers_headers(&headers);
1133 }
1134
1135 do_fetch(&url, &opts).await
1136}
1137
1138/// Run one fetch and return the response, turning any non-2xx status into
1139/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
1140/// message. Callers that need the raw response (text, a header, or nothing at
1141/// all) use this directly; JSON callers go through [`do_fetch`].
1142async fn fetch_checked(
1143 url: &str,
1144 opts: &web_sys::RequestInit,
1145 fallback_msg: &str,
1146) -> Result<web_sys::Response, ApiError> {
1147 let window =
1148 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
1149 let req = web_sys::Request::new_with_str_and_init(url, opts)
1150 .map_err(|e| ApiError::Net(js_msg(&e)))?;
1151
1152 let promise = window.fetch_with_request(&req);
1153 // `fetch` only rejects when no response arrived at all (server down,
1154 // connection lost, blocked): one short localized line instead of the
1155 // JS stack.
1156 let resp_val = JsFuture::from(promise).await.map_err(|_| {
1157 ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string())
1158 })?;
1159 let resp: web_sys::Response = resp_val
1160 .dyn_into()
1161 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
1162
1163 let status = resp.status();
1164 if !(200..300).contains(&status) {
1165 let body = parse_error_body(&resp).await;
1166 let fallback = body
1167 .error
1168 .clone()
1169 .unwrap_or_else(|| fallback_msg.to_string());
1170 return Err(ApiError::Http {
1171 status,
1172 // Known server errors carry a code the client maps to a
1173 // localized message; unknown ones fall back to the raw text.
1174 message: crate::i18n::error_text(body.code.as_deref(), &fallback),
1175 skipped: body.skipped,
1176 });
1177 }
1178 Ok(resp)
1179}
1180
1181async fn do_fetch<T: DeserializeOwned>(
1182 url: &str,
1183 opts: &web_sys::RequestInit,
1184) -> Result<T, ApiError> {
1185 let resp = fetch_checked(url, opts, "request failed").await?;
1186 read_json(&resp).await
1187}
1188
1189/// Read a response body as text and parse it with serde_json.
1190///
1191/// Going through text rather than `Response::json()` keeps one JSON
1192/// implementation in play. It also keeps the server's 64-bit integers exact:
1193/// a detour through a JS value would round file sizes through an f64.
1194async fn read_json<T: DeserializeOwned>(resp: &web_sys::Response) -> Result<T, ApiError> {
1195 let text = response_text(resp)
1196 .await
1197 .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
1198 serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}")))
1199}
1200
1201async fn response_text(resp: &web_sys::Response) -> Option<String> {
1202 JsFuture::from(resp.text().ok()?).await.ok()?.as_string()
1203}
1204
1205/// Parse the server's error JSON (message + optional conflict list).
1206/// An unparseable body yields the default, and the caller's fallback message.
1207async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
1208 response_text(resp)
1209 .await
1210 .and_then(|t| serde_json::from_str(&t).ok())
1211 .unwrap_or_default()
1212}
1213
1214// ---------------------------------------------------------------------------
1215// Search (streamed)
1216// ---------------------------------------------------------------------------
1217
1218/// Start a search and stream its results as they are found.
1219///
1220/// [`web_sys::EventSource`] is the platform's SSE client: it frames the
1221/// stream and parses the events. `on_event` runs once per event on the main
1222/// thread; `.close()` on the returned source stops the search (the server
1223/// notices the dropped connection and unwinds its walk).
1224///
1225/// A stream that ends or dies mid-way simply stops, without a `done` event.
1226/// An `EventSource` cannot read the server's JSON error body, so a rejected
1227/// request reports one generic message.
1228pub fn search_stream(
1229 q: String,
1230 scope: &str,
1231 root: i64,
1232 // `path`: folder inside the root to start in ("" = the whole root).
1233 path: &str,
1234 on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
1235 // A plain closure, not a `Callback`: the caller may run from a render
1236 // closure whose owner is disposed on the next re-render, which would
1237 // dispose a `Callback` created there before the stream fails.
1238 on_error: impl Fn(String) + 'static,
1239) -> Result<web_sys::EventSource, ApiError> {
1240 let url = format!(
1241 "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}",
1242 js_sys::encode_uri_component(&q),
1243 js_sys::encode_uri_component(path),
1244 );
1245 let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?;
1246
1247 // The server always ends a search with `Done`. `error` fires after that
1248 // for the normal end of the stream too (a reconnect pending), so the flag
1249 // tells a finished search from a dropped or refused connection.
1250 let done = std::rc::Rc::new(std::cell::Cell::new(false));
1251 let done2 = done.clone();
1252 let on_msg =
1253 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
1254 let Some(data) = ev.data().as_string() else {
1255 return;
1256 };
1257 if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(&data) {
1258 if matches!(ev, api_types::SearchEvent::Done { .. }) {
1259 done2.set(true);
1260 }
1261 on_event.run(ev);
1262 }
1263 });
1264 src.set_onmessage(Some(on_msg.as_ref().unchecked_ref()));
1265 on_msg.forget();
1266
1267 // A reconnect would re-run the whole search, so close the source either
1268 // way. `CLOSED` means the server answered and rejected the request (401,
1269 // 403, 400); anything else with no `Done` is a lost connection.
1270 let s = src.clone();
1271 let on_err = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1272 let rejected = s.ready_state() == web_sys::EventSource::CLOSED;
1273 s.close();
1274 if done.get() {
1275 return;
1276 }
1277 let key = if rejected {
1278 crate::i18n::k::SEARCH_FAILED
1279 } else {
1280 crate::i18n::k::SERVER_UNREACHABLE
1281 };
1282 on_error(crate::i18n::t(key).to_string());
1283 });
1284 src.set_onerror(Some(on_err.as_ref().unchecked_ref()));
1285 on_err.forget();
1286
1287 Ok(src)
1288}
1289
1290/// Blank an input, so a password does not sit in the DOM waiting for the next
1291/// person at the keyboard.
1292pub fn clear_input(id: &str) {
1293 if let Some(el) = web_sys::window()
1294 .and_then(|w| w.document())
1295 .and_then(|d| d.get_element_by_id(id))
1296 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
1297 {
1298 el.set_value("");
1299 }
1300}
1301
1302/// Read a form input's value by element id.
1303pub fn input_value(id: &str) -> String {
1304 web_sys::window()
1305 .and_then(|w| w.document())
1306 .and_then(|d| {
1307 d.get_element_by_id(id)
1308 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
1309 })
1310 .map(|i| i.value())
1311 .unwrap_or_default()
1312}
1313