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