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