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