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// ---------------------------------------------------------------------------
243// Download / preview / content (milestone 4)
244// ---------------------------------------------------------------------------
245
246/// `...?action=download` — a single file as-is, or a folder as `format`.
247pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
248 let base = format!("{}?action=download", files_url(root_id, path));
249 match format {
250 Some(f) => format!("{base}&format={f}"),
251 None => base,
252 }
253}
254
255/// `...?action=preview` — a single file, inline (native media).
256pub fn preview_url(root_id: i64, path: &str) -> String {
257 format!("{}?action=preview", files_url(root_id, path))
258}
259
260/// `...?action=content` — raw file bytes for the text preview/editor.
261pub fn content_url(root_id: i64, path: &str) -> String {
262 format!("{}?action=content", files_url(root_id, path))
263}
264
265/// Fetch a file's raw text content (for the CodeMirror preview/editor).
266pub async fn fetch_content(root_id: i64, path: &str) -> Result<String, ApiError> {
267 let window =
268 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
269 let opts = web_sys::RequestInit::new();
270 opts.set_method("GET");
271 opts.set_mode(web_sys::RequestMode::SameOrigin);
272 let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
273 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
274 let promise = window.fetch_with_request(&req);
275 let resp_val = JsFuture::from(promise)
276 .await
277 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
278 let resp: web_sys::Response = resp_val
279 .dyn_into()
280 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
281 let status = resp.status();
282 if !(200..300).contains(&status) {
283 let body = parse_error_body(&resp).await;
284 return Err(ApiError::Http {
285 status,
286 message: body.error.unwrap_or_else(|| "could not read file".to_string()),
287 skipped: None,
288 });
289 }
290 let tp = resp
291 .text()
292 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
293 let js = JsFuture::from(tp)
294 .await
295 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
296 js.as_string().ok_or_else(|| ApiError::Net("content is not a string".to_string()))
297}
298
299/// Trigger a browser download of a same-origin URL via a temporary anchor.
300/// No data is pulled into JS memory — the browser streams it.
301pub fn trigger_download(url: &str, filename: &str) {
302 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
303 return;
304 };
305 let Ok(el) = doc.create_element("a") else {
306 return;
307 };
308 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
309 return;
310 };
311 a.set_href(url);
312 a.set_download(filename);
313 if let Some(body) = doc.body() {
314 let _ = body.append_child(&a);
315 }
316 a.click();
317 let _ = a.remove();
318}
319
320/// Upload files into a directory. Each part is `(relative_path, file)`;
321/// the relative path may contain subfolders (created on the server).
322///
323/// The multipart body is assembled by hand into a `Blob` (instead of using
324/// `FormData` directly as the fetch body): a FormData body makes Chrome send
325/// the request as a *streaming* body, which forces the HTTP/2-cleartext
326/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
327/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
328/// it goes out as a regular length-prefixed HTTP/1.1 request.
329pub async fn upload(
330 root_id: i64,
331 dir: &str,
332 overwrite: bool,
333 parts: Vec<(String, web_sys::File)>,
334) -> Result<UploadResp, ApiError> {
335 let window =
336 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
337
338 let boundary = format!("----fbng{}", random_boundary_suffix());
339 let segments = js_sys::Array::new();
340 for (name, file) in &parts {
341 let basename = name.rsplit('/').next().unwrap_or(name);
342 segments.push(&JsValue::from_str(&format!(
343 "--{boundary}\r\n\
344 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
345 Content-Type: application/octet-stream\r\n\r\n"
346 )));
347 let f: JsValue = file.clone().unchecked_into();
348 segments.push(&f);
349 segments.push(&JsValue::from_str("\r\n"));
350 }
351 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
352 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
353 .map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
354
355 let url = format!(
356 "{}?overwrite={}",
357 files_url(root_id, dir),
358 if overwrite { "true" } else { "false" }
359 );
360
361 let headers = web_sys::Headers::new()
362 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
363 headers
364 .set(
365 "Content-Type",
366 &format!("multipart/form-data; boundary={boundary}"),
367 )
368 .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
369
370 let opts = web_sys::RequestInit::new();
371 opts.set_method("POST");
372 opts.set_mode(web_sys::RequestMode::SameOrigin);
373 opts.set_headers_headers(&headers);
374 opts.set_body_opt_blob(Some(&body));
375
376 let promise = window.fetch_with_str_and_init(&url, &opts);
377 let resp_val = JsFuture::from(promise)
378 .await
379 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
380 let resp: web_sys::Response = resp_val
381 .dyn_into()
382 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
383
384 let status = resp.status();
385 if !(200..300).contains(&status) {
386 let body = parse_error_body(&resp).await;
387 return Err(ApiError::Http {
388 status,
389 message: body.error.unwrap_or_else(|| "upload failed".to_string()),
390 skipped: body.skipped,
391 });
392 }
393 let js = resp
394 .json()
395 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
396 let js: JsValue = JsFuture::from(js)
397 .await
398 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
399 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
400}
401
402// ---------------------------------------------------------------------------
403// File picker (imperative, one at a time)
404// ---------------------------------------------------------------------------
405
406fn random_boundary_suffix() -> String {
407 let mut s = String::with_capacity(16);
408 for _ in 0..16 {
409 let n = (js_sys::Math::random() * 36.0) as u32;
410 s.push(char::from_digit(n, 36).unwrap_or('a'));
411 }
412 s
413}
414
415use wasm_bindgen::closure::Closure;
416
417/// Open the native file dialog and run `on_files` with the picked files once
418/// the user confirms. `directory` uses webkitdirectory (folder upload).
419fn webkit_relative_path(file: &web_sys::File) -> String {
420 let js: JsValue = file.into();
421 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
422 .ok()
423 .and_then(|v| v.as_string())
424 .filter(|s| !s.is_empty())
425 .unwrap_or_default()
426}
427
428pub fn pick_files(
429 multiple: bool,
430 directory: bool,
431 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
432) {
433 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
434 return;
435 };
436 let Ok(el) = doc.create_element("input") else {
437 return;
438 };
439 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
440 return;
441 };
442 input.set_type("file");
443 if multiple {
444 input.set_multiple(true);
445 }
446 if directory {
447 let _ = input.set_attribute("webkitdirectory", "");
448 }
449
450 // The listener keeps the JS callback alive while the input exists;
451 // detach from Rust (the input is removed after the dialog is used).
452 // A cancelled dialog leaks the hidden input until reload — acceptable.
453 let input2 = input.clone();
454 let closure = Closure::<dyn FnMut()>::new(move || {
455 let mut files: Vec<(String, web_sys::File)> = Vec::new();
456 if let Some(list) = input.files() {
457 for i in 0..list.length() {
458 if let Some(f) = list.get(i) {
459 let rel = webkit_relative_path(&f);
460 let name = if rel.is_empty() { f.name() } else { rel };
461 files.push((name, f));
462 }
463 }
464 }
465 let _ = input.remove();
466 if !files.is_empty() {
467 on_files(files);
468 }
469 });
470 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
471 let _ = input2.add_event_listener_with_callback("change", listener);
472 closure.forget();
473 if let Some(body) = doc.body() {
474 let _ = body.append_child(&input2);
475 }
476 input2.click();
477}
478
479// ---------------------------------------------------------------------------
480// Low-level request helpers
481// ---------------------------------------------------------------------------
482
483async fn request<T: DeserializeOwned>(
484 method: &str,
485 url: String,
486 body: Option<impl Serialize>,
487) -> Result<T, ApiError> {
488 let window =
489 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
490
491 let opts = web_sys::RequestInit::new();
492 opts.set_method(method);
493 opts.set_mode(web_sys::RequestMode::SameOrigin);
494 if let Some(body) = body {
495 let json = serde_json_to_string(&body)
496 .map_err(|e| ApiError::Net(e.to_string()))?;
497 opts.set_body_opt_str(Some(&json));
498 let headers = web_sys::Headers::new()
499 .expect("Headers constructor failed");
500 let _ = headers.set("Content-Type", "application/json");
501 opts.set_headers_headers(&headers);
502 }
503
504 do_fetch(window, url, opts).await
505}
506
507/// Request without a body (mkdir / delete).
508async fn request_no_body<T: DeserializeOwned>(
509 method: &str,
510 url: String,
511) -> Result<T, ApiError> {
512 let window =
513 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
514 let opts = web_sys::RequestInit::new();
515 opts.set_method(method);
516 opts.set_mode(web_sys::RequestMode::SameOrigin);
517 do_fetch(window, url, opts).await
518}
519
520async fn do_fetch<T: DeserializeOwned>(
521 window: web_sys::Window,
522 url: String,
523 opts: web_sys::RequestInit,
524) -> Result<T, ApiError> {
525 let req = web_sys::Request::new_with_str_and_init(&url, &opts)
526 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
527
528 let promise = window.fetch_with_request(&req);
529 let resp_val = JsFuture::from(promise)
530 .await
531 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
532 let resp: web_sys::Response = resp_val
533 .dyn_into()
534 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
535
536 let status = resp.status();
537 if !(200..300).contains(&status) {
538 let body = parse_error_body(&resp).await;
539 return Err(ApiError::Http {
540 status,
541 message: body.error.unwrap_or_else(|| "request failed".to_string()),
542 skipped: body.skipped,
543 });
544 }
545
546 let json_promise = resp
547 .json()
548 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
549 let js: JsValue = JsFuture::from(json_promise)
550 .await
551 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
552
553 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
554}
555
556/// Parse the server's error JSON (message + optional conflict list).
557async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
558 let Ok(promise) = resp.text() else {
559 return ErrBody::default();
560 };
561 let Ok(js) = JsFuture::from(promise).await else {
562 return ErrBody::default();
563 };
564 let Some(text) = js.as_string() else {
565 return ErrBody::default();
566 };
567 if let Ok(v) = js_sys::JSON::parse(&text) {
568 if let Ok(body) = serde_wasm_bindgen::from_value::<ErrBody>(v) {
569 return body;
570 }
571 }
572 // Fallback: naive extraction of the "error" string.
573 const MARKER: &str = "\"error\":\"";
574 let error = text.find(MARKER).and_then(|start| {
575 let rest = &text[start + MARKER.len()..];
576 rest.find('"').map(|end| rest[..end].replace("\\\"", "\""))
577 });
578 ErrBody {
579 error,
580 skipped: None,
581 }
582}
583
584/// Read a form input's value by element id.
585pub fn input_value(id: &str) -> String {
586 web_sys::window()
587 .and_then(|w| w.document())
588 .and_then(|d| {
589 d.get_element_by_id(id)
590 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
591 })
592 .map(|i| i.value())
593 .unwrap_or_default()
594}
595
596fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
597 // Reuse the wasm-bindgen JSON serializer; the body must be a plain string
598 // so we convert via JSON text.
599 let value = serde_wasm_bindgen::to_value(v)?;
600 let s = js_sys::JSON::stringify(&value).map_err(|e| {
601 serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}"))
602 })?;
603 s.as_string().ok_or_else(|| {
604 serde_wasm_bindgen::Error::new("stringify returned a non-string")
605 })
606}
607