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