use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use wasm_bindgen_futures::JsFuture; #[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, #[error("network error: {0}")] Net(String), } impl ApiError { pub fn skipped(&self) -> Option<&[String]> { match self { ApiError::Http { skipped: Some(s), .. } => Some(s), _ => None, } } } #[derive(Deserialize, Clone)] pub struct Me { pub first_boot: bool, #[serde(default)] pub user: Option, #[serde(default)] pub roots: Vec, } #[derive(Deserialize, Clone)] pub struct UserInfo { #[allow(dead_code)] // used from milestone 7 onwards pub id: i64, pub name: String, pub is_admin: bool, } #[derive(Deserialize, Clone)] pub struct RootInfo { pub id: i64, pub name: String, pub path: String, pub mode: String, } #[derive(Deserialize, Clone, Debug, PartialEq)] pub struct Entry { pub name: String, pub is_dir: bool, pub size: u64, pub mtime: String, } #[derive(Deserialize)] pub struct FilesResp { pub entries: Vec, } #[derive(Deserialize)] pub struct UploadResp { #[allow(dead_code)] pub uploaded: usize, } /// Acknowledges a successful 2xx response whose body we don't care about. /// Accepts any JSON value (the server sends `{"ok": true}`). pub struct OkResp; impl<'de> Deserialize<'de> for OkResp { fn deserialize>(d: D) -> Result { serde::de::IgnoredAny::deserialize(d)?; Ok(OkResp) } } #[derive(Serialize)] struct CredentialsBody { name: String, password: String, } /// Server error body: `{"error": "...", "skipped": [...]?}`. #[derive(Deserialize, Default)] struct ErrBody { #[serde(default)] error: Option, #[serde(default)] skipped: Option>, } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- pub fn me() -> impl std::future::Future> { request("GET", "/api/auth/me".to_string(), None::<()>) } pub fn login( name: String, password: String, ) -> impl std::future::Future> { request( "POST", "/api/auth/login".to_string(), Some(CredentialsBody { name, password }), ) } pub fn setup( name: String, password: String, ) -> impl std::future::Future> { request( "POST", "/api/auth/setup".to_string(), Some(CredentialsBody { name, password }), ) } pub fn logout() -> impl std::future::Future> { request("POST", "/api/auth/logout".to_string(), Some(())) } // --------------------------------------------------------------------------- // Files // --------------------------------------------------------------------------- fn files_url(root_id: i64, path: &str) -> String { if path.is_empty() { format!("/api/files/{root_id}") } else { let encoded: Vec = path .split('/') .map(|s| js_sys::encode_uri_component(s).into()) .collect(); format!("/api/files/{root_id}/{}", encoded.join("/")) } } pub fn list_files( root_id: i64, path: &str, ) -> impl std::future::Future> { request("GET", files_url(root_id, path), 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> { request_no_body("POST", files_url(root_id, path)) } 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: "rename".to_string(), 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, "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, "copy", dst_root_id, dst, overwrite) } fn mutation( root_id: i64, path: &str, op: &str, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { request( "POST", files_url(root_id, path), Some(Mutation { op: op.to_string(), new_name: None, dst_root_id: Some(dst_root_id), dst: Some(dst.to_string()), overwrite, }), ) } #[derive(Serialize)] struct Mutation { op: String, #[serde(skip_serializing_if = "Option::is_none")] new_name: Option, #[serde(skip_serializing_if = "Option::is_none")] dst_root_id: Option, #[serde(skip_serializing_if = "Option::is_none")] dst: Option, overwrite: bool, } pub fn delete_item( root_id: i64, path: &str, ) -> impl std::future::Future> { request_no_body("DELETE", files_url(root_id, path)) } // --------------------------------------------------------------------------- // 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 base = format!("{}?action=download", files_url(root_id, path)); match format { Some(f) => format!("{base}&format={f}"), None => base, } } /// `...?action=preview` — a single file, inline (native media). pub fn preview_url(root_id: i64, path: &str) -> String { format!("{}?action=preview", files_url(root_id, path)) } /// `...?action=content` — raw file bytes for the text preview/editor. pub fn content_url(root_id: i64, path: &str) -> String { format!("{}?action=content", files_url(root_id, path)) } /// Fetch a file's raw text content (for the CodeMirror preview/editor). pub async fn fetch_content(root_id: i64, path: &str) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; 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; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "could not read file".to_string()), skipped: None, }); } let tp = resp .text() .map_err(|e| ApiError::Net(format!("{e:?}")))?; let js = JsFuture::from(tp) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; js.as_string().ok_or_else(|| ApiError::Net("content is not a string".to_string())) } /// Fetch a file's raw text content plus its mtime (unix seconds), for 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 window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; 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; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "could not read file".to_string()), skipped: None, }); } 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(format!("{e:?}")))?; let js = JsFuture::from(tp) .await .map_err(|e| ApiError::Net(format!("{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 window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let url = format!("{}?action=content", files_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(format!("could not create headers: {e:?}")))?; headers .set("Content-Type", "text/plain; charset=utf-8") .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?; if !force { if let Some(m) = expected_mtime { headers .set("X-Expected-Mtime", &m.to_string()) .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?; } } opts.set_headers_headers(&headers); let req = web_sys::Request::new_with_str_and_init(&url, &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; 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 status == 409 { return Err(ApiError::Conflict); } if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "could not save file".to_string()), skipped: None, }); } let js = resp .json() .map_err(|e| ApiError::Net(format!("{e:?}")))?; let js: JsValue = JsFuture::from(js) .await .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?; #[derive(Deserialize)] struct SaveResp { #[serde(default)] mtime: Option, } let save: SaveResp = serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?; Ok(save.mtime.unwrap_or(0)) } /// 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(); let _ = a.remove(); } /// Upload files into a directory. Each part is `(relative_path, file)`; /// the relative path may contain subfolders (created on the server). /// /// The multipart body is assembled by hand into a `Blob` (instead of using /// `FormData` directly as the fetch 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. pub async fn upload( root_id: i64, dir: &str, overwrite: bool, parts: Vec<(String, web_sys::File)>, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let boundary = format!("----fbng{}", random_boundary_suffix()); let segments = js_sys::Array::new(); for (name, file) in &parts { let basename = name.rsplit('/').next().unwrap_or(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(format!("could not build upload body: {e:?}")))?; let url = format!( "{}?overwrite={}", files_url(root_id, dir), if overwrite { "true" } else { "false" } ); let headers = web_sys::Headers::new() .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?; headers .set( "Content-Type", &format!("multipart/form-data; boundary={boundary}"), ) .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?; let opts = web_sys::RequestInit::new(); opts.set_method("POST"); opts.set_mode(web_sys::RequestMode::SameOrigin); opts.set_headers_headers(&headers); opts.set_body_opt_blob(Some(&body)); let promise = window.fetch_with_str_and_init(&url, &opts); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; 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; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "upload failed".to_string()), skipped: body.skipped, }); } let js = resp .json() .map_err(|e| ApiError::Net(format!("{e:?}")))?; let js: JsValue = JsFuture::from(js) .await .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?; serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string())) } // --------------------------------------------------------------------------- // 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 } use wasm_bindgen::closure::Closure; /// 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"); if multiple { input.set_multiple(true); } if directory { let _ = input.set_attribute("webkitdirectory", ""); } // The listener keeps the JS callback alive while the input exists; // detach from Rust (the input is removed after the dialog is used). // A cancelled dialog leaks the hidden input until reload — acceptable. 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)); } } } let _ = 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(); } // --------------------------------------------------------------------------- // Low-level request helpers // --------------------------------------------------------------------------- async fn request( method: &str, url: String, body: Option, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; 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(window, url, opts).await } /// Request without a body (mkdir / delete). async fn request_no_body( method: &str, url: String, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method(method); opts.set_mode(web_sys::RequestMode::SameOrigin); do_fetch(window, url, opts).await } async fn do_fetch( window: web_sys::Window, url: String, opts: web_sys::RequestInit, ) -> Result { let req = web_sys::Request::new_with_str_and_init(&url, &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; 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; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "request failed".to_string()), skipped: body.skipped, }); } let json_promise = resp .json() .map_err(|e| ApiError::Net(format!("{e:?}")))?; let js: JsValue = JsFuture::from(json_promise) .await .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?; serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string())) } /// Parse the server's error JSON (message + optional conflict list). async fn parse_error_body(resp: &web_sys::Response) -> ErrBody { let Ok(promise) = resp.text() else { return ErrBody::default(); }; let Ok(js) = JsFuture::from(promise).await else { return ErrBody::default(); }; let Some(text) = js.as_string() else { return ErrBody::default(); }; if let Ok(v) = js_sys::JSON::parse(&text) { if let Ok(body) = serde_wasm_bindgen::from_value::(v) { return body; } } // Fallback: naive extraction of the "error" string. const MARKER: &str = "\"error\":\""; let error = text.find(MARKER).and_then(|start| { let rest = &text[start + MARKER.len()..]; rest.find('"').map(|end| rest[..end].replace("\\\"", "\"")) }); ErrBody { error, skipped: None, } } /// 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() } fn serde_json_to_string(v: &impl Serialize) -> Result { // Reuse the wasm-bindgen JSON serializer; the body must be a plain string // so we convert via JSON text. let value = serde_wasm_bindgen::to_value(v)?; let s = js_sys::JSON::stringify(&value).map_err(|e| { serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}")) })?; s.as_string().ok_or_else(|| { serde_wasm_bindgen::Error::new("stringify returned a non-string") }) }