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