api.rs
⎇
Raw
1//! Typed HTTP client for the filebrowser-ng API.
2//!
3//! Endpoint paths, query params and wire types all come from the shared
4//! `api_types` crate (the same one the server's route table and handlers
5//! use), so the two sides cannot drift apart.
6
7use leptos::prelude::Callable;
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use wasm_bindgen::JsCast;
11use wasm_bindgen::JsValue;
12use wasm_bindgen::closure::Closure;
13use wasm_bindgen_futures::JsFuture;
14
15use api_types::{
16 ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_EXISTS, ACTION_MKDIR,
17 ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS,
18 AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
19 AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateAppPassword,
20 CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq,
21 Mutation, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_PATH,
22 P_Q, P_ROOT, P_SCOPE, P_SHARE, P_SORT, PasskeyLoginBegin, PasskeyLoginFinish,
23 PasskeyRegisterFinish, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings,
24 SortKey, UnlockShare, UpdateUser,
25};
26use api_types::{
27 ADMIN_PIM_LINKS, ADMIN_ROOMS, CreatePimCollection, CreatePimLink, CreatePimShare, CreateRoom,
28 EXPORT_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, P_TZ, PIM_COLLECTIONS, PIM_PREVIEW, PimRootFile,
29 SHARES_SUFFIX, UpdatePimCollection, UpdateRoom,
30};
31pub use api_types::{
32 AdminPimLink, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview,
33 PimShareInfo, PimShareMode, RoomInfo, RoomKind,
34};
35pub use api_types::{
36 AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
37 Mode, NewAppPassword, OkResp, Op, PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo,
38 SaveResp, ShareInfo, UserInfo,
39};
40
41#[derive(Debug)]
42pub enum ApiError {
43 /// Non-2xx response. `skipped` carries the server's conflict file list
44 /// when present (upload conflicts).
45 Http {
46 #[allow(dead_code)]
47 status: u16,
48 message: String,
49 skipped: Option<Vec<String>>,
50 },
51 /// The file changed on disk since it was read (save conflict, HTTP 409).
52 Conflict,
53 /// The request never got a response (server down, connection lost) or
54 /// the response could not be used. Carries a message ready to display.
55 Net(String),
56}
57
58impl std::fmt::Display for ApiError {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 ApiError::Http { message: m, .. } | ApiError::Net(m) => f.write_str(m),
62 ApiError::Conflict => f.write_str("the file was changed on disk"),
63 }
64 }
65}
66
67/// A JS error's `message` (the whole `Debug` output includes the stack).
68fn js_msg(e: &JsValue) -> String {
69 e.dyn_ref::<js_sys::Error>()
70 .map(|e| String::from(e.message()))
71 .unwrap_or_else(|| format!("{e:?}"))
72}
73
74impl ApiError {
75 pub fn skipped(&self) -> Option<&[String]> {
76 match self {
77 ApiError::Http {
78 skipped: Some(s), ..
79 } => Some(s),
80 _ => None,
81 }
82 }
83
84 /// The HTTP status code, when this was an HTTP (non-2xx) error.
85 pub fn status(&self) -> Option<u16> {
86 match self {
87 ApiError::Http { status, .. } => Some(*status),
88 _ => None,
89 }
90 }
91}
92
93/// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`.
94/// The message is already localized in [`fetch_checked`]; `code` carries the
95/// machine-readable identifier the server sends for known failures.
96#[derive(serde::Deserialize, Default)]
97struct ErrBody {
98 #[serde(default)]
99 error: Option<String>,
100 #[serde(default)]
101 code: Option<String>,
102 #[serde(default)]
103 skipped: Option<Vec<String>>,
104}
105
106impl ErrBody {
107 /// The error for a non-2xx `status`. Known server errors carry a code
108 /// the client maps to a localized message; unknown ones fall back to the
109 /// raw text.
110 fn into_error(self, status: u16, fallback: &str) -> ApiError {
111 let fallback = self.error.as_deref().unwrap_or(fallback);
112 ApiError::Http {
113 status,
114 message: crate::i18n::error_text(self.code.as_deref(), fallback),
115 skipped: self.skipped,
116 }
117 }
118}
119
120// ---------------------------------------------------------------------------
121// Auth
122// ---------------------------------------------------------------------------
123
124pub async fn me() -> Result<Me, ApiError> {
125 let me: Me = request("GET", AUTH_ME.to_string(), None::<()>).await?;
126 crate::router::set_public_url(me.public_url.clone());
127 Ok(me)
128}
129
130/// PUT /api/auth/me — update the signed-in user's profile settings.
131/// Omitted fields are left unchanged; `language: Some(None)` means "follow
132/// the browser".
133#[derive(Serialize, Default)]
134struct ProfilePatch {
135 #[serde(skip_serializing_if = "Option::is_none")]
136 single_click_open: Option<bool>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 thumbnails: Option<bool>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 language: Option<Option<String>>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 default_root_id: Option<Option<i64>>,
143}
144
145pub fn update_profile(
146 single_click_open: Option<bool>,
147 thumbnails: Option<bool>,
148 language: Option<Option<String>>,
149 default_root_id: Option<Option<i64>>,
150) -> impl std::future::Future<Output = Result<Me, ApiError>> {
151 request(
152 "PUT",
153 AUTH_ME.to_string(),
154 Some(ProfilePatch {
155 single_click_open,
156 thumbnails,
157 language,
158 default_root_id,
159 }),
160 )
161}
162
163/// The password leg of signing in.
164///
165/// `state_id` names a passkey leg that already identified the account, which
166/// is how an account requiring both factors finishes when the user starts
167/// with the passkey. Then `name` is not needed and is ignored.
168pub fn login(
169 name: Option<String>,
170 password: String,
171 state_id: Option<String>,
172) -> impl std::future::Future<Output = Result<LoginResp, ApiError>> {
173 request(
174 "POST",
175 AUTH_LOGIN.to_string(),
176 Some(LoginReq {
177 name,
178 password,
179 state_id,
180 }),
181 )
182}
183
184// ---------------------------------------------------------------------------
185// Credentials: password, sign-in mode, passkeys
186// ---------------------------------------------------------------------------
187
188pub fn change_password(
189 new_password: String,
190) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
191 request(
192 "POST",
193 AUTH_PASSWORD.to_string(),
194 Some(ChangePassword { new_password }),
195 )
196}
197
198pub fn delete_password() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
199 request("DELETE", AUTH_PASSWORD.to_string(), None::<()>)
200}
201
202pub fn set_auth_mode(
203 mode: AuthMode,
204) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
205 request("PUT", AUTH_MODE.to_string(), Some(SetAuthMode { mode }))
206}
207
208pub fn list_passkeys() -> impl std::future::Future<Output = Result<Vec<PasskeyInfo>, ApiError>> {
209 request("GET", AUTH_PASSKEYS.to_string(), None::<()>)
210}
211
212pub fn delete_passkey(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
213 request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>)
214}
215
216pub fn list_app_passwords()
217-> impl std::future::Future<Output = Result<Vec<AppPasswordInfo>, ApiError>> {
218 request("GET", AUTH_APP_PASSWORDS.to_string(), None::<()>)
219}
220
221pub fn create_app_password(
222 name: String,
223) -> impl std::future::Future<Output = Result<NewAppPassword, ApiError>> {
224 request(
225 "POST",
226 AUTH_APP_PASSWORDS.to_string(),
227 Some(CreateAppPassword { name }),
228 )
229}
230
231pub fn delete_app_password(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
232 request("DELETE", format!("{AUTH_APP_PASSWORDS}/{id}"), None::<()>)
233}
234
235/// Register a passkey end to end: ask for a challenge, hand it to the
236/// browser, send the answer back.
237///
238/// One function rather than two calls at the view layer, because the two legs
239/// are useless apart and the handle between them is not the view's business.
240pub async fn add_passkey(name: String) -> Result<PasskeyInfo, ApiError> {
241 let challenge: PasskeyChallenge =
242 request("POST", AUTH_PASSKEYS_REGISTER.to_string(), None::<()>).await?;
243 let credential = crate::passkey::create(&challenge.options)
244 .await
245 .map_err(ApiError::Net)?;
246 request(
247 "POST",
248 format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"),
249 Some(PasskeyRegisterFinish {
250 state_id: challenge.state_id,
251 name,
252 credential,
253 }),
254 )
255 .await
256}
257
258/// Sign in with a passkey.
259///
260/// `Ok(None)` means the browser request was cancelled to make room for
261/// another one — nothing happened, and nothing should be shown.
262pub async fn passkey_login(
263 name: Option<String>,
264 conditional: bool,
265) -> Result<Option<LoginResp>, ApiError> {
266 let challenge: PasskeyChallenge = request(
267 "POST",
268 AUTH_PASSKEY_LOGIN.to_string(),
269 Some(PasskeyLoginBegin { name, conditional }),
270 )
271 .await?;
272 passkey_finish(challenge, conditional).await
273}
274
275/// Answer a challenge the server already handed out: the second factor of a
276/// password sign-in arrives inside the login response, so that leg has no
277/// begin call of its own.
278pub async fn passkey_finish(
279 challenge: PasskeyChallenge,
280 conditional: bool,
281) -> Result<Option<LoginResp>, ApiError> {
282 let Some(credential) = crate::passkey::get(&challenge.options, conditional)
283 .await
284 .map_err(ApiError::Net)?
285 else {
286 return Ok(None);
287 };
288 request(
289 "POST",
290 format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"),
291 Some(PasskeyLoginFinish {
292 state_id: challenge.state_id,
293 credential,
294 }),
295 )
296 .await
297 .map(Some)
298}
299
300pub fn setup(
301 name: String,
302 password: String,
303) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
304 request(
305 "POST",
306 AUTH_SETUP.to_string(),
307 Some(Credentials { name, password }),
308 )
309}
310
311pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
312 request("POST", AUTH_LOGOUT.to_string(), Some(()))
313}
314
315// ---------------------------------------------------------------------------
316// Files
317// ---------------------------------------------------------------------------
318
319/// The public share token while the app is showing a share page. Every file
320/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
321/// shown per page, so a plain static suffices (wasm is single-threaded).
322use std::sync::Mutex;
323static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
324
325/// Set (or clear, with `None`) the share token used by file API calls.
326pub fn set_share_token(token: Option<&str>) {
327 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
328}
329
330fn share_suffix() -> String {
331 SHARE_TOKEN
332 .lock()
333 .unwrap()
334 .as_deref()
335 .map(|t| format!("?{P_SHARE}={t}"))
336 .unwrap_or_default()
337}
338
339/// Append `key=value` to a URL, using `&` when a query string already exists.
340fn append_query(url: &str, kv: &str) -> String {
341 if url.contains('?') {
342 format!("{url}&{kv}")
343 } else {
344 format!("{url}?{kv}")
345 }
346}
347
348fn files_url(root_id: i64, path: &str) -> String {
349 let base = if path.is_empty() {
350 format!("{FILES}/{root_id}")
351 } else {
352 let encoded: Vec<String> = path
353 .split('/')
354 .map(|s| js_sys::encode_uri_component(s).into())
355 .collect();
356 format!("{FILES}/{root_id}/{}", encoded.join("/"))
357 };
358 format!("{base}{}", share_suffix())
359}
360
361/// One page of a folder, in the given order. With `around`, the page that
362/// holds that name.
363pub fn list_files(
364 root_id: i64,
365 path: &str,
366 sort: SortKey,
367 desc: bool,
368 offset: usize,
369 limit: usize,
370 around: Option<&str>,
371) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
372 let mut query = format!(
373 "{P_SORT}={}&{P_DESC}={desc}&{P_OFFSET}={offset}&{P_LIMIT}={limit}",
374 sort.as_str()
375 );
376 if let Some(name) = around {
377 query.push_str(&format!(
378 "&{P_AROUND}={}",
379 String::from(js_sys::encode_uri_component(name))
380 ));
381 }
382 request(
383 "GET",
384 append_query(&files_url(root_id, path), &query),
385 None::<()>,
386 )
387}
388
389/// One entry of a folder, with its sniffed kind. `None` when it is gone.
390pub async fn find_entry(root_id: i64, dir: &str, name: &str) -> Result<Option<Entry>, ApiError> {
391 let r = list_files(root_id, dir, SortKey::Name, false, 0, 1, Some(name)).await?;
392 Ok(r.entries.into_iter().find(|e| e.name == name))
393}
394
395/// The subfolders of a folder, by name.
396pub fn list_dirs(
397 root_id: i64,
398 path: &str,
399) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
400 let url = append_query(&files_url(root_id, path), &format!("{P_DIRS}=true"));
401 request("GET", url, None::<()>)
402}
403
404/// Create an empty file. `path` is relative to the root (may contain
405/// subfolders); the file must not exist yet.
406pub fn create_file(
407 root_id: i64,
408 path: &str,
409) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
410 let url = append_query(
411 &files_url(root_id, path),
412 &format!("{P_ACTION}={ACTION_CREATE_FILE}"),
413 );
414 request("POST", url, None::<()>)
415}
416
417/// Create a folder. `path` is relative to the root (may contain subfolders).
418pub fn mkdir(
419 root_id: i64,
420 path: &str,
421) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
422 let url = append_query(
423 &files_url(root_id, path),
424 &format!("{P_ACTION}={ACTION_MKDIR}"),
425 );
426 request("POST", url, None::<()>)
427}
428
429pub fn rename_item(
430 root_id: i64,
431 path: &str,
432 new_name: String,
433 overwrite: bool,
434) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
435 request(
436 "POST",
437 files_url(root_id, path),
438 Some(Mutation {
439 op: Op::Rename,
440 new_name: Some(new_name),
441 dst_root_id: None,
442 dst: None,
443 overwrite,
444 }),
445 )
446}
447
448/// Move or copy an item into `dst` of `dst_root_id`.
449pub fn mutation(
450 root_id: i64,
451 path: &str,
452 op: Op,
453 dst_root_id: i64,
454 dst: &str,
455 overwrite: bool,
456) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
457 request(
458 "POST",
459 files_url(root_id, path),
460 Some(Mutation {
461 op,
462 new_name: None,
463 dst_root_id: Some(dst_root_id),
464 dst: Some(dst.to_string()),
465 overwrite,
466 }),
467 )
468}
469
470pub fn delete_item(
471 root_id: i64,
472 path: &str,
473) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
474 request("DELETE", files_url(root_id, path), None::<()>)
475}
476
477// ---------------------------------------------------------------------------
478// Download / preview / content (milestone 4)
479// ---------------------------------------------------------------------------
480
481/// `...?action=download` — a single file as-is, or a folder as `format`.
482pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
483 let mut base = append_query(
484 &files_url(root_id, path),
485 &format!("{P_ACTION}={ACTION_DOWNLOAD}"),
486 );
487 if let Some(f) = format {
488 base = append_query(&base, &format!("{P_FORMAT}={f}"));
489 }
490 base
491}
492
493/// `...?action=preview` — a single file, inline (native media).
494pub fn preview_url(root_id: i64, path: &str) -> String {
495 append_query(
496 &files_url(root_id, path),
497 &format!("{P_ACTION}={ACTION_PREVIEW}"),
498 )
499}
500
501/// `...?action=thumb` — a small WebP for the grid.
502///
503/// `mtime` is in the query so a changed file is a new URL. The server marks
504/// the response immutable, which depends on that.
505pub fn thumb_url(root_id: i64, path: &str, mtime: &str) -> String {
506 append_query(
507 &files_url(root_id, path),
508 &format!(
509 "{P_ACTION}={ACTION_THUMB}&v={}",
510 js_sys::encode_uri_component(mtime)
511 ),
512 )
513}
514
515/// `...?action=content` — raw file bytes for the text preview/editor.
516pub fn content_url(root_id: i64, path: &str) -> String {
517 append_query(
518 &files_url(root_id, path),
519 &format!("{P_ACTION}={ACTION_CONTENT}"),
520 )
521}
522
523/// Fetch a file's raw text content plus its mtime (unix seconds), for the
524/// preview and the editor. The mtime anchors the save-time conflict check.
525pub async fn fetch_content_meta(
526 root_id: i64,
527 path: &str,
528) -> Result<(String, Option<i64>), ApiError> {
529 let opts = web_sys::RequestInit::new();
530 opts.set_method("GET");
531 opts.set_mode(web_sys::RequestMode::SameOrigin);
532 let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
533 let mtime: Option<i64> = resp
534 .headers()
535 .get("x-file-mtime")
536 .ok()
537 .flatten()
538 .and_then(|s| s.parse::<i64>().ok());
539 let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?;
540 let js = JsFuture::from(tp)
541 .await
542 .map_err(|e| ApiError::Net(js_msg(&e)))?;
543 let text = js
544 .as_string()
545 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
546 Ok((text, mtime))
547}
548
549/// Save a file's text content (the editor's write path).
550///
551/// When `force` is false, `expected_mtime` is sent and the server rejects the
552/// save with [`ApiError::Conflict`] if the file changed on disk since it was
553/// read. When `force` is true the check is skipped (overwrite). Returns the
554/// file's new mtime (unix seconds) to anchor the next check.
555pub async fn save_content(
556 root_id: i64,
557 path: &str,
558 text: &str,
559 expected_mtime: Option<i64>,
560 force: bool,
561) -> Result<i64, ApiError> {
562 let url = content_url(root_id, path);
563 let opts = web_sys::RequestInit::new();
564 opts.set_method("PUT");
565 opts.set_mode(web_sys::RequestMode::SameOrigin);
566 opts.set_body_opt_str(Some(text));
567 let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
568 headers
569 .set("Content-Type", "text/plain; charset=utf-8")
570 .map_err(|e| ApiError::Net(js_msg(&e)))?;
571 if !force && let Some(m) = expected_mtime {
572 headers
573 .set("X-Expected-Mtime", &m.to_string())
574 .map_err(|e| ApiError::Net(js_msg(&e)))?;
575 }
576 opts.set_headers_headers(&headers);
577 // 409 is the server's "changed on disk" answer, not a generic HTTP error.
578 let resp = match fetch_checked(&url, &opts, "could not save file").await {
579 Ok(resp) => resp,
580 Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
581 Err(e) => return Err(e),
582 };
583 let save: SaveResp = read_json(&resp).await?;
584 Ok(save.mtime)
585}
586
587/// Open a URL in a new tab.
588///
589/// `noopener` severs the `window.opener` link, so the opened page cannot
590/// script this one. That matters here because the target is a *user file*:
591/// HTML and SVG render as real documents (under the server's sandbox CSP, see
592/// `FILE_CSP`), and this is the browser-side half of the same isolation.
593pub fn open_in_new_tab(url: &str) {
594 if let Some(w) = web_sys::window() {
595 let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener");
596 }
597}
598
599/// Trigger a browser download of a same-origin URL via a temporary anchor.
600/// No data is pulled into JS memory — the browser streams it.
601pub fn trigger_download(url: &str, filename: &str) {
602 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
603 return;
604 };
605 let Ok(el) = doc.create_element("a") else {
606 return;
607 };
608 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
609 return;
610 };
611 a.set_href(url);
612 a.set_download(filename);
613 if let Some(body) = doc.body() {
614 let _ = body.append_child(&a);
615 }
616 a.click();
617 a.remove();
618}
619
620/// Build a multipart body by hand into a `Blob` (instead of using
621/// `FormData` directly as the request body): a FormData body makes Chrome
622/// send the request as a *streaming* body, which forces the HTTP/2-cleartext
623/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
624/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
625/// it goes out as a regular length-prefixed HTTP/1.1 request.
626///
627/// Returns the body and its `Content-Type` header value.
628fn multipart_blob(parts: &[(String, web_sys::File)]) -> Result<(web_sys::Blob, String), ApiError> {
629 let boundary = format!("----fbng{}", random_boundary_suffix());
630 let segments = js_sys::Array::new();
631 for (name, file) in parts {
632 let basename = escape_cd(name.rsplit('/').next().unwrap_or(name));
633 let name = escape_cd(name);
634 segments.push(&JsValue::from_str(&format!(
635 "--{boundary}\r\n\
636 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
637 Content-Type: application/octet-stream\r\n\r\n"
638 )));
639 let f: JsValue = file.clone().unchecked_into();
640 segments.push(&f);
641 segments.push(&JsValue::from_str("\r\n"));
642 }
643 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
644 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
645 .map_err(|e| ApiError::Net(js_msg(&e)))?;
646 Ok((body, format!("multipart/form-data; boundary={boundary}")))
647}
648
649/// Escape a multipart part name per the WHATWG form-data rules. The three
650/// characters that would break out of the quoted `Content-Disposition`
651/// value are percent-encoded; the server decodes them back.
652fn escape_cd(s: &str) -> String {
653 s.replace('"', "%22")
654 .replace('\r', "%0D")
655 .replace('\n', "%0A")
656}
657
658/// Read-only upload pre-check: which of `paths` (relative to `dir`, may
659/// contain subfolders) already exist, and whether each is a folder.
660pub async fn check_exists(
661 root_id: i64,
662 dir: &str,
663 paths: Vec<String>,
664) -> Result<Vec<Existing>, ApiError> {
665 let url = append_query(
666 &files_url(root_id, dir),
667 &format!("{P_ACTION}={ACTION_EXISTS}"),
668 );
669 // The server caps one request at 10 000 paths, the JSON body at 1 MB.
670 // A folder upload can bring far more (a kernel tree is ~80 000 files).
671 // Path length varies a lot, so the chunks are cut by bytes as well.
672 const CHUNK_BYTES: usize = 900_000;
673 const CHUNK_PATHS: usize = 10_000;
674 let mut existing = Vec::new();
675 let mut chunk: Vec<String> = Vec::new();
676 let mut bytes = 0usize;
677 for p in paths {
678 // Quotes, comma and escaping headroom around each path in the JSON.
679 bytes += p.len() + 8;
680 chunk.push(p);
681 if bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_PATHS {
682 existing.extend(exists_chunk(&url, std::mem::take(&mut chunk)).await?);
683 bytes = 0;
684 }
685 }
686 if !chunk.is_empty() {
687 existing.extend(exists_chunk(&url, chunk).await?);
688 }
689 Ok(existing)
690}
691
692async fn exists_chunk(url: &str, paths: Vec<String>) -> Result<Vec<Existing>, ApiError> {
693 let resp: ExistsResp = request("POST", url.to_string(), Some(ExistsReq { paths })).await?;
694 Ok(resp.existing)
695}
696
697/// Upload `files` into `dir` in one request, each as `(relative path,
698/// file)`; subfolders are created on the server. The returned request can be
699/// `abort()`ed; the future then resolves to [`ApiError::Net`], the same as a
700/// lost connection. `on_progress` gets the file bytes sent so far (multipart
701/// framing excluded). `overwrite=false` makes the server skip files that
702/// exist and list them in a 409 after writing the rest; those are the files
703/// that appeared during the transfer, since the pre-check covered the ones
704/// that existed before.
705pub fn start_upload(
706 root_id: i64,
707 dir: &str,
708 files: Vec<(String, web_sys::File)>,
709 overwrite: bool,
710 on_progress: impl Fn(f64) + 'static,
711) -> Result<
712 (
713 web_sys::XmlHttpRequest,
714 impl std::future::Future<Output = Result<(), ApiError>>,
715 ),
716 ApiError,
717> {
718 let size: f64 = files.iter().map(|(_, f)| f.size()).sum();
719 let (body, content_type) = multipart_blob(&files)?;
720 let url = append_query(
721 &files_url(root_id, dir),
722 &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
723 );
724 let xhr = web_sys::XmlHttpRequest::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
725 xhr.open_with_async("POST", &url, true)
726 .map_err(|e| ApiError::Net(js_msg(&e)))?;
727 xhr.set_request_header("Content-Type", &content_type)
728 .map_err(|e| ApiError::Net(js_msg(&e)))?;
729
730 let progress =
731 Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |ev: web_sys::ProgressEvent| {
732 // `loaded` counts the whole multipart body, boundaries included.
733 // Scale it to file bytes so a tiny file never reads as 600 %.
734 let total = ev.total();
735 let file_bytes = if total > 0.0 {
736 (ev.loaded() / total * size).min(size)
737 } else {
738 ev.loaded().min(size)
739 };
740 on_progress(file_bytes);
741 });
742 if let Ok(up) = xhr.upload() {
743 up.set_onprogress(Some(progress.as_ref().unchecked_ref()));
744 }
745 // `loadend` fires for success, error and abort alike; the status tells
746 // them apart afterwards.
747 let done = js_sys::Promise::new(&mut |resolve, _reject| {
748 xhr.set_onloadend(Some(&resolve));
749 });
750 let req = xhr.clone();
751 xhr.send_with_opt_blob(Some(&body))
752 .map_err(|e| ApiError::Net(js_msg(&e)))?;
753
754 let fut = async move {
755 let _ = JsFuture::from(done).await;
756 // Keep the progress listener alive until here.
757 drop(progress);
758 let status = xhr.status().unwrap_or(0);
759 if status == 0 {
760 // Our own abort and a dead network are indistinguishable here:
761 // both give status 0 and an empty status text. Report the
762 // network error; the caller knows whether it aborted.
763 return Err(ApiError::Net(
764 crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string(),
765 ));
766 }
767 if (200..300).contains(&status) {
768 return Ok(());
769 }
770 let body: ErrBody = xhr
771 .response_text()
772 .ok()
773 .flatten()
774 .and_then(|t| serde_json::from_str(&t).ok())
775 .unwrap_or_default();
776 Err(body.into_error(status, "upload failed"))
777 };
778 Ok((req, fut))
779}
780
781// ---------------------------------------------------------------------------
782// Shares (milestone 6)
783// ---------------------------------------------------------------------------
784
785pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
786 request("GET", SHARES.to_string(), None::<()>)
787}
788
789pub fn create_share(
790 root_id: i64,
791 path: &str,
792 writable: bool,
793 expires_at: Option<&str>,
794 password: Option<&str>,
795) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
796 request(
797 "POST",
798 SHARES.to_string(),
799 Some(CreateShare {
800 root_id,
801 path: path.to_string(),
802 writable,
803 expires_at: expires_at.map(|s| s.to_string()),
804 password: password.map(|s| s.to_string()),
805 }),
806 )
807}
808
809pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
810 request("DELETE", format!("{SHARES}/{id}"), None::<()>)
811}
812
813/// Admin only: every share on the server with its creator.
814pub fn list_all_shares() -> impl std::future::Future<Output = Result<Vec<AdminShare>, ApiError>> {
815 request("GET", ADMIN_SHARES.to_string(), None::<()>)
816}
817
818/// Admin only: end a share whoever created it.
819pub fn admin_delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
820 request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>)
821}
822
823/// Public: resolve a share (no session required). A password-protected
824/// share answers 401 until [`unlock_share`] has run in this browser.
825pub fn resolve_share(
826 token: &str,
827) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
828 request("GET", format!("{SHARE}/{token}"), None::<()>)
829}
830
831/// Public: submit a protected share's password. The proof of the unlock is
832/// a cookie the server sets, so nothing has to be kept here.
833pub fn unlock_share(
834 token: &str,
835 password: &str,
836) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
837 request(
838 "POST",
839 format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
840 Some(UnlockShare {
841 password: password.to_string(),
842 }),
843 )
844}
845
846// ---------------------------------------------------------------------------
847// Admin (milestone 7): user management + settings
848// ---------------------------------------------------------------------------
849
850fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
851 roots
852 .iter()
853 .map(|(path, mode)| Root {
854 path: path.clone(),
855 mode: *mode,
856 })
857 .collect()
858}
859
860pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
861 request("GET", ADMIN_USERS.to_string(), None::<()>)
862}
863
864pub fn create_admin_user(
865 name: &str,
866 password: &str,
867 is_admin: bool,
868 roots: &[(String, Mode)],
869) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
870 request(
871 "POST",
872 ADMIN_USERS.to_string(),
873 Some(CreateUser {
874 name: name.to_string(),
875 password: password.to_string(),
876 is_admin,
877 roots: roots_to_bodies(roots),
878 }),
879 )
880}
881
882pub fn update_admin_user(
883 id: i64,
884 password: Option<String>,
885 is_admin: Option<bool>,
886 active: Option<bool>,
887 roots: Option<Vec<(String, Mode)>>,
888) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
889 request(
890 "PUT",
891 format!("{ADMIN_USERS}/{id}"),
892 Some(UpdateUser {
893 password,
894 is_admin,
895 active,
896 roots: roots.map(|r| roots_to_bodies(&r)),
897 }),
898 )
899}
900
901pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
902 request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
903}
904
905pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
906 request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
907}
908
909/// PUT replaces the whole settings object, so every setting has to be
910/// passed. Omitting one would reset it.
911pub fn update_admin_settings(
912 allow_writable_shares: bool,
913 search_excludes: Vec<String>,
914) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
915 request(
916 "PUT",
917 ADMIN_SETTINGS.to_string(),
918 Some(Settings {
919 allow_writable_shares,
920 search_excludes,
921 }),
922 )
923}
924
925// ---------------------------------------------------------------------------
926// File picker (imperative, one at a time)
927// ---------------------------------------------------------------------------
928
929fn random_boundary_suffix() -> String {
930 let mut s = String::with_capacity(16);
931 for _ in 0..16 {
932 let n = (js_sys::Math::random() * 36.0) as u32;
933 s.push(char::from_digit(n, 36).unwrap_or('a'));
934 }
935 s
936}
937
938/// Open the native file dialog and run `on_files` with the picked files once
939/// the user confirms. `directory` uses webkitdirectory (folder upload).
940fn webkit_relative_path(file: &web_sys::File) -> String {
941 let js: JsValue = file.into();
942 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
943 .ok()
944 .and_then(|v| v.as_string())
945 .filter(|s| !s.is_empty())
946 .unwrap_or_default()
947}
948
949pub fn pick_files(
950 multiple: bool,
951 directory: bool,
952 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
953) {
954 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
955 return;
956 };
957 let Ok(el) = doc.create_element("input") else {
958 return;
959 };
960 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
961 return;
962 };
963 input.set_type("file");
964 // Off screen: the browser renders a bare "Choose files" control for an
965 // input in the body, and `click()` still works on a hidden one.
966 let _ = input.set_attribute("hidden", "");
967 if multiple {
968 input.set_multiple(true);
969 }
970 if directory {
971 let _ = input.set_attribute("webkitdirectory", "");
972 }
973
974 // Known small leak, deliberate: the input holds the listener, the
975 // listener holds this closure, and the closure captures the input — a
976 // cycle that nothing frees. `forget()` detaches the Rust side, so the
977 // element + JS function + closure (~1 KB) survive until reload even
978 // when the dialog is used (not only when cancelled). Dropping the
979 // closure from Rust while the JS listener still references it would
980 // leave a dangling callback, and a closure cannot drop itself; a clean
981 // fix needs the caller to own it (e.g. a `StoredValue` released in
982 // `on_cleanup`), which is not worth it at this size.
983 let input2 = input.clone();
984 let closure = Closure::<dyn FnMut()>::new(move || {
985 let mut files: Vec<(String, web_sys::File)> = Vec::new();
986 if let Some(list) = input.files() {
987 for i in 0..list.length() {
988 if let Some(f) = list.get(i) {
989 let rel = webkit_relative_path(&f);
990 let name = if rel.is_empty() { f.name() } else { rel };
991 files.push((name, f));
992 }
993 }
994 }
995 input.remove();
996 if !files.is_empty() {
997 on_files(files);
998 }
999 });
1000 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
1001 let _ = input2.add_event_listener_with_callback("change", listener);
1002 closure.forget();
1003 if let Some(body) = doc.body() {
1004 let _ = body.append_child(&input2);
1005 }
1006 input2.click();
1007}
1008
1009/// True when a drag carries files (not text or a link from the page).
1010pub fn drag_has_files(ev: &web_sys::DragEvent) -> bool {
1011 ev.data_transfer()
1012 .map(|dt| dt.types().includes(&JsValue::from_str("Files"), 0))
1013 .unwrap_or(false)
1014}
1015
1016/// The top-level items of a drop, read out of the `DataTransfer` while the
1017/// drop handler still runs. A `DataTransfer` is only readable during its own
1018/// event, so an async task that reads it later finds it empty. [`read_drop`]
1019/// therefore copies the entries and files out first.
1020pub struct Dropped {
1021 entries: Vec<web_sys::FileSystemEntry>,
1022 /// Flat list, for browsers without the entries API.
1023 files: Vec<web_sys::File>,
1024}
1025
1026impl Dropped {
1027 /// Names of the top-level items, for a job label before the folders are
1028 /// walked. A dropped folder shows up as one name here.
1029 pub fn names(&self) -> Vec<String> {
1030 if self.entries.is_empty() {
1031 self.files.iter().map(|f| f.name()).collect()
1032 } else {
1033 self.entries.iter().map(|e| e.name()).collect()
1034 }
1035 }
1036}
1037
1038/// Read a drop synchronously. See [`Dropped`].
1039pub fn read_drop(ev: &web_sys::DragEvent) -> Dropped {
1040 let Some(dt) = ev.data_transfer() else {
1041 return Dropped {
1042 entries: Vec::new(),
1043 files: Vec::new(),
1044 };
1045 };
1046 let items = dt.items();
1047 let mut entries = Vec::new();
1048 for i in 0..items.length() {
1049 if let Some(item) = items.get(i)
1050 && item.kind() == "file"
1051 && let Ok(Some(entry)) = item.webkit_get_as_entry()
1052 {
1053 entries.push(entry);
1054 }
1055 }
1056 let mut files = Vec::new();
1057 if let Some(list) = dt.files() {
1058 for i in 0..list.length() {
1059 if let Some(f) = list.get(i) {
1060 files.push(f);
1061 }
1062 }
1063 }
1064 Dropped { entries, files }
1065}
1066
1067/// The files of a drop as `(relative path, file)`. Dropped folders are
1068/// walked through the entries API, which is what gives them a path; the
1069/// plain `files` list flattens them to nothing. Browsers without that API
1070/// get the flat list.
1071///
1072/// Each directory's files are resolved in one `Promise.all`: `entry.file()`
1073/// is a round trip into the browser process, and 80 000 of them in a row
1074/// take minutes.
1075pub async fn files_from_drop(dropped: Dropped) -> Vec<(String, web_sys::File)> {
1076 let Dropped { entries, files } = dropped;
1077 let mut out = Vec::new();
1078 if entries.is_empty() {
1079 return files.into_iter().map(|f| (f.name(), f)).collect();
1080 }
1081 // Iterative walk: a stack instead of recursion keeps the future `Sized`.
1082 // Top-level files are one batch; then each directory is one batch.
1083 let mut dirs: Vec<(String, web_sys::FileSystemDirectoryEntry)> = Vec::new();
1084 let mut files: Vec<(String, web_sys::FileSystemFileEntry)> = Vec::new();
1085 for e in entries {
1086 let name = e.name();
1087 if e.is_directory() {
1088 dirs.push((name, e.unchecked_into()));
1089 } else if e.is_file() {
1090 files.push((name, e.unchecked_into()));
1091 }
1092 }
1093 out.extend(resolve_files(files).await);
1094 while let Some((path, dir)) = dirs.pop() {
1095 let reader = dir.create_reader();
1096 let mut files = Vec::new();
1097 // `readEntries` hands out batches (Chrome: 100) until an empty one.
1098 loop {
1099 let batch = read_entries(&reader).await;
1100 if batch.is_empty() {
1101 break;
1102 }
1103 for e in batch {
1104 let sub = format!("{path}/{}", e.name());
1105 if e.is_directory() {
1106 dirs.push((sub, e.unchecked_into()));
1107 } else if e.is_file() {
1108 files.push((sub, e.unchecked_into()));
1109 }
1110 }
1111 }
1112 out.extend(resolve_files(files).await);
1113 }
1114 out
1115}
1116
1117/// `entry.file()` for every entry at once. An entry that fails (vanished
1118/// mid-drop) is left out.
1119async fn resolve_files(
1120 entries: Vec<(String, web_sys::FileSystemFileEntry)>,
1121) -> Vec<(String, web_sys::File)> {
1122 if entries.is_empty() {
1123 return Vec::new();
1124 }
1125 let promises = js_sys::Array::new();
1126 for (_, entry) in &entries {
1127 let p = js_sys::Promise::new(&mut |resolve, _reject| {
1128 let resolve2 = resolve.clone();
1129 let ok = Closure::once_into_js(move |f: web_sys::File| {
1130 let _ = resolve2.call1(&JsValue::NULL, &f);
1131 });
1132 let err = Closure::once_into_js(move |_e: JsValue| {
1133 let _ = resolve.call1(&JsValue::NULL, &JsValue::NULL);
1134 });
1135 entry.file_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
1136 });
1137 promises.push(&p);
1138 }
1139 let all = match wasm_bindgen_futures::JsFuture::from(js_sys::Promise::all(&promises)).await {
1140 Ok(a) => a,
1141 Err(_) => return Vec::new(),
1142 };
1143 entries
1144 .into_iter()
1145 .zip(js_sys::Array::from(&all).iter())
1146 .filter_map(|((path, _), v)| v.dyn_into::<web_sys::File>().ok().map(|f| (path, f)))
1147 .collect()
1148}
1149
1150async fn read_entries(
1151 reader: &web_sys::FileSystemDirectoryReader,
1152) -> Vec<web_sys::FileSystemEntry> {
1153 let p = js_sys::Promise::new(&mut |resolve, reject| {
1154 let ok = Closure::once_into_js(move |arr: JsValue| {
1155 let _ = resolve.call1(&JsValue::NULL, &arr);
1156 });
1157 let err = Closure::once_into_js(move |e: JsValue| {
1158 let _ = reject.call1(&JsValue::NULL, &e);
1159 });
1160 let _ =
1161 reader.read_entries_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
1162 });
1163 match wasm_bindgen_futures::JsFuture::from(p).await {
1164 Ok(arr) => js_sys::Array::from(&arr)
1165 .iter()
1166 // `unchecked_into`: Chromium has no global `FileSystemEntry`,
1167 // so an `instanceof` check (`dyn_into`) fails for every entry.
1168 .map(|v| v.unchecked_into())
1169 .collect(),
1170 Err(_) => Vec::new(),
1171 }
1172}
1173
1174// ---------------------------------------------------------------------------
1175// Calendars and address books
1176// ---------------------------------------------------------------------------
1177
1178fn enc(s: &str) -> String {
1179 js_sys::encode_uri_component(s).into()
1180}
1181
1182pub fn pim_collections()
1183-> impl std::future::Future<Output = Result<Vec<PimCollectionInfo>, ApiError>> {
1184 request("GET", PIM_COLLECTIONS.to_string(), None::<()>)
1185}
1186
1187pub fn pim_create_collection(
1188 body: CreatePimCollection,
1189) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
1190 request("POST", PIM_COLLECTIONS.to_string(), Some(body))
1191}
1192
1193pub fn pim_update_collection(
1194 id: i64,
1195 body: UpdatePimCollection,
1196) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
1197 request("PUT", format!("{PIM_COLLECTIONS}/{id}"), Some(body))
1198}
1199
1200/// Deletes an own collection, or ends the loan of a lent one.
1201pub fn pim_delete_collection(
1202 id: i64,
1203) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1204 request("DELETE", format!("{PIM_COLLECTIONS}/{id}"), None::<()>)
1205}
1206
1207pub fn pim_shares(
1208 id: i64,
1209) -> impl std::future::Future<Output = Result<Vec<PimShareInfo>, ApiError>> {
1210 request(
1211 "GET",
1212 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
1213 None::<()>,
1214 )
1215}
1216
1217/// Lends a collection, or changes the mode of a loan.
1218pub fn pim_share(
1219 id: i64,
1220 user: &str,
1221 mode: PimShareMode,
1222) -> impl std::future::Future<Output = Result<PimShareInfo, ApiError>> {
1223 request(
1224 "POST",
1225 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
1226 Some(CreatePimShare {
1227 user: user.to_string(),
1228 mode,
1229 }),
1230 )
1231}
1232
1233pub fn pim_unshare(
1234 id: i64,
1235 user_id: i64,
1236) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1237 request(
1238 "DELETE",
1239 format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}"),
1240 None::<()>,
1241 )
1242}
1243
1244pub fn pim_links(id: i64) -> impl std::future::Future<Output = Result<Vec<PimLinkInfo>, ApiError>> {
1245 request(
1246 "GET",
1247 format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
1248 None::<()>,
1249 )
1250}
1251
1252pub fn pim_create_link(
1253 id: i64,
1254 body: CreatePimLink,
1255) -> impl std::future::Future<Output = Result<PimLinkInfo, ApiError>> {
1256 request(
1257 "POST",
1258 format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
1259 Some(body),
1260 )
1261}
1262
1263pub fn pim_delete_link(
1264 id: i64,
1265 link_id: i64,
1266) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1267 request(
1268 "DELETE",
1269 format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}"),
1270 None::<()>,
1271 )
1272}
1273
1274/// Imports a file of a root into a collection.
1275pub fn pim_import(
1276 id: i64,
1277 root_id: i64,
1278 path: &str,
1279) -> impl std::future::Future<Output = Result<PimImportResult, ApiError>> {
1280 request(
1281 "POST",
1282 format!("{PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}"),
1283 Some(PimRootFile {
1284 root_id,
1285 path: path.to_string(),
1286 }),
1287 )
1288}
1289
1290/// Downloads a collection as one `.ics` or `.vcf` file.
1291pub fn pim_export_url(id: i64) -> String {
1292 format!("{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}")
1293}
1294
1295/// Saves a collection as a new file into a root.
1296pub fn pim_export_to_root(
1297 id: i64,
1298 root_id: i64,
1299 path: &str,
1300) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1301 request(
1302 "POST",
1303 pim_export_url(id),
1304 Some(PimRootFile {
1305 root_id,
1306 path: path.to_string(),
1307 }),
1308 )
1309}
1310
1311pub fn pim_preview(
1312 root_id: i64,
1313 path: &str,
1314 tz: &str,
1315) -> impl std::future::Future<Output = Result<PimPreview, ApiError>> {
1316 request(
1317 "GET",
1318 format!(
1319 "{PIM_PREVIEW}?{P_ROOT}={root_id}&{P_PATH}={}&{P_TZ}={}",
1320 enc(path),
1321 enc(tz)
1322 ),
1323 None::<()>,
1324 )
1325}
1326
1327pub fn list_rooms() -> impl std::future::Future<Output = Result<Vec<RoomInfo>, ApiError>> {
1328 request("GET", ADMIN_ROOMS.to_string(), None::<()>)
1329}
1330
1331pub fn create_room(
1332 name: &str,
1333 display_name: &str,
1334 kind: RoomKind,
1335) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
1336 request(
1337 "POST",
1338 ADMIN_ROOMS.to_string(),
1339 Some(CreateRoom {
1340 name: name.to_string(),
1341 display_name: Some(display_name.to_string()).filter(|d| !d.is_empty()),
1342 kind,
1343 }),
1344 )
1345}
1346
1347pub fn update_room(
1348 id: i64,
1349 display_name: &str,
1350) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
1351 request(
1352 "PUT",
1353 format!("{ADMIN_ROOMS}/{id}"),
1354 Some(UpdateRoom {
1355 display_name: display_name.to_string(),
1356 }),
1357 )
1358}
1359
1360pub fn delete_room(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1361 request("DELETE", format!("{ADMIN_ROOMS}/{id}"), None::<()>)
1362}
1363
1364/// Admin only: every public calendar and address book feed.
1365pub fn admin_pim_links() -> impl std::future::Future<Output = Result<Vec<AdminPimLink>, ApiError>> {
1366 request("GET", ADMIN_PIM_LINKS.to_string(), None::<()>)
1367}
1368
1369pub fn admin_delete_pim_link(
1370 id: i64,
1371) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
1372 request("DELETE", format!("{ADMIN_PIM_LINKS}/{id}"), None::<()>)
1373}
1374
1375// ---------------------------------------------------------------------------
1376// Low-level request helpers
1377// ---------------------------------------------------------------------------
1378
1379async fn request<T: DeserializeOwned>(
1380 method: &str,
1381 url: String,
1382 body: Option<impl Serialize>,
1383) -> Result<T, ApiError> {
1384 let opts = web_sys::RequestInit::new();
1385 opts.set_method(method);
1386 opts.set_mode(web_sys::RequestMode::SameOrigin);
1387 if let Some(body) = body {
1388 let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
1389 opts.set_body_opt_str(Some(&json));
1390 let headers = web_sys::Headers::new().expect("Headers constructor failed");
1391 let _ = headers.set("Content-Type", "application/json");
1392 opts.set_headers_headers(&headers);
1393 }
1394
1395 let resp = fetch_checked(&url, &opts, "request failed").await?;
1396 read_json(&resp).await
1397}
1398
1399/// Run one fetch and return the response, turning any non-2xx status into
1400/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
1401/// message. Callers that need the raw response (text, a header, or nothing at
1402/// all) use this directly; JSON callers go through [`request`].
1403async fn fetch_checked(
1404 url: &str,
1405 opts: &web_sys::RequestInit,
1406 fallback_msg: &str,
1407) -> Result<web_sys::Response, ApiError> {
1408 let window =
1409 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
1410 let req = web_sys::Request::new_with_str_and_init(url, opts)
1411 .map_err(|e| ApiError::Net(js_msg(&e)))?;
1412
1413 let promise = window.fetch_with_request(&req);
1414 // `fetch` only rejects when no response arrived at all (server down,
1415 // connection lost, blocked): one short localized line instead of the
1416 // JS stack.
1417 let resp_val = JsFuture::from(promise).await.map_err(|_| {
1418 ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string())
1419 })?;
1420 let resp: web_sys::Response = resp_val
1421 .dyn_into()
1422 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
1423
1424 let status = resp.status();
1425 if !(200..300).contains(&status) {
1426 return Err(parse_error_body(&resp)
1427 .await
1428 .into_error(status, fallback_msg));
1429 }
1430 Ok(resp)
1431}
1432
1433/// Read a response body as text and parse it with serde_json.
1434///
1435/// Going through text rather than `Response::json()` keeps one JSON
1436/// implementation in play. It also keeps the server's 64-bit integers exact:
1437/// a detour through a JS value would round file sizes through an f64.
1438async fn read_json<T: DeserializeOwned>(resp: &web_sys::Response) -> Result<T, ApiError> {
1439 let text = response_text(resp)
1440 .await
1441 .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
1442 serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}")))
1443}
1444
1445async fn response_text(resp: &web_sys::Response) -> Option<String> {
1446 JsFuture::from(resp.text().ok()?).await.ok()?.as_string()
1447}
1448
1449/// Parse the server's error JSON (message + optional conflict list).
1450/// An unparseable body yields the default, and the caller's fallback message.
1451async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
1452 response_text(resp)
1453 .await
1454 .and_then(|t| serde_json::from_str(&t).ok())
1455 .unwrap_or_default()
1456}
1457
1458// ---------------------------------------------------------------------------
1459// Search (streamed)
1460// ---------------------------------------------------------------------------
1461
1462/// Start a search and stream its results as they are found.
1463///
1464/// [`web_sys::EventSource`] is the platform's SSE client: it frames the
1465/// stream and parses the events. `on_event` runs once per event on the main
1466/// thread; `.close()` on the returned source stops the search (the server
1467/// notices the dropped connection and unwinds its walk).
1468///
1469/// A stream that ends or dies mid-way simply stops, without a `done` event.
1470/// An `EventSource` cannot read the server's JSON error body, so a rejected
1471/// request reports one generic message.
1472pub fn search_stream(
1473 q: String,
1474 scope: &str,
1475 root: i64,
1476 // `path`: folder inside the root to start in ("" = the whole root).
1477 path: &str,
1478 on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
1479 // A plain closure, not a `Callback`: the caller may run from a render
1480 // closure whose owner is disposed on the next re-render, which would
1481 // dispose a `Callback` created there before the stream fails.
1482 on_error: impl Fn(String) + 'static,
1483) -> Result<web_sys::EventSource, ApiError> {
1484 let url = format!(
1485 "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}",
1486 js_sys::encode_uri_component(&q),
1487 js_sys::encode_uri_component(path),
1488 );
1489 let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?;
1490
1491 // The server always ends a search with `Done`. `error` fires after that
1492 // for the normal end of the stream too (a reconnect pending), so the flag
1493 // tells a finished search from a dropped or refused connection.
1494 let done = std::rc::Rc::new(std::cell::Cell::new(false));
1495 let done2 = done.clone();
1496 let on_msg =
1497 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
1498 let Some(data) = ev.data().as_string() else {
1499 return;
1500 };
1501 if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(&data) {
1502 if matches!(ev, api_types::SearchEvent::Done { .. }) {
1503 done2.set(true);
1504 }
1505 on_event.run(ev);
1506 }
1507 });
1508 src.set_onmessage(Some(on_msg.as_ref().unchecked_ref()));
1509 on_msg.forget();
1510
1511 // A reconnect would re-run the whole search, so close the source either
1512 // way. `CLOSED` means the server answered and rejected the request (401,
1513 // 403, 400); anything else with no `Done` is a lost connection.
1514 let s = src.clone();
1515 let on_err = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1516 let rejected = s.ready_state() == web_sys::EventSource::CLOSED;
1517 s.close();
1518 if done.get() {
1519 return;
1520 }
1521 let key = if rejected {
1522 crate::i18n::k::SEARCH_FAILED
1523 } else {
1524 crate::i18n::k::SERVER_UNREACHABLE
1525 };
1526 on_error(crate::i18n::t(key).to_string());
1527 });
1528 src.set_onerror(Some(on_err.as_ref().unchecked_ref()));
1529 on_err.forget();
1530
1531 Ok(src)
1532}
1533
1534/// Blank an input, so a password does not sit in the DOM waiting for the next
1535/// person at the keyboard.
1536pub fn clear_input(id: &str) {
1537 if let Some(el) = web_sys::window()
1538 .and_then(|w| w.document())
1539 .and_then(|d| d.get_element_by_id(id))
1540 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
1541 {
1542 el.set_value("");
1543 }
1544}
1545
1546/// Read a form input's value by element id.
1547pub fn input_value(id: &str) -> String {
1548 web_sys::window()
1549 .and_then(|w| w.document())
1550 .and_then(|d| {
1551 d.get_element_by_id(id)
1552 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
1553 })
1554 .map(|i| i.value())
1555 .unwrap_or_default()
1556}
1557