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