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