api.rs
⎇
Raw
1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use wasm_bindgen::JsCast;
4use wasm_bindgen::JsValue;
5use wasm_bindgen_futures::JsFuture;
6
7#[derive(Debug, thiserror::Error)]
8pub enum ApiError {
9 /// Non-2xx response. `skipped` carries the server's conflict file list
10 /// when present (upload conflicts).
11 #[error("{message}")]
12 Http {
13 #[allow(dead_code)]
14 status: u16,
15 message: String,
16 skipped: Option<Vec<String>>,
17 },
18 #[error("network error: {0}")]
19 Net(String),
20}
21
22impl ApiError {
23 pub fn skipped(&self) -> Option<&[String]> {
24 match self {
25 ApiError::Http { skipped: Some(s), .. } => Some(s),
26 _ => None,
27 }
28 }
29}
30
31#[derive(Deserialize, Clone)]
32pub struct Me {
33 pub first_boot: bool,
34 #[serde(default)]
35 pub user: Option<UserInfo>,
36 #[serde(default)]
37 pub roots: Vec<RootInfo>,
38}
39
40#[derive(Deserialize, Clone)]
41pub struct UserInfo {
42 #[allow(dead_code)] // used from milestone 7 onwards
43 pub id: i64,
44 pub name: String,
45 pub is_admin: bool,
46}
47
48#[derive(Deserialize, Clone)]
49pub struct RootInfo {
50 pub id: i64,
51 pub name: String,
52 pub path: String,
53 pub mode: String,
54}
55
56#[derive(Deserialize, Clone, Debug, PartialEq)]
57pub struct Entry {
58 pub name: String,
59 pub is_dir: bool,
60 pub size: u64,
61 pub mtime: String,
62}
63
64#[derive(Deserialize)]
65pub struct FilesResp {
66 pub entries: Vec<Entry>,
67}
68
69#[derive(Deserialize)]
70pub struct UploadResp {
71 #[allow(dead_code)]
72 pub uploaded: usize,
73}
74
75/// Acknowledges a successful 2xx response whose body we don't care about.
76/// Accepts any JSON value (the server sends `{"ok": true}`).
77pub struct OkResp;
78
79impl<'de> Deserialize<'de> for OkResp {
80 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
81 serde::de::IgnoredAny::deserialize(d)?;
82 Ok(OkResp)
83 }
84}
85
86#[derive(Serialize)]
87struct CredentialsBody {
88 name: String,
89 password: String,
90}
91
92/// Server error body: `{"error": "...", "skipped": [...]?}`.
93#[derive(Deserialize, Default)]
94struct ErrBody {
95 #[serde(default)]
96 error: Option<String>,
97 #[serde(default)]
98 skipped: Option<Vec<String>>,
99}
100
101// ---------------------------------------------------------------------------
102// Auth
103// ---------------------------------------------------------------------------
104
105pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
106 request("GET", "/api/auth/me".to_string(), None::<()>)
107}
108
109pub fn login(
110 name: String,
111 password: String,
112) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
113 request(
114 "POST",
115 "/api/auth/login".to_string(),
116 Some(CredentialsBody { name, password }),
117 )
118}
119
120pub fn setup(
121 name: String,
122 password: String,
123) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
124 request(
125 "POST",
126 "/api/auth/setup".to_string(),
127 Some(CredentialsBody { name, password }),
128 )
129}
130
131pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
132 request("POST", "/api/auth/logout".to_string(), Some(()))
133}
134
135// ---------------------------------------------------------------------------
136// Files
137// ---------------------------------------------------------------------------
138
139fn files_url(root_id: i64, path: &str) -> String {
140 if path.is_empty() {
141 format!("/api/files/{root_id}")
142 } else {
143 let encoded: Vec<String> = path
144 .split('/')
145 .map(|s| js_sys::encode_uri_component(s).into())
146 .collect();
147 format!("/api/files/{root_id}/{}", encoded.join("/"))
148 }
149}
150
151pub fn list_files(
152 root_id: i64,
153 path: &str,
154) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
155 request("GET", files_url(root_id, path), None::<()>)
156}
157
158/// Create a folder. `path` is relative to the root (may contain subfolders).
159pub fn mkdir(root_id: i64, path: &str) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
160 request_no_body("POST", files_url(root_id, path))
161}
162
163pub fn rename_item(
164 root_id: i64,
165 path: &str,
166 new_name: String,
167 overwrite: bool,
168) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
169 request(
170 "POST",
171 files_url(root_id, path),
172 Some(Mutation {
173 op: "rename".to_string(),
174 new_name: Some(new_name),
175 dst_root_id: None,
176 dst: None,
177 overwrite,
178 }),
179 )
180}
181
182pub fn move_item(
183 root_id: i64,
184 path: &str,
185 dst_root_id: i64,
186 dst: &str,
187 overwrite: bool,
188) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
189 mutation(root_id, path, "move", dst_root_id, dst, overwrite)
190}
191
192pub fn copy_item(
193 root_id: i64,
194 path: &str,
195 dst_root_id: i64,
196 dst: &str,
197 overwrite: bool,
198) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
199 mutation(root_id, path, "copy", dst_root_id, dst, overwrite)
200}
201
202fn mutation(
203 root_id: i64,
204 path: &str,
205 op: &str,
206 dst_root_id: i64,
207 dst: &str,
208 overwrite: bool,
209) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
210 request(
211 "POST",
212 files_url(root_id, path),
213 Some(Mutation {
214 op: op.to_string(),
215 new_name: None,
216 dst_root_id: Some(dst_root_id),
217 dst: Some(dst.to_string()),
218 overwrite,
219 }),
220 )
221}
222
223#[derive(Serialize)]
224struct Mutation {
225 op: String,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 new_name: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 dst_root_id: Option<i64>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 dst: Option<String>,
232 overwrite: bool,
233}
234
235pub fn delete_item(
236 root_id: i64,
237 path: &str,
238) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
239 request_no_body("DELETE", files_url(root_id, path))
240}
241
242/// Upload files into a directory. Each part is `(relative_path, file)`;
243/// the relative path may contain subfolders (created on the server).
244///
245/// The multipart body is assembled by hand into a `Blob` (instead of using
246/// `FormData` directly as the fetch body): a FormData body makes Chrome send
247/// the request as a *streaming* body, which forces the HTTP/2-cleartext
248/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
249/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
250/// it goes out as a regular length-prefixed HTTP/1.1 request.
251pub async fn upload(
252 root_id: i64,
253 dir: &str,
254 overwrite: bool,
255 parts: Vec<(String, web_sys::File)>,
256) -> Result<UploadResp, ApiError> {
257 let window =
258 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
259
260 let boundary = format!("----fbng{}", random_boundary_suffix());
261 let segments = js_sys::Array::new();
262 for (name, file) in &parts {
263 let basename = name.rsplit('/').next().unwrap_or(name);
264 segments.push(&JsValue::from_str(&format!(
265 "--{boundary}\r\n\
266 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
267 Content-Type: application/octet-stream\r\n\r\n"
268 )));
269 let f: JsValue = file.clone().unchecked_into();
270 segments.push(&f);
271 segments.push(&JsValue::from_str("\r\n"));
272 }
273 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
274 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
275 .map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
276
277 let url = format!(
278 "{}?overwrite={}",
279 files_url(root_id, dir),
280 if overwrite { "true" } else { "false" }
281 );
282
283 let headers = web_sys::Headers::new()
284 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
285 headers
286 .set(
287 "Content-Type",
288 &format!("multipart/form-data; boundary={boundary}"),
289 )
290 .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
291
292 let opts = web_sys::RequestInit::new();
293 opts.set_method("POST");
294 opts.set_mode(web_sys::RequestMode::SameOrigin);
295 opts.set_headers_headers(&headers);
296 opts.set_body_opt_blob(Some(&body));
297
298 let promise = window.fetch_with_str_and_init(&url, &opts);
299 let resp_val = JsFuture::from(promise)
300 .await
301 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
302 let resp: web_sys::Response = resp_val
303 .dyn_into()
304 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
305
306 let status = resp.status();
307 if !(200..300).contains(&status) {
308 let body = parse_error_body(&resp).await;
309 return Err(ApiError::Http {
310 status,
311 message: body.error.unwrap_or_else(|| "upload failed".to_string()),
312 skipped: body.skipped,
313 });
314 }
315 let js = resp
316 .json()
317 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
318 let js: JsValue = JsFuture::from(js)
319 .await
320 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
321 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
322}
323
324// ---------------------------------------------------------------------------
325// File picker (imperative, one at a time)
326// ---------------------------------------------------------------------------
327
328fn random_boundary_suffix() -> String {
329 let mut s = String::with_capacity(16);
330 for _ in 0..16 {
331 let n = (js_sys::Math::random() * 36.0) as u32;
332 s.push(char::from_digit(n, 36).unwrap_or('a'));
333 }
334 s
335}
336
337use wasm_bindgen::closure::Closure;
338
339/// Open the native file dialog and run `on_files` with the picked files once
340/// the user confirms. `directory` uses webkitdirectory (folder upload).
341fn webkit_relative_path(file: &web_sys::File) -> String {
342 let js: JsValue = file.into();
343 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
344 .ok()
345 .and_then(|v| v.as_string())
346 .filter(|s| !s.is_empty())
347 .unwrap_or_default()
348}
349
350pub fn pick_files(
351 multiple: bool,
352 directory: bool,
353 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
354) {
355 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
356 return;
357 };
358 let Ok(el) = doc.create_element("input") else {
359 return;
360 };
361 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
362 return;
363 };
364 input.set_type("file");
365 if multiple {
366 input.set_multiple(true);
367 }
368 if directory {
369 let _ = input.set_attribute("webkitdirectory", "");
370 }
371
372 // The listener keeps the JS callback alive while the input exists;
373 // detach from Rust (the input is removed after the dialog is used).
374 // A cancelled dialog leaks the hidden input until reload — acceptable.
375 let input2 = input.clone();
376 let closure = Closure::<dyn FnMut()>::new(move || {
377 let mut files: Vec<(String, web_sys::File)> = Vec::new();
378 if let Some(list) = input.files() {
379 for i in 0..list.length() {
380 if let Some(f) = list.get(i) {
381 let rel = webkit_relative_path(&f);
382 let name = if rel.is_empty() { f.name() } else { rel };
383 files.push((name, f));
384 }
385 }
386 }
387 let _ = input.remove();
388 if !files.is_empty() {
389 on_files(files);
390 }
391 });
392 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
393 let _ = input2.add_event_listener_with_callback("change", listener);
394 closure.forget();
395 if let Some(body) = doc.body() {
396 let _ = body.append_child(&input2);
397 }
398 input2.click();
399}
400
401// ---------------------------------------------------------------------------
402// Low-level request helpers
403// ---------------------------------------------------------------------------
404
405async fn request<T: DeserializeOwned>(
406 method: &str,
407 url: String,
408 body: Option<impl Serialize>,
409) -> Result<T, ApiError> {
410 let window =
411 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
412
413 let opts = web_sys::RequestInit::new();
414 opts.set_method(method);
415 opts.set_mode(web_sys::RequestMode::SameOrigin);
416 if let Some(body) = body {
417 let json = serde_json_to_string(&body)
418 .map_err(|e| ApiError::Net(e.to_string()))?;
419 opts.set_body_opt_str(Some(&json));
420 let headers = web_sys::Headers::new()
421 .expect("Headers constructor failed");
422 let _ = headers.set("Content-Type", "application/json");
423 opts.set_headers_headers(&headers);
424 }
425
426 do_fetch(window, url, opts).await
427}
428
429/// Request without a body (mkdir / delete).
430async fn request_no_body<T: DeserializeOwned>(
431 method: &str,
432 url: String,
433) -> Result<T, ApiError> {
434 let window =
435 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
436 let opts = web_sys::RequestInit::new();
437 opts.set_method(method);
438 opts.set_mode(web_sys::RequestMode::SameOrigin);
439 do_fetch(window, url, opts).await
440}
441
442async fn do_fetch<T: DeserializeOwned>(
443 window: web_sys::Window,
444 url: String,
445 opts: web_sys::RequestInit,
446) -> Result<T, ApiError> {
447 let req = web_sys::Request::new_with_str_and_init(&url, &opts)
448 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
449
450 let promise = window.fetch_with_request(&req);
451 let resp_val = JsFuture::from(promise)
452 .await
453 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
454 let resp: web_sys::Response = resp_val
455 .dyn_into()
456 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
457
458 let status = resp.status();
459 if !(200..300).contains(&status) {
460 let body = parse_error_body(&resp).await;
461 return Err(ApiError::Http {
462 status,
463 message: body.error.unwrap_or_else(|| "request failed".to_string()),
464 skipped: body.skipped,
465 });
466 }
467
468 let json_promise = resp
469 .json()
470 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
471 let js: JsValue = JsFuture::from(json_promise)
472 .await
473 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
474
475 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
476}
477
478/// Parse the server's error JSON (message + optional conflict list).
479async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
480 let Ok(promise) = resp.text() else {
481 return ErrBody::default();
482 };
483 let Ok(js) = JsFuture::from(promise).await else {
484 return ErrBody::default();
485 };
486 let Some(text) = js.as_string() else {
487 return ErrBody::default();
488 };
489 if let Ok(v) = js_sys::JSON::parse(&text) {
490 if let Ok(body) = serde_wasm_bindgen::from_value::<ErrBody>(v) {
491 return body;
492 }
493 }
494 // Fallback: naive extraction of the "error" string.
495 const MARKER: &str = "\"error\":\"";
496 let error = text.find(MARKER).and_then(|start| {
497 let rest = &text[start + MARKER.len()..];
498 rest.find('"').map(|end| rest[..end].replace("\\\"", "\""))
499 });
500 ErrBody {
501 error,
502 skipped: None,
503 }
504}
505
506/// Read a form input's value by element id.
507pub fn input_value(id: &str) -> String {
508 web_sys::window()
509 .and_then(|w| w.document())
510 .and_then(|d| {
511 d.get_element_by_id(id)
512 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
513 })
514 .map(|i| i.value())
515 .unwrap_or_default()
516}
517
518fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
519 // Reuse the wasm-bindgen JSON serializer; the body must be a plain string
520 // so we convert via JSON text.
521 let value = serde_wasm_bindgen::to_value(v)?;
522 let s = js_sys::JSON::stringify(&value).map_err(|e| {
523 serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}"))
524 })?;
525 s.as_string().ok_or_else(|| {
526 serde_wasm_bindgen::Error::new("stringify returned a non-string")
527 })
528}
529