//! Typed HTTP client for the filebrowser-ng API. //! //! Endpoint paths, query params and wire types all come from the shared //! `api_types` crate (the same one the server's route table and handlers //! use), so the two sides cannot drift apart. use leptos::prelude::Callable; use serde::Serialize; use serde::de::DeserializeOwned; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::JsFuture; use api_types::{ ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_EXISTS, ACTION_MKDIR, ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, UnlockShare, UpdateUser, }; pub use api_types::{ AdminShare, AdminUser, AuthMode, Entry, Existing, FilesResp, LoginResp, Me, Mode, OkResp, Op, PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo, SaveResp, ShareInfo, UserInfo, }; #[derive(Debug, thiserror::Error)] pub enum ApiError { /// Non-2xx response. `skipped` carries the server's conflict file list /// when present (upload conflicts). #[error("{message}")] Http { #[allow(dead_code)] status: u16, message: String, skipped: Option>, }, /// The file changed on disk since it was read (save conflict, HTTP 409). #[error("the file was changed on disk")] Conflict, /// The request never got a response (server down, connection lost) or /// the response could not be used. Carries a message ready to display. #[error("{0}")] Net(String), } /// A JS error's `message` (the whole `Debug` output includes the stack). fn js_msg(e: &JsValue) -> String { e.dyn_ref::() .map(|e| String::from(e.message())) .unwrap_or_else(|| format!("{e:?}")) } impl ApiError { pub fn skipped(&self) -> Option<&[String]> { match self { ApiError::Http { skipped: Some(s), .. } => Some(s), _ => None, } } /// The HTTP status code, when this was an HTTP (non-2xx) error. pub fn status(&self) -> Option { match self { ApiError::Http { status, .. } => Some(*status), _ => None, } } } /// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`. /// The message is already localized in [`fetch_checked`]; `code` carries the /// machine-readable identifier the server sends for known failures. #[derive(serde::Deserialize, Default)] struct ErrBody { #[serde(default)] error: Option, #[serde(default)] code: Option, #[serde(default)] skipped: Option>, } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- pub async fn me() -> Result { let me: Me = request("GET", AUTH_ME.to_string(), None::<()>).await?; crate::router::set_public_url(me.public_url.clone()); Ok(me) } /// PUT /api/auth/me — update the signed-in user's profile settings. /// Omitted fields are left unchanged; `language: Some(None)` means "follow /// the browser". #[derive(Serialize, Default)] struct ProfilePatch { #[serde(skip_serializing_if = "Option::is_none")] single_click_open: Option, #[serde(skip_serializing_if = "Option::is_none")] thumbnails: Option, #[serde(skip_serializing_if = "Option::is_none")] language: Option>, #[serde(skip_serializing_if = "Option::is_none")] default_root_id: Option>, } pub fn update_profile( single_click_open: Option, thumbnails: Option, language: Option>, default_root_id: Option>, ) -> impl std::future::Future> { request( "PUT", AUTH_ME.to_string(), Some(ProfilePatch { single_click_open, thumbnails, language, default_root_id, }), ) } /// The password leg of signing in. /// /// `state_id` names a passkey leg that already identified the account, which /// is how an account requiring both factors finishes when the user starts /// with the passkey. Then `name` is not needed and is ignored. pub fn login( name: Option, password: String, state_id: Option, ) -> impl std::future::Future> { request( "POST", AUTH_LOGIN.to_string(), Some(LoginReq { name, password, state_id, }), ) } // --------------------------------------------------------------------------- // Credentials: password, sign-in mode, passkeys // --------------------------------------------------------------------------- pub fn change_password( new_password: String, ) -> impl std::future::Future> { request( "POST", AUTH_PASSWORD.to_string(), Some(ChangePassword { new_password }), ) } pub fn delete_password() -> impl std::future::Future> { request("DELETE", AUTH_PASSWORD.to_string(), None::<()>) } pub fn set_auth_mode( mode: AuthMode, ) -> impl std::future::Future> { request("PUT", AUTH_MODE.to_string(), Some(SetAuthMode { mode })) } pub fn list_passkeys() -> impl std::future::Future, ApiError>> { request("GET", AUTH_PASSKEYS.to_string(), None::<()>) } pub fn delete_passkey(id: i64) -> impl std::future::Future> { request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>) } /// Register a passkey end to end: ask for a challenge, hand it to the /// browser, send the answer back. /// /// One function rather than two calls at the view layer, because the two legs /// are useless apart and the handle between them is not the view's business. pub async fn add_passkey(name: String) -> Result { let challenge: PasskeyChallenge = request("POST", AUTH_PASSKEYS_REGISTER.to_string(), None::<()>).await?; let credential = crate::passkey::create(&challenge.options) .await .map_err(ApiError::Net)?; request( "POST", format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"), Some(PasskeyRegisterFinish { state_id: challenge.state_id, name, credential, }), ) .await } /// Sign in with a passkey. /// /// `Ok(None)` means the browser request was cancelled to make room for /// another one — nothing happened, and nothing should be shown. pub async fn passkey_login( name: Option, conditional: bool, ) -> Result, ApiError> { let challenge: PasskeyChallenge = request( "POST", AUTH_PASSKEY_LOGIN.to_string(), Some(PasskeyLoginBegin { name, conditional }), ) .await?; passkey_finish(challenge, conditional).await } /// Answer a challenge the server already handed out: the second factor of a /// password sign-in arrives inside the login response, so that leg has no /// begin call of its own. pub async fn passkey_finish( challenge: PasskeyChallenge, conditional: bool, ) -> Result, ApiError> { let Some(credential) = crate::passkey::get(&challenge.options, conditional) .await .map_err(ApiError::Net)? else { return Ok(None); }; request( "POST", format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"), Some(PasskeyLoginFinish { state_id: challenge.state_id, credential, }), ) .await .map(Some) } pub fn setup( name: String, password: String, ) -> impl std::future::Future> { request( "POST", AUTH_SETUP.to_string(), Some(Credentials { name, password }), ) } pub fn logout() -> impl std::future::Future> { request("POST", AUTH_LOGOUT.to_string(), Some(())) } // --------------------------------------------------------------------------- // Files // --------------------------------------------------------------------------- /// The public share token while the app is showing a share page. Every file /// API call appends `?share=` (or `&share=`). Only one share is /// shown per page, so a plain static suffices (wasm is single-threaded). use std::sync::Mutex; static SHARE_TOKEN: Mutex> = Mutex::new(None); /// Set (or clear, with `None`) the share token used by file API calls. pub fn set_share_token(token: Option<&str>) { *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string()); } fn share_suffix() -> String { SHARE_TOKEN .lock() .unwrap() .as_deref() .map(|t| format!("?{P_SHARE}={t}")) .unwrap_or_default() } /// Append `key=value` to a URL, using `&` when a query string already exists. fn append_query(url: &str, kv: &str) -> String { if url.contains('?') { format!("{url}&{kv}") } else { format!("{url}?{kv}") } } fn files_url(root_id: i64, path: &str) -> String { let base = if path.is_empty() { format!("{FILES}/{root_id}") } else { let encoded: Vec = path .split('/') .map(|s| js_sys::encode_uri_component(s).into()) .collect(); format!("{FILES}/{root_id}/{}", encoded.join("/")) }; format!("{base}{}", share_suffix()) } pub fn list_files( root_id: i64, path: &str, ) -> impl std::future::Future> { request("GET", files_url(root_id, path), None::<()>) } /// Create an empty file. `path` is relative to the root (may contain /// subfolders); the file must not exist yet. pub fn create_file( root_id: i64, path: &str, ) -> impl std::future::Future> { let url = append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_CREATE_FILE}"), ); request("POST", url, None::<()>) } /// Create a folder. `path` is relative to the root (may contain subfolders). pub fn mkdir( root_id: i64, path: &str, ) -> impl std::future::Future> { let url = append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_MKDIR}"), ); request("POST", url, None::<()>) } pub fn rename_item( root_id: i64, path: &str, new_name: String, overwrite: bool, ) -> impl std::future::Future> { request( "POST", files_url(root_id, path), Some(Mutation { op: Op::Rename, new_name: Some(new_name), dst_root_id: None, dst: None, overwrite, }), ) } pub fn move_item( root_id: i64, path: &str, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { mutation(root_id, path, Op::Move, dst_root_id, dst, overwrite) } pub fn copy_item( root_id: i64, path: &str, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { mutation(root_id, path, Op::Copy, dst_root_id, dst, overwrite) } fn mutation( root_id: i64, path: &str, op: Op, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { request( "POST", files_url(root_id, path), Some(Mutation { op, new_name: None, dst_root_id: Some(dst_root_id), dst: Some(dst.to_string()), overwrite, }), ) } pub fn delete_item( root_id: i64, path: &str, ) -> impl std::future::Future> { request("DELETE", files_url(root_id, path), None::<()>) } // --------------------------------------------------------------------------- // Download / preview / content (milestone 4) // --------------------------------------------------------------------------- /// `...?action=download` — a single file as-is, or a folder as `format`. pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String { let mut base = append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_DOWNLOAD}"), ); if let Some(f) = format { base = append_query(&base, &format!("{P_FORMAT}={f}")); } base } /// `...?action=preview` — a single file, inline (native media). pub fn preview_url(root_id: i64, path: &str) -> String { append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_PREVIEW}"), ) } /// `...?action=thumb` — a small WebP for the grid. /// /// `mtime` is in the query so a changed file is a new URL. The server marks /// the response immutable, which depends on that. pub fn thumb_url(root_id: i64, path: &str, mtime: &str) -> String { append_query( &files_url(root_id, path), &format!( "{P_ACTION}={ACTION_THUMB}&v={}", js_sys::encode_uri_component(mtime) ), ) } /// `...?action=content` — raw file bytes for the text preview/editor. pub fn content_url(root_id: i64, path: &str) -> String { append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_CONTENT}"), ) } /// Fetch a file's raw text content plus its mtime (unix seconds), for the /// preview and the editor. The mtime anchors the save-time conflict check. pub async fn fetch_content_meta( root_id: i64, path: &str, ) -> Result<(String, Option), ApiError> { let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?; let mtime: Option = resp .headers() .get("x-file-mtime") .ok() .flatten() .and_then(|s| s.parse::().ok()); let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?; let js = JsFuture::from(tp) .await .map_err(|e| ApiError::Net(js_msg(&e)))?; let text = js .as_string() .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?; Ok((text, mtime)) } /// Save a file's text content (the editor's write path). /// /// When `force` is false, `expected_mtime` is sent and the server rejects the /// save with [`ApiError::Conflict`] if the file changed on disk since it was /// read. When `force` is true the check is skipped (overwrite). Returns the /// file's new mtime (unix seconds) to anchor the next check. pub async fn save_content( root_id: i64, path: &str, text: &str, expected_mtime: Option, force: bool, ) -> Result { let url = content_url(root_id, path); let opts = web_sys::RequestInit::new(); opts.set_method("PUT"); opts.set_mode(web_sys::RequestMode::SameOrigin); opts.set_body_opt_str(Some(text)); let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?; headers .set("Content-Type", "text/plain; charset=utf-8") .map_err(|e| ApiError::Net(js_msg(&e)))?; if !force && let Some(m) = expected_mtime { headers .set("X-Expected-Mtime", &m.to_string()) .map_err(|e| ApiError::Net(js_msg(&e)))?; } opts.set_headers_headers(&headers); // 409 is the server's "changed on disk" answer, not a generic HTTP error. let resp = match fetch_checked(&url, &opts, "could not save file").await { Ok(resp) => resp, Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict), Err(e) => return Err(e), }; let save: SaveResp = read_json(&resp).await?; Ok(save.mtime) } /// Open a URL in a new tab. /// /// `noopener` severs the `window.opener` link, so the opened page cannot /// script this one. That matters here because the target is a *user file*: /// HTML and SVG render as real documents (under the server's sandbox CSP, see /// `FILE_CSP`), and this is the browser-side half of the same isolation. pub fn open_in_new_tab(url: &str) { if let Some(w) = web_sys::window() { let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener"); } } /// Trigger a browser download of a same-origin URL via a temporary anchor. /// No data is pulled into JS memory — the browser streams it. pub fn trigger_download(url: &str, filename: &str) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; let Ok(el) = doc.create_element("a") else { return; }; let Ok(a) = el.dyn_into::() else { return; }; a.set_href(url); a.set_download(filename); if let Some(body) = doc.body() { let _ = body.append_child(&a); } a.click(); a.remove(); } /// Build a multipart body by hand into a `Blob` (instead of using /// `FormData` directly as the request body): a FormData body makes Chrome /// send the request as a *streaming* body, which forces the HTTP/2-cleartext /// (h2c/ALPN) path and fails against an HTTP/1.1-only server with /// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so /// it goes out as a regular length-prefixed HTTP/1.1 request. /// /// Returns the body and its `Content-Type` header value. fn multipart_blob(parts: &[(String, web_sys::File)]) -> Result<(web_sys::Blob, String), ApiError> { let boundary = format!("----fbng{}", random_boundary_suffix()); let segments = js_sys::Array::new(); for (name, file) in parts { let basename = escape_cd(name.rsplit('/').next().unwrap_or(name)); let name = escape_cd(name); segments.push(&JsValue::from_str(&format!( "--{boundary}\r\n\ Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\ Content-Type: application/octet-stream\r\n\r\n" ))); let f: JsValue = file.clone().unchecked_into(); segments.push(&f); segments.push(&JsValue::from_str("\r\n")); } segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n"))); let body = web_sys::Blob::new_with_buffer_source_sequence(&segments) .map_err(|e| ApiError::Net(js_msg(&e)))?; Ok((body, format!("multipart/form-data; boundary={boundary}"))) } /// Escape a multipart part name per the WHATWG form-data rules. The three /// characters that would break out of the quoted `Content-Disposition` /// value are percent-encoded; the server decodes them back. fn escape_cd(s: &str) -> String { s.replace('"', "%22") .replace('\r', "%0D") .replace('\n', "%0A") } /// Read-only upload pre-check: which of `paths` (relative to `dir`, may /// contain subfolders) already exist, and whether each is a folder. pub async fn check_exists( root_id: i64, dir: &str, paths: Vec, ) -> Result, ApiError> { let url = append_query( &files_url(root_id, dir), &format!("{P_ACTION}={ACTION_EXISTS}"), ); // The server caps one request at 10 000 paths, the JSON body at 1 MB. // A folder upload can bring far more (a kernel tree is ~80 000 files). // Path length varies a lot, so the chunks are cut by bytes as well. const CHUNK_BYTES: usize = 900_000; const CHUNK_PATHS: usize = 10_000; let mut existing = Vec::new(); let mut chunk: Vec = Vec::new(); let mut bytes = 0usize; for p in paths { // Quotes, comma and escaping headroom around each path in the JSON. bytes += p.len() + 8; chunk.push(p); if bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_PATHS { existing.extend(exists_chunk(&url, std::mem::take(&mut chunk)).await?); bytes = 0; } } if !chunk.is_empty() { existing.extend(exists_chunk(&url, chunk).await?); } Ok(existing) } async fn exists_chunk(url: &str, paths: Vec) -> Result, ApiError> { let resp: ExistsResp = request("POST", url.to_string(), Some(ExistsReq { paths })).await?; Ok(resp.existing) } /// Upload `files` into `dir` in one request, each as `(relative path, /// file)`; subfolders are created on the server. The returned request can be /// `abort()`ed; the future then resolves to [`ApiError::Net`], the same as a /// lost connection. `on_progress` gets the file bytes sent so far (multipart /// framing excluded). `overwrite=false` makes the server skip files that /// exist and list them in a 409 after writing the rest; those are the files /// that appeared during the transfer, since the pre-check covered the ones /// that existed before. pub fn start_upload( root_id: i64, dir: &str, files: Vec<(String, web_sys::File)>, overwrite: bool, on_progress: impl Fn(f64) + 'static, ) -> Result< ( web_sys::XmlHttpRequest, impl std::future::Future>, ), ApiError, > { let size: f64 = files.iter().map(|(_, f)| f.size()).sum(); let (body, content_type) = multipart_blob(&files)?; let url = append_query( &files_url(root_id, dir), &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }), ); let xhr = web_sys::XmlHttpRequest::new().map_err(|e| ApiError::Net(js_msg(&e)))?; xhr.open_with_async("POST", &url, true) .map_err(|e| ApiError::Net(js_msg(&e)))?; xhr.set_request_header("Content-Type", &content_type) .map_err(|e| ApiError::Net(js_msg(&e)))?; let progress = Closure::::new(move |ev: web_sys::ProgressEvent| { // `loaded` counts the whole multipart body, boundaries included. // Scale it to file bytes so a tiny file never reads as 600 %. let total = ev.total(); let file_bytes = if total > 0.0 { (ev.loaded() / total * size).min(size) } else { ev.loaded().min(size) }; on_progress(file_bytes); }); if let Ok(up) = xhr.upload() { up.set_onprogress(Some(progress.as_ref().unchecked_ref())); } // `loadend` fires for success, error and abort alike; the status tells // them apart afterwards. let done = js_sys::Promise::new(&mut |resolve, _reject| { xhr.set_onloadend(Some(&resolve)); }); let req = xhr.clone(); xhr.send_with_opt_blob(Some(&body)) .map_err(|e| ApiError::Net(js_msg(&e)))?; let fut = async move { let _ = JsFuture::from(done).await; // Keep the progress listener alive until here. drop(progress); let status = xhr.status().unwrap_or(0); if status == 0 { // Our own abort and a dead network are indistinguishable here: // both give status 0 and an empty status text. Report the // network error; the caller knows whether it aborted. return Err(ApiError::Net( crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string(), )); } if (200..300).contains(&status) { return Ok(()); } let body: ErrBody = xhr .response_text() .ok() .flatten() .and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default(); let fallback = body .error .clone() .unwrap_or_else(|| "upload failed".to_string()); Err(ApiError::Http { status, message: crate::i18n::error_text(body.code.as_deref(), &fallback), skipped: body.skipped, }) }; Ok((req, fut)) } // --------------------------------------------------------------------------- // Shares (milestone 6) // --------------------------------------------------------------------------- pub fn list_shares() -> impl std::future::Future, ApiError>> { request("GET", SHARES.to_string(), None::<()>) } pub fn create_share( root_id: i64, path: &str, writable: bool, expires_at: Option<&str>, password: Option<&str>, ) -> impl std::future::Future> { request( "POST", SHARES.to_string(), Some(CreateShare { root_id, path: path.to_string(), writable, expires_at: expires_at.map(|s| s.to_string()), password: password.map(|s| s.to_string()), }), ) } pub fn delete_share(id: i64) -> impl std::future::Future> { request("DELETE", format!("{SHARES}/{id}"), None::<()>) } /// Admin only: every share on the server with its creator. pub fn list_all_shares() -> impl std::future::Future, ApiError>> { request("GET", ADMIN_SHARES.to_string(), None::<()>) } /// Admin only: end a share whoever created it. pub fn admin_delete_share(id: i64) -> impl std::future::Future> { request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>) } /// Public: resolve a share (no session required). A password-protected /// share answers 401 until [`unlock_share`] has run in this browser. pub fn resolve_share( token: &str, ) -> impl std::future::Future> { request("GET", format!("{SHARE}/{token}"), None::<()>) } /// Public: submit a protected share's password. The proof of the unlock is /// a cookie the server sets, so nothing has to be kept here. pub fn unlock_share( token: &str, password: &str, ) -> impl std::future::Future> { request( "POST", format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"), Some(UnlockShare { password: password.to_string(), }), ) } // --------------------------------------------------------------------------- // Admin (milestone 7): user management + settings // --------------------------------------------------------------------------- fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec { roots .iter() .map(|(path, mode)| Root { path: path.clone(), mode: *mode, }) .collect() } pub fn list_admin_users() -> impl std::future::Future, ApiError>> { request("GET", ADMIN_USERS.to_string(), None::<()>) } pub fn create_admin_user( name: &str, password: &str, is_admin: bool, roots: &[(String, Mode)], ) -> impl std::future::Future> { request( "POST", ADMIN_USERS.to_string(), Some(CreateUser { name: name.to_string(), password: password.to_string(), is_admin, roots: roots_to_bodies(roots), }), ) } pub fn update_admin_user( id: i64, password: Option, is_admin: Option, active: Option, roots: Option>, ) -> impl std::future::Future> { request( "PUT", format!("{ADMIN_USERS}/{id}"), Some(UpdateUser { password, is_admin, active, roots: roots.map(|r| roots_to_bodies(&r)), }), ) } pub fn delete_admin_user(id: i64) -> impl std::future::Future> { request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>) } pub fn get_admin_settings() -> impl std::future::Future> { request("GET", ADMIN_SETTINGS.to_string(), None::<()>) } /// PUT replaces the whole settings object, so every setting has to be /// passed. Omitting one would reset it. pub fn update_admin_settings( allow_writable_shares: bool, search_excludes: Vec, ) -> impl std::future::Future> { request( "PUT", ADMIN_SETTINGS.to_string(), Some(Settings { allow_writable_shares, search_excludes, }), ) } // --------------------------------------------------------------------------- // File picker (imperative, one at a time) // --------------------------------------------------------------------------- fn random_boundary_suffix() -> String { let mut s = String::with_capacity(16); for _ in 0..16 { let n = (js_sys::Math::random() * 36.0) as u32; s.push(char::from_digit(n, 36).unwrap_or('a')); } s } /// Open the native file dialog and run `on_files` with the picked files once /// the user confirms. `directory` uses webkitdirectory (folder upload). fn webkit_relative_path(file: &web_sys::File) -> String { let js: JsValue = file.into(); js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath")) .ok() .and_then(|v| v.as_string()) .filter(|s| !s.is_empty()) .unwrap_or_default() } pub fn pick_files( multiple: bool, directory: bool, on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static, ) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; let Ok(el) = doc.create_element("input") else { return; }; let Ok(input) = el.dyn_into::() else { return; }; input.set_type("file"); // Off screen: the browser renders a bare "Choose files" control for an // input in the body, and `click()` still works on a hidden one. let _ = input.set_attribute("hidden", ""); if multiple { input.set_multiple(true); } if directory { let _ = input.set_attribute("webkitdirectory", ""); } // Known small leak, deliberate: the input holds the listener, the // listener holds this closure, and the closure captures the input — a // cycle that nothing frees. `forget()` detaches the Rust side, so the // element + JS function + closure (~1 KB) survive until reload even // when the dialog is used (not only when cancelled). Dropping the // closure from Rust while the JS listener still references it would // leave a dangling callback, and a closure cannot drop itself; a clean // fix needs the caller to own it (e.g. a `StoredValue` released in // `on_cleanup`), which is not worth it at this size. let input2 = input.clone(); let closure = Closure::::new(move || { let mut files: Vec<(String, web_sys::File)> = Vec::new(); if let Some(list) = input.files() { for i in 0..list.length() { if let Some(f) = list.get(i) { let rel = webkit_relative_path(&f); let name = if rel.is_empty() { f.name() } else { rel }; files.push((name, f)); } } } input.remove(); if !files.is_empty() { on_files(files); } }); let listener: &js_sys::Function = closure.as_js_value().unchecked_ref(); let _ = input2.add_event_listener_with_callback("change", listener); closure.forget(); if let Some(body) = doc.body() { let _ = body.append_child(&input2); } input2.click(); } /// True when a drag carries files (not text or a link from the page). pub fn drag_has_files(ev: &web_sys::DragEvent) -> bool { ev.data_transfer() .map(|dt| dt.types().includes(&JsValue::from_str("Files"), 0)) .unwrap_or(false) } /// The top-level items of a drop, read out of the `DataTransfer` while the /// drop handler still runs. A `DataTransfer` is only readable during its own /// event, so an async task that reads it later finds it empty. [`read_drop`] /// therefore copies the entries and files out first. pub struct Dropped { entries: Vec, /// Flat list, for browsers without the entries API. files: Vec, } impl Dropped { /// Names of the top-level items, for a job label before the folders are /// walked. A dropped folder shows up as one name here. pub fn names(&self) -> Vec { if self.entries.is_empty() { self.files.iter().map(|f| f.name()).collect() } else { self.entries.iter().map(|e| e.name()).collect() } } } /// Read a drop synchronously. See [`Dropped`]. pub fn read_drop(ev: &web_sys::DragEvent) -> Dropped { let Some(dt) = ev.data_transfer() else { return Dropped { entries: Vec::new(), files: Vec::new(), }; }; let items = dt.items(); let mut entries = Vec::new(); for i in 0..items.length() { if let Some(item) = items.get(i) && item.kind() == "file" && let Ok(Some(entry)) = item.webkit_get_as_entry() { entries.push(entry); } } let mut files = Vec::new(); if let Some(list) = dt.files() { for i in 0..list.length() { if let Some(f) = list.get(i) { files.push(f); } } } Dropped { entries, files } } /// The files of a drop as `(relative path, file)`. Dropped folders are /// walked through the entries API, which is what gives them a path; the /// plain `files` list flattens them to nothing. Browsers without that API /// get the flat list. /// /// Each directory's files are resolved in one `Promise.all`: `entry.file()` /// is a round trip into the browser process, and 80 000 of them in a row /// take minutes. pub async fn files_from_drop(dropped: Dropped) -> Vec<(String, web_sys::File)> { let Dropped { entries, files } = dropped; let mut out = Vec::new(); if entries.is_empty() { return files.into_iter().map(|f| (f.name(), f)).collect(); } // Iterative walk: a stack instead of recursion keeps the future `Sized`. // Top-level files are one batch; then each directory is one batch. let mut dirs: Vec<(String, web_sys::FileSystemDirectoryEntry)> = Vec::new(); let mut files: Vec<(String, web_sys::FileSystemFileEntry)> = Vec::new(); for e in entries { let name = e.name(); if e.is_directory() { dirs.push((name, e.unchecked_into())); } else if e.is_file() { files.push((name, e.unchecked_into())); } } out.extend(resolve_files(files).await); while let Some((path, dir)) = dirs.pop() { let reader = dir.create_reader(); let mut files = Vec::new(); // `readEntries` hands out batches (Chrome: 100) until an empty one. loop { let batch = read_entries(&reader).await; if batch.is_empty() { break; } for e in batch { let sub = format!("{path}/{}", e.name()); if e.is_directory() { dirs.push((sub, e.unchecked_into())); } else if e.is_file() { files.push((sub, e.unchecked_into())); } } } out.extend(resolve_files(files).await); } out } /// `entry.file()` for every entry at once. An entry that fails (vanished /// mid-drop) is left out. async fn resolve_files( entries: Vec<(String, web_sys::FileSystemFileEntry)>, ) -> Vec<(String, web_sys::File)> { if entries.is_empty() { return Vec::new(); } let promises = js_sys::Array::new(); for (_, entry) in &entries { let p = js_sys::Promise::new(&mut |resolve, _reject| { let resolve2 = resolve.clone(); let ok = Closure::once_into_js(move |f: web_sys::File| { let _ = resolve2.call1(&JsValue::NULL, &f); }); let err = Closure::once_into_js(move |_e: JsValue| { let _ = resolve.call1(&JsValue::NULL, &JsValue::NULL); }); entry.file_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref()); }); promises.push(&p); } let all = match wasm_bindgen_futures::JsFuture::from(js_sys::Promise::all(&promises)).await { Ok(a) => a, Err(_) => return Vec::new(), }; entries .into_iter() .zip(js_sys::Array::from(&all).iter()) .filter_map(|((path, _), v)| v.dyn_into::().ok().map(|f| (path, f))) .collect() } async fn read_entries( reader: &web_sys::FileSystemDirectoryReader, ) -> Vec { let p = js_sys::Promise::new(&mut |resolve, reject| { let ok = Closure::once_into_js(move |arr: JsValue| { let _ = resolve.call1(&JsValue::NULL, &arr); }); let err = Closure::once_into_js(move |e: JsValue| { let _ = reject.call1(&JsValue::NULL, &e); }); let _ = reader.read_entries_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref()); }); match wasm_bindgen_futures::JsFuture::from(p).await { Ok(arr) => js_sys::Array::from(&arr) .iter() // `unchecked_into`: Chromium has no global `FileSystemEntry`, // so an `instanceof` check (`dyn_into`) fails for every entry. .map(|v| v.unchecked_into()) .collect(), Err(_) => Vec::new(), } } // --------------------------------------------------------------------------- // Low-level request helpers // --------------------------------------------------------------------------- async fn request( method: &str, url: String, body: Option, ) -> Result { let opts = web_sys::RequestInit::new(); opts.set_method(method); opts.set_mode(web_sys::RequestMode::SameOrigin); if let Some(body) = body { let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?; opts.set_body_opt_str(Some(&json)); let headers = web_sys::Headers::new().expect("Headers constructor failed"); let _ = headers.set("Content-Type", "application/json"); opts.set_headers_headers(&headers); } do_fetch(&url, &opts).await } /// Run one fetch and return the response, turning any non-2xx status into /// [`ApiError::Http`]. `fallback_msg` is used when the error body has no /// message. Callers that need the raw response (text, a header, or nothing at /// all) use this directly; JSON callers go through [`do_fetch`]. async fn fetch_checked( url: &str, opts: &web_sys::RequestInit, fallback_msg: &str, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let req = web_sys::Request::new_with_str_and_init(url, opts) .map_err(|e| ApiError::Net(js_msg(&e)))?; let promise = window.fetch_with_request(&req); // `fetch` only rejects when no response arrived at all (server down, // connection lost, blocked): one short localized line instead of the // JS stack. let resp_val = JsFuture::from(promise).await.map_err(|_| { ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string()) })?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?; let status = resp.status(); if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; let fallback = body .error .clone() .unwrap_or_else(|| fallback_msg.to_string()); return Err(ApiError::Http { status, // Known server errors carry a code the client maps to a // localized message; unknown ones fall back to the raw text. message: crate::i18n::error_text(body.code.as_deref(), &fallback), skipped: body.skipped, }); } Ok(resp) } async fn do_fetch( url: &str, opts: &web_sys::RequestInit, ) -> Result { let resp = fetch_checked(url, opts, "request failed").await?; read_json(&resp).await } /// Read a response body as text and parse it with serde_json. /// /// Going through text rather than `Response::json()` keeps one JSON /// implementation in play. It also keeps the server's 64-bit integers exact: /// a detour through a JS value would round file sizes through an f64. async fn read_json(resp: &web_sys::Response) -> Result { let text = response_text(resp) .await .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?; serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}"))) } async fn response_text(resp: &web_sys::Response) -> Option { JsFuture::from(resp.text().ok()?).await.ok()?.as_string() } /// Parse the server's error JSON (message + optional conflict list). /// An unparseable body yields the default, and the caller's fallback message. async fn parse_error_body(resp: &web_sys::Response) -> ErrBody { response_text(resp) .await .and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default() } // --------------------------------------------------------------------------- // Search (streamed) // --------------------------------------------------------------------------- /// Start a search and stream its results as they are found. /// /// [`web_sys::EventSource`] is the platform's SSE client: it frames the /// stream and parses the events. `on_event` runs once per event on the main /// thread; `.close()` on the returned source stops the search (the server /// notices the dropped connection and unwinds its walk). /// /// A stream that ends or dies mid-way simply stops, without a `done` event. /// An `EventSource` cannot read the server's JSON error body, so a rejected /// request reports one generic message. pub fn search_stream( q: String, scope: &str, root: i64, // `path`: folder inside the root to start in ("" = the whole root). path: &str, on_event: leptos::prelude::Callback, // A plain closure, not a `Callback`: the caller may run from a render // closure whose owner is disposed on the next re-render, which would // dispose a `Callback` created there before the stream fails. on_error: impl Fn(String) + 'static, ) -> Result { let url = format!( "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}", js_sys::encode_uri_component(&q), js_sys::encode_uri_component(path), ); let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?; // The server always ends a search with `Done`. `error` fires after that // for the normal end of the stream too (a reconnect pending), so the flag // tells a finished search from a dropped or refused connection. let done = std::rc::Rc::new(std::cell::Cell::new(false)); let done2 = done.clone(); let on_msg = Closure::::new(move |ev: web_sys::MessageEvent| { let Some(data) = ev.data().as_string() else { return; }; if let Ok(ev) = serde_json::from_str::(&data) { if matches!(ev, api_types::SearchEvent::Done { .. }) { done2.set(true); } on_event.run(ev); } }); src.set_onmessage(Some(on_msg.as_ref().unchecked_ref())); on_msg.forget(); // A reconnect would re-run the whole search, so close the source either // way. `CLOSED` means the server answered and rejected the request (401, // 403, 400); anything else with no `Done` is a lost connection. let s = src.clone(); let on_err = Closure::::new(move |_| { let rejected = s.ready_state() == web_sys::EventSource::CLOSED; s.close(); if done.get() { return; } let key = if rejected { crate::i18n::k::SEARCH_FAILED } else { crate::i18n::k::SERVER_UNREACHABLE }; on_error(crate::i18n::t(key).to_string()); }); src.set_onerror(Some(on_err.as_ref().unchecked_ref())); on_err.forget(); Ok(src) } /// Blank an input, so a password does not sit in the DOM waiting for the next /// person at the keyboard. pub fn clear_input(id: &str) { if let Some(el) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.get_element_by_id(id)) .and_then(|el| el.dyn_into::().ok()) { el.set_value(""); } } /// Read a form input's value by element id. pub fn input_value(id: &str) -> String { web_sys::window() .and_then(|w| w.document()) .and_then(|d| { d.get_element_by_id(id) .and_then(|el| el.dyn_into::().ok()) }) .map(|i| i.value()) .unwrap_or_default() }