pim_api.rs
⎇
Raw
1//! JSON management of calendars and address books (session-authenticated):
2//! - `GET`, `POST {PIM_COLLECTIONS}` — own, lent and generated; a new one
3//! - `PUT`, `DELETE {PIM_COLLECTIONS}/{id}` — change or delete one, or end its loan
4//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection
5//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
6//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
7//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection
8//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
9//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
10//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root
11//! - `GET`, `POST {PIM_SYSTEM_EXPORT}` — the same for the system address book
12//!
13//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo
14//!
15//! Public: `GET {FEED}/{token}` — a collection as one file.
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use api_types::{
21 CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo,
22 PimCollectionKind, PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode,
23 PimSkipped, UpdatePimCollection,
24};
25use axum::Json;
26use axum::body::Body;
27use axum::extract::{Path as AxumPath, State};
28use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
29use axum::http::{HeaderMap, StatusCode};
30use axum::response::{IntoResponse, Response};
31use pimdav::bundle::{self, Detail};
32use pimdav::{contact, object};
33use sha2::{Digest, Sha256};
34
35use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
36use crate::api::dav::challenge;
37use crate::api::files::{disposition, find_root, require_rw_root};
38use crate::api::pim::{
39 BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, OUTBOX, SHARED_PREFIX,
40 collection_href, delete_own, etag_of, generated, members_of,
41};
42use crate::api::pim_schedule::{self, Directory, object_name};
43use crate::auth;
44use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace};
45use crate::error::{ApiError, AppState};
46use crate::fs;
47
48/// The largest file an import reads.
49const MAX_IMPORT: usize = 20 * 1024 * 1024;
50
51/// How many skipped objects an import names.
52const MAX_SKIPPED: usize = 100;
53
54pub(super) fn wire_kind(kind: PimKind) -> PimCollectionKind {
55 match kind {
56 PimKind::Calendar => PimCollectionKind::Calendar,
57 PimKind::AddressBook => PimCollectionKind::Addressbook,
58 }
59}
60
61fn name_of(c: &PimCollection) -> String {
62 c.displayname.clone().unwrap_or_else(|| c.slug.clone())
63}
64
65/// A collection as `GET {PIM_COLLECTIONS}` lists it.
66fn info(
67 c: &PimCollection,
68 kind: PimKind,
69 url: String,
70 owner: &str,
71 mode: Option<PimShareMode>,
72) -> PimCollectionInfo {
73 PimCollectionInfo {
74 id: c.id,
75 kind: wire_kind(kind),
76 name: name_of(c),
77 url,
78 owner: owner.to_string(),
79 mode,
80 generated: generated(c.id),
81 color: c.color.clone(),
82 description: c.description.clone(),
83 components: c
84 .components
85 .split(',')
86 .filter(|s| !s.is_empty())
87 .map(str::to_string)
88 .collect(),
89 transparent: c.transparent,
90 is_default: false,
91 shares: 0,
92 links: 0,
93 }
94}
95
96/// GET {PIM_COLLECTIONS}
97pub async fn list(
98 State(state): State<Arc<AppState>>,
99 auth: SessionUser,
100) -> Result<Json<Vec<PimCollectionInfo>>, ApiError> {
101 let me = &auth.user;
102 let pid = state.db.principal_of(me.id).await?;
103 state.db.pim_ensure_defaults(pid).await?;
104 let default = state
105 .db
106 .pim_calendar_for(pid, "VEVENT")
107 .await?
108 .map(|c| c.id);
109 let mut out = Vec::new();
110 for kind in [PimKind::Calendar, PimKind::AddressBook] {
111 for c in state.db.pim_collections(pid, kind).await? {
112 if kind == PimKind::Calendar && c.slug == INBOX {
113 continue;
114 }
115 let url = collection_href(&me.name, kind, &c.slug, None);
116 out.push(PimCollectionInfo {
117 is_default: default == Some(c.id),
118 shares: state.db.pim_shares(c.id).await?.len(),
119 links: state.db.pim_links(c.id).await?.len(),
120 ..info(&c, kind, url, &me.name, None)
121 });
122 }
123 let (slug, generated) = match kind {
124 PimKind::Calendar => (BIRTHDAYS_SLUG, generated_info(BIRTHDAYS)),
125 PimKind::AddressBook => (DIRECTORY_SLUG, generated_info(DIRECTORY)),
126 };
127 let url = collection_href(&me.name, kind, slug, None);
128 out.push(info(&generated, kind, url, &me.name, None));
129 for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? {
130 let url = collection_href(&me.name, kind, &c.slug, Some(c.id));
131 out.push(info(&c, kind, url, &owner, Some(mode)));
132 }
133 }
134 Ok(Json(out))
135}
136
137/// A generated collection without its members, which listing it needs
138/// not build.
139fn generated_info(id: i64) -> PimCollection {
140 match id {
141 BIRTHDAYS => PimCollection {
142 id,
143 slug: BIRTHDAYS_SLUG.to_string(),
144 displayname: Some("Birthdays".to_string()),
145 components: "VEVENT".to_string(),
146 transparent: true,
147 ..Default::default()
148 },
149 _ => PimCollection {
150 id,
151 slug: DIRECTORY_SLUG.to_string(),
152 displayname: Some("Directory".to_string()),
153 ..Default::default()
154 },
155 }
156}
157
158fn db_kind(kind: PimCollectionKind) -> PimKind {
159 match kind {
160 PimCollectionKind::Calendar => PimKind::Calendar,
161 PimCollectionKind::Addressbook => PimKind::AddressBook,
162 }
163}
164
165/// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`.
166fn valid_color(c: &str) -> bool {
167 c.strip_prefix('#')
168 .is_some_and(|h| [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()))
169}
170
171fn bad_request(msg: &str) -> ApiError {
172 ApiError::new(StatusCode::BAD_REQUEST, msg)
173}
174
175/// A URL segment from a display name: ASCII letters, digits and dashes.
176fn slug_of(name: &str, kind: PimKind) -> String {
177 let mut slug = String::new();
178 for c in name.chars().flat_map(char::to_lowercase) {
179 match c {
180 'a'..='z' | '0'..='9' => slug.push(c),
181 _ if !slug.ends_with('-') && !slug.is_empty() => slug.push('-'),
182 _ => {}
183 }
184 }
185 let slug: String = slug.trim_end_matches('-').chars().take(40).collect();
186 match slug.trim_end_matches('-') {
187 "" => match kind {
188 PimKind::Calendar => "calendar".to_string(),
189 PimKind::AddressBook => "contacts".to_string(),
190 },
191 s => s.to_string(),
192 }
193}
194
195/// POST {PIM_COLLECTIONS}
196pub async fn create(
197 State(state): State<Arc<AppState>>,
198 auth: SessionUser,
199 Json(body): Json<CreatePimCollection>,
200) -> Result<Json<PimCollectionInfo>, ApiError> {
201 let me = &auth.user;
202 let pid = state.db.principal_of(me.id).await?;
203 let kind = db_kind(body.kind);
204 let name = body.name.trim();
205 if name.is_empty() {
206 return Err(bad_request("a name is required"));
207 }
208 let color = body.color.filter(|c| !c.trim().is_empty());
209 if color.as_deref().is_some_and(|c| !valid_color(c)) {
210 return Err(bad_request("invalid color"));
211 }
212 let components = match kind {
213 PimKind::Calendar if body.components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
214 PimKind::Calendar => {
215 let comps: Vec<String> = body
216 .components
217 .iter()
218 .map(|c| c.trim().to_ascii_uppercase())
219 .collect();
220 if !comps
221 .iter()
222 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()))
223 {
224 return Err(bad_request("unknown component type"));
225 }
226 comps.join(",")
227 }
228 PimKind::AddressBook => String::new(),
229 };
230 let base = slug_of(name, kind);
231 let reserved = |s: &str| {
232 s.starts_with(SHARED_PREFIX) || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&s)
233 };
234 let mut col = PimCollection {
235 displayname: Some(name.to_string()),
236 description: body.description.filter(|d| !d.trim().is_empty()),
237 color,
238 components,
239 ..Default::default()
240 };
241 for n in 1..100 {
242 let slug = match n {
243 1 if !reserved(&base) => base.clone(),
244 1 => continue,
245 n => format!("{base}-{n}"),
246 };
247 col.slug = slug.clone();
248 if state.db.pim_create_collection(pid, kind, &col, &[]).await? {
249 let c = state
250 .db
251 .pim_collection(pid, kind, &slug)
252 .await?
253 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
254 let url = collection_href(&me.name, kind, &slug, None);
255 return Ok(Json(info(&c, kind, url, &me.name, None)));
256 }
257 }
258 Err(ApiError::new(StatusCode::CONFLICT, "no free name"))
259}
260
261/// PUT {PIM_COLLECTIONS}/{id}
262pub async fn update(
263 State(state): State<Arc<AppState>>,
264 auth: SessionUser,
265 AxumPath(id): AxumPath<i64>,
266 Json(body): Json<UpdatePimCollection>,
267) -> Result<Json<PimCollectionInfo>, ApiError> {
268 let id = own(&state, &auth, id).await?;
269 let (_, kind, mut col) = state
270 .db
271 .pim_collection_by_id(id)
272 .await?
273 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
274 if let Some(name) = body.name {
275 let name = name.trim();
276 if name.is_empty() {
277 return Err(bad_request("a name is required"));
278 }
279 col.displayname = Some(name.to_string());
280 }
281 if let Some(color) = body.color {
282 let color = color.trim();
283 if !color.is_empty() && !valid_color(color) {
284 return Err(bad_request("invalid color"));
285 }
286 col.color = (!color.is_empty()).then(|| color.to_string());
287 }
288 if let Some(d) = body.description {
289 col.description = (!d.trim().is_empty()).then(|| d.trim().to_string());
290 }
291 if let Some(t) = body.transparent {
292 if kind != PimKind::Calendar {
293 return Err(bad_request("transparent needs a calendar"));
294 }
295 col.transparent = t;
296 }
297 state
298 .db
299 .pim_patch(PropPlace::Collection(id), Some(&col), &[], &[])
300 .await?;
301 let url = collection_href(&auth.user.name, kind, &col.slug, None);
302 Ok(Json(info(&col, kind, url, &auth.user.name, None)))
303}
304
305/// DELETE {PIM_COLLECTIONS}/{id}: an own collection, or the loan of a lent
306/// one.
307pub async fn delete(
308 State(state): State<Arc<AppState>>,
309 auth: SessionUser,
310 AxumPath(id): AxumPath<i64>,
311) -> Result<Json<OkResp>, ApiError> {
312 let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
313 let pid = state.db.principal_of(auth.user.id).await?;
314 if generated(id) {
315 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
316 }
317 if owner != pid {
318 state.db.pim_remove_share(id, auth.user.id).await?;
319 return Ok(Json(OkResp {}));
320 }
321 match delete_own(&state, pid, kind, &col).await? {
322 Ok(()) => Ok(Json(OkResp {})),
323 Err(_) => Err(ApiError::localized(
324 StatusCode::CONFLICT,
325 "the calendar that receives invitations cannot be deleted",
326 "err_default_calendar",
327 )),
328 }
329}
330
331/// The id of a collection the signed-in user owns, or 404.
332async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
333 let pid = state.db.principal_of(auth.user.id).await?;
334 match state.db.pim_collection_by_id(id).await? {
335 // The inbox is not lent: it holds messages, not events.
336 Some((owner, _, c)) if owner == pid && c.slug != INBOX => Ok(id),
337 _ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
338 }
339}
340
341/// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
342pub async fn shares(
343 State(state): State<Arc<AppState>>,
344 auth: SessionUser,
345 AxumPath(id): AxumPath<i64>,
346) -> Result<Json<Vec<PimShareInfo>>, ApiError> {
347 let id = own(&state, &auth, id).await?;
348 let out = state
349 .db
350 .pim_shares(id)
351 .await?
352 .into_iter()
353 .map(|(user_id, user_name, mode)| PimShareInfo {
354 user_id,
355 user_name,
356 mode,
357 })
358 .collect();
359 Ok(Json(out))
360}
361
362/// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
363pub async fn share(
364 State(state): State<Arc<AppState>>,
365 auth: SessionUser,
366 AxumPath(id): AxumPath<i64>,
367 Json(body): Json<CreatePimShare>,
368) -> Result<Json<PimShareInfo>, ApiError> {
369 let id = own(&state, &auth, id).await?;
370 let user = state
371 .db
372 .pim_principal(body.user.trim())
373 .await?
374 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
375 let Some(user_id) = user.user_id else {
376 return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
377 };
378 if user_id == auth.user.id {
379 return Err(ApiError::new(
380 StatusCode::BAD_REQUEST,
381 "a collection cannot be shared with its owner",
382 ));
383 }
384 state.db.pim_set_share(id, user_id, body.mode).await?;
385 Ok(Json(PimShareInfo {
386 user_id,
387 user_name: user.name,
388 mode: body.mode,
389 }))
390}
391
392/// DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}
393pub async fn unshare(
394 State(state): State<Arc<AppState>>,
395 auth: SessionUser,
396 AxumPath((id, user_id)): AxumPath<(i64, i64)>,
397) -> Result<Json<OkResp>, ApiError> {
398 let id = own(&state, &auth, id).await?;
399 if !state.db.pim_remove_share(id, user_id).await? {
400 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
401 }
402 Ok(Json(OkResp {}))
403}
404
405/// A collection the signed-in user may read: its owner principal, kind, the
406/// collection, and whether they may also write it. The inbox is not one.
407pub(super) async fn reachable(
408 state: &AppState,
409 auth: &SessionUser,
410 id: i64,
411) -> Result<(i64, PimKind, PimCollection, bool), ApiError> {
412 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "collection not found");
413 let pid = state.db.principal_of(auth.user.id).await?;
414 if generated(id) {
415 let (kind, col) = match id {
416 BIRTHDAYS => (PimKind::Calendar, generated_info(BIRTHDAYS)),
417 DIRECTORY => (PimKind::AddressBook, generated_info(DIRECTORY)),
418 _ => return Err(not_found()),
419 };
420 return Ok((pid, kind, col, false));
421 }
422 let (owner, kind, c) = state
423 .db
424 .pim_collection_by_id(id)
425 .await?
426 .ok_or_else(not_found)?;
427 if c.slug == INBOX {
428 return Err(not_found());
429 }
430 if owner == pid {
431 return Ok((owner, kind, c, true));
432 }
433 match state
434 .db
435 .pim_shared_collection(auth.user.id, kind, id)
436 .await?
437 {
438 Some((_, _, mode)) => Ok((owner, kind, c, mode != PimShareMode::Ro)),
439 None => Err(not_found()),
440 }
441}
442
443/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}
444///
445/// Always a WebP thumbnail, never the stored bytes: those come from a client
446/// and could be HTML or SVG with script. Without a thumbnail cache it is made
447/// on each request; a matching ETag still skips the decode.
448pub async fn photo(
449 State(state): State<Arc<AppState>>,
450 auth: SessionUser,
451 AxumPath((id, name)): AxumPath<(i64, String)>,
452 headers: HeaderMap,
453) -> Result<Response, ApiError> {
454 let no_photo = || ApiError::new(StatusCode::NOT_FOUND, "no photo");
455 let (_, kind, _, _) = reachable(&state, &auth, id).await?;
456 if kind != PimKind::AddressBook {
457 return Err(no_photo());
458 }
459 let (obj, data) = state.db.pim_object(id, &name).await?.ok_or_else(no_photo)?;
460 let cached = [
461 (ETAG, obj.etag.clone()),
462 (CACHE_CONTROL, "private, no-cache".to_string()),
463 ];
464 if headers.get(IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(obj.etag.as_str()) {
465 return Ok((StatusCode::NOT_MODIFIED, cached).into_response());
466 }
467 let image = contact::photo(&String::from_utf8_lossy(&data)).ok_or_else(no_photo)?;
468 let bytes = match &state.thumbs {
469 Some(thumbs) => {
470 thumbs
471 .of_bytes(&format!("pim-photo {}", obj.etag), image)
472 .await
473 }
474 None => crate::thumb::of_image(image).await,
475 }
476 .ok_or_else(no_photo)?;
477 Ok((cached, [(CONTENT_TYPE, "image/webp")], bytes).into_response())
478}
479
480fn extension(kind: PimKind) -> &'static str {
481 match kind {
482 PimKind::Calendar => "ics",
483 PimKind::AddressBook => "vcf",
484 }
485}
486
487pub(super) fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
488 PimLinkInfo {
489 id: link.id,
490 path: format!("{FEED}/{}.{}", link.token, extension(kind)),
491 busy_only: link.busy_only,
492 created_at: link.created_at.clone(),
493 expires_at: link.expires_at.clone(),
494 has_password: link.password_hash.is_some(),
495 }
496}
497
498/// GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
499pub async fn links(
500 State(state): State<Arc<AppState>>,
501 auth: SessionUser,
502 AxumPath(id): AxumPath<i64>,
503) -> Result<Json<Vec<PimLinkInfo>>, ApiError> {
504 let id = own(&state, &auth, id).await?;
505 let (_, kind, _) = state
506 .db
507 .pim_collection_by_id(id)
508 .await?
509 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
510 let links = state.db.pim_links(id).await?;
511 Ok(Json(links.iter().map(|l| link_info(l, kind)).collect()))
512}
513
514/// POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
515pub async fn create_link(
516 State(state): State<Arc<AppState>>,
517 auth: SessionUser,
518 AxumPath(id): AxumPath<i64>,
519 Json(body): Json<CreatePimLink>,
520) -> Result<Json<PimLinkInfo>, ApiError> {
521 let id = own(&state, &auth, id).await?;
522 let (_, kind, _) = state
523 .db
524 .pim_collection_by_id(id)
525 .await?
526 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
527 if body.busy_only && kind != PimKind::Calendar {
528 return Err(ApiError::new(
529 StatusCode::BAD_REQUEST,
530 "busy_only needs a calendar",
531 ));
532 }
533 // As for shares: an unparseable expiry would never expire.
534 if let Some(e) = &body.expires_at
535 && chrono::DateTime::parse_from_rfc3339(e).is_err()
536 {
537 return Err(ApiError::localized(
538 StatusCode::BAD_REQUEST,
539 "expires_at must be an RFC 3339 timestamp",
540 "err_bad_expires_at",
541 ));
542 }
543 let password_hash = match body.password.as_deref().map(str::trim) {
544 Some(pw) if !pw.is_empty() => {
545 validate_password(pw)?;
546 Some(hash_password(pw).await?)
547 }
548 _ => None,
549 };
550 let link = state
551 .db
552 .pim_create_link(
553 id,
554 &auth::short_token(),
555 body.busy_only,
556 body.expires_at.as_deref(),
557 password_hash.as_deref(),
558 )
559 .await?;
560 Ok(Json(link_info(&link, kind)))
561}
562
563/// DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}
564pub async fn delete_link(
565 State(state): State<Arc<AppState>>,
566 auth: SessionUser,
567 AxumPath((id, link_id)): AxumPath<(i64, i64)>,
568) -> Result<Json<OkResp>, ApiError> {
569 let id = own(&state, &auth, id).await?;
570 if !state.db.pim_delete_link(id, link_id).await? {
571 return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
572 }
573 Ok(Json(OkResp {}))
574}
575
576/// GET {FEED}/{token}
577pub async fn feed(
578 State(state): State<Arc<AppState>>,
579 AxumPath(file): AxumPath<String>,
580 headers: HeaderMap,
581) -> Result<Response, ApiError> {
582 let token = file
583 .strip_suffix(".ics")
584 .or_else(|| file.strip_suffix(".vcf"))
585 .unwrap_or(&file);
586 let Some(link) = state.db.pim_link_by_token(token).await? else {
587 return Ok(StatusCode::NOT_FOUND.into_response());
588 };
589 if link.is_expired() {
590 return Ok(StatusCode::GONE.into_response());
591 }
592 // Basic with the user name ignored, like a protected share mount.
593 if let Some(hash) = link.password_hash.clone() {
594 let Some((_, password)) = auth::basic_credentials(&headers) else {
595 return Ok(challenge());
596 };
597 let (pw, id, tok) = (password.clone(), link.id, link.token.clone());
598 // A negative realm: share ids are positive, and one share's password
599 // must never open a feed with the same id.
600 let ok = auth::verify_cached(-link.id, "", &password, move || async move {
601 auth::throttle(&tok).await;
602 let ok = auth::verify_password_async(&pw, &hash).await;
603 auth::record_login(&tok, ok);
604 ok.then_some(id)
605 })
606 .await;
607 if ok.is_none() {
608 return Ok(challenge());
609 }
610 }
611 let Some((owner, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
612 return Ok(StatusCode::NOT_FOUND.into_response());
613 };
614 let etag = format!(
615 "\"feed-{}-{}{}\"",
616 col.id,
617 col.seq,
618 if link.busy_only { "-busy" } else { "" }
619 );
620 let unchanged = headers
621 .get(IF_NONE_MATCH)
622 .and_then(|v| v.to_str().ok())
623 .is_some_and(|v| {
624 v.split(',')
625 .map(|t| t.trim().trim_start_matches("W/"))
626 .any(|t| t == etag || t == "*")
627 });
628 if unchanged {
629 return Ok((StatusCode::NOT_MODIFIED, [(ETAG, etag)]).into_response());
630 }
631 let detail = match link.busy_only {
632 true => Detail::Busy,
633 false => Detail::Public,
634 };
635 let body = render(&state, owner, kind, &col, detail).await?;
636 Ok((
637 [
638 (CONTENT_TYPE, mime(kind).to_string()),
639 (ETAG, etag),
640 (CACHE_CONTROL, "no-cache".to_string()),
641 ],
642 body,
643 )
644 .into_response())
645}
646
647fn mime(kind: PimKind) -> &'static str {
648 match kind {
649 PimKind::Calendar => "text/calendar; charset=utf-8",
650 PimKind::AddressBook => "text/vcard; charset=utf-8",
651 }
652}
653
654async fn render(
655 state: &AppState,
656 owner: i64,
657 kind: PimKind,
658 col: &PimCollection,
659 detail: Detail,
660) -> Result<String, ApiError> {
661 let objects = members_of(state, owner, col.id).await?;
662 let texts: Vec<String> = objects
663 .into_iter()
664 .map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
665 .collect();
666 let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
667 Ok(match kind {
668 PimKind::Calendar => bundle::calendar(&texts, Some(&name_of(col)), detail),
669 PimKind::AddressBook => bundle::cards(&texts),
670 })
671}
672
673/// GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}
674pub async fn export(
675 State(state): State<Arc<AppState>>,
676 auth: SessionUser,
677 AxumPath(id): AxumPath<i64>,
678) -> Result<Response, ApiError> {
679 let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
680 let body = render(&state, owner, kind, &col, Detail::All).await?;
681 Ok(download(kind, &name_of(&col), body))
682}
683
684/// POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}: a new file in a writable root.
685pub async fn export_to_root(
686 State(state): State<Arc<AppState>>,
687 auth: SessionUser,
688 AxumPath(id): AxumPath<i64>,
689 Json(target): Json<PimRootFile>,
690) -> Result<Json<OkResp>, ApiError> {
691 let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
692 let body = render(&state, owner, kind, &col, Detail::All).await?;
693 save(&state, &auth, target, body).await
694}
695
696/// GET {PIM_SYSTEM_EXPORT}
697pub async fn export_system(
698 State(state): State<Arc<AppState>>,
699 _auth: SessionUser,
700) -> Result<Response, ApiError> {
701 let (col, body) = system_cards(&state).await?;
702 Ok(download(PimKind::AddressBook, &name_of(&col), body))
703}
704
705/// POST {PIM_SYSTEM_EXPORT}: a new file in a writable root.
706pub async fn export_system_to_root(
707 State(state): State<Arc<AppState>>,
708 auth: SessionUser,
709 Json(target): Json<PimRootFile>,
710) -> Result<Json<OkResp>, ApiError> {
711 let (_, body) = system_cards(&state).await?;
712 save(&state, &auth, target, body).await
713}
714
715async fn system_cards(state: &AppState) -> Result<(PimCollection, String), ApiError> {
716 let (col, members) = crate::api::pim::directory(state).await?;
717 let texts: Vec<String> = members
718 .into_iter()
719 .map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
720 .collect();
721 let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
722 Ok((col, bundle::cards(&texts)))
723}
724
725fn download(kind: PimKind, name: &str, body: String) -> Response {
726 let file = format!("{}.{}", name.replace(['/', '\\'], "_"), extension(kind));
727 (
728 [
729 (CONTENT_TYPE, mime(kind).to_string()),
730 (CONTENT_DISPOSITION, disposition("attachment", &file)),
731 ],
732 body,
733 )
734 .into_response()
735}
736
737async fn save(
738 state: &AppState,
739 auth: &SessionUser,
740 target: PimRootFile,
741 body: String,
742) -> Result<Json<OkResp>, ApiError> {
743 let root = require_rw_root(&auth.roots, target.root_id)?;
744 let (server_root, root_path) = (state.root.clone(), root.path.clone());
745 blocking(move || {
746 fs::create_file(&server_root, &root_path, &target.path)?;
747 fs::save_file(
748 &server_root,
749 &root_path,
750 &target.path,
751 body.as_bytes(),
752 None,
753 )
754 })
755 .await?;
756 Ok(Json(OkResp {}))
757}
758
759/// POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}
760///
761/// Each object goes through the checks of a PUT and is skipped where a PUT
762/// would fail. An object whose UID the collection already has replaces it.
763/// Nothing is sent to attendees or organizers.
764pub async fn import(
765 State(state): State<Arc<AppState>>,
766 auth: SessionUser,
767 AxumPath(id): AxumPath<i64>,
768 headers: HeaderMap,
769 body: Body,
770) -> Result<Json<PimImportResult>, ApiError> {
771 let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
772 if !writable {
773 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
774 }
775 let too_large = || ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large");
776 let json = headers
777 .get(CONTENT_TYPE)
778 .and_then(|v| v.to_str().ok())
779 .is_some_and(|t| t.starts_with("application/json"));
780 let data = if json {
781 let raw = axum::body::to_bytes(body, 64 * 1024)
782 .await
783 .map_err(|_| too_large())?;
784 let file: PimRootFile = serde_json::from_slice(&raw)
785 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid JSON body"))?;
786 read_root_file(&state, &auth, file).await?
787 } else {
788 axum::body::to_bytes(body, MAX_IMPORT)
789 .await
790 .map_err(|_| too_large())?
791 .to_vec()
792 };
793 // Old phone exports are often Latin-1.
794 let text = String::from_utf8(data)
795 .unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect());
796 // From the content, so importing the same file twice updates.
797 let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string();
798 let parts = match kind {
799 PimKind::Calendar => bundle::split_calendar(&text, &mut new_uid),
800 PimKind::AddressBook => bundle::split_cards(&text, &mut new_uid),
801 };
802 if parts.is_empty() {
803 return Err(ApiError::new(
804 StatusCode::BAD_REQUEST,
805 "the file holds no calendar or address objects",
806 ));
807 }
808
809 let _lock = pim_schedule::LOCK.lock().await;
810 let dir = Directory::load(&state).await?;
811 let owner = dir
812 .get(owner)
813 .cloned()
814 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
815 let supported: Vec<&str> = col.components.split(',').collect();
816 let now = chrono::Utc::now();
817 let mut result = PimImportResult {
818 created: 0,
819 updated: 0,
820 skipped_total: 0,
821 skipped: Vec::new(),
822 };
823 let mut skip = |uid: Option<String>, reason: &str| {
824 result.skipped_total += 1;
825 if result.skipped.len() < MAX_SKIPPED {
826 result.skipped.push(PimSkipped {
827 uid,
828 reason: reason.to_string(),
829 });
830 }
831 };
832 // Names given in this import, so a UID seen twice updates its first copy.
833 let mut names: HashMap<String, String> = HashMap::new();
834 let mut ops = Vec::new();
835 let (mut created, mut updated) = (0, 0);
836 for part in parts {
837 let checked = match kind {
838 PimKind::Calendar => object::calendar(part.as_bytes(), &supported)
839 .map(|o| (o.uid, o.component.to_string())),
840 PimKind::AddressBook => {
841 object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
842 }
843 };
844 let (uid, component) = match checked {
845 Ok(v) => v,
846 Err(invalid) => {
847 skip(None, &invalid.condition().name);
848 continue;
849 }
850 };
851 let data = match kind {
852 PimKind::Calendar => {
853 object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
854 }
855 PimKind::AddressBook => part.into_bytes(),
856 };
857 let existing = match names.get(&uid) {
858 Some(name) => Some(name.clone()),
859 None => state.db.pim_uid_holder(col.id, &uid, "").await?,
860 };
861 let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind));
862 let schedule_tag = match kind {
863 PimKind::Calendar => {
864 match pim_schedule::import_tag(&state, &dir, &owner, (col.id, &name), &data).await?
865 {
866 Ok(tag) => tag,
867 Err(condition) => {
868 skip(Some(uid), &condition.name);
869 continue;
870 }
871 }
872 }
873 PimKind::AddressBook => None,
874 };
875 match existing {
876 Some(_) => updated += 1,
877 None => created += 1,
878 }
879 names.insert(uid.clone(), name.clone());
880 ops.push(PimOp::Put {
881 collection_id: col.id,
882 obj: PimObject {
883 name,
884 uid,
885 component,
886 etag: etag_of(&data),
887 schedule_tag,
888 ..Default::default()
889 },
890 data,
891 });
892 }
893 state.db.pim_apply(&ops).await?;
894 result.created = created;
895 result.updated = updated;
896 Ok(Json(result))
897}
898
899pub(super) async fn read_root_file(
900 state: &AppState,
901 auth: &SessionUser,
902 file: PimRootFile,
903) -> Result<Vec<u8>, ApiError> {
904 let root = find_root(&auth.roots, file.root_id)?;
905 let (server_root, root_path) = (state.root.clone(), root.path.clone());
906 blocking(move || {
907 let full = fs::resolve_path(&server_root, &root_path, &file.path)?;
908 let meta = std::fs::metadata(&full)?;
909 if meta.is_dir() {
910 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
911 }
912 if meta.len() > MAX_IMPORT as u64 {
913 return Err(ApiError::new(
914 StatusCode::PAYLOAD_TOO_LARGE,
915 "file too large",
916 ));
917 }
918 Ok(std::fs::read(&full)?)
919 })
920 .await
921}
922