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