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