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//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}` — who it can be lent to
7//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
8//! - `GET {PIM_SHARES}` — the own feed links and loans
9//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection
10//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
11//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
12//! - `POST {PIM_IMPORT_NEW}` — import a file as a new collection
13//! - `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download
14//! - `GET {PIM_SYSTEM_EXPORT}` — the same for the system address book
15//!
16//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo
17//!
18//! Public: `GET {FEED}/{token}` — a collection as one file.
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use api_types::{
24 AdminPimLink, CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp,
25 PimCollectionInfo, PimCollectionKind, PimImportNew, PimImportResult, PimLend, PimLinkInfo,
26 PimOwnShares, PimShareCandidate, PimShareInfo, PimShareMode, PimSkipped, UpdatePimCollection,
27};
28use axum::Json;
29use axum::body::Body;
30use axum::extract::{Path as AxumPath, Query, State};
31use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
32use axum::http::{HeaderMap, StatusCode};
33use axum::response::{IntoResponse, Response};
34use pimdav::bundle::{self, Detail};
35use pimdav::principal::UserType;
36use pimdav::{contact, object};
37use sha2::{Digest, Sha256};
38
39use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
40use crate::api::dav::challenge;
41use crate::api::files::disposition;
42use crate::api::pim::{
43 BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, MAX_COLLECTIONS, MAX_DESCRIPTION,
44 MAX_DISPLAYNAME, MAX_RESOURCE_SIZE, OUTBOX, SHARED_PREFIX, collection_href, delete_own,
45 etag_of, generated, mailto, members_of, valid_text,
46};
47use crate::api::pim_schedule::{self, Directory, object_name};
48use crate::api::pim_views;
49use crate::auth;
50use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace, User};
51use crate::error::{ApiError, AppState};
52
53/// The largest file an import reads.
54const MAX_IMPORT: usize = 20 * 1024 * 1024;
55
56/// Largest total an import may split into. Each object carries a copy of
57/// the time zones it names.
58const MAX_SPLIT: usize = 128 * 1024 * 1024;
59
60/// How many skipped objects an import names.
61const MAX_SKIPPED: usize = 100;
62
63pub(super) fn wire_kind(kind: PimKind) -> PimCollectionKind {
64 match kind {
65 PimKind::Calendar => PimCollectionKind::Calendar,
66 PimKind::AddressBook => PimCollectionKind::Addressbook,
67 }
68}
69
70fn name_of(c: &PimCollection) -> String {
71 c.displayname.clone().unwrap_or_else(|| c.slug.clone())
72}
73
74/// A collection as `GET {PIM_COLLECTIONS}` lists it.
75fn info(
76 c: &PimCollection,
77 kind: PimKind,
78 url: String,
79 owner: &str,
80 mode: Option<PimShareMode>,
81) -> PimCollectionInfo {
82 PimCollectionInfo {
83 id: c.id,
84 kind: wire_kind(kind),
85 name: name_of(c),
86 url,
87 owner: owner.to_string(),
88 mode,
89 generated: generated(c.id),
90 color: c.color.clone(),
91 description: c.description.clone(),
92 components: c
93 .components
94 .split(',')
95 .filter(|s| !s.is_empty())
96 .map(str::to_string)
97 .collect(),
98 transparent: c.transparent,
99 is_default: false,
100 shares: 0,
101 links: 0,
102 }
103}
104
105/// GET {PIM_COLLECTIONS}
106pub async fn list(
107 State(state): State<Arc<AppState>>,
108 auth: SessionUser,
109) -> Result<Json<Vec<PimCollectionInfo>>, ApiError> {
110 let me = &auth.user;
111 let pid = state.db.principal_of(me.id).await?;
112 state.db.pim_ensure_defaults(pid).await?;
113 let default = state
114 .db
115 .pim_calendar_for(pid, "VEVENT")
116 .await?
117 .map(|c| c.id);
118 let counts = state.db.pim_share_counts(pid).await?;
119 let mut out = Vec::new();
120 for kind in [PimKind::Calendar, PimKind::AddressBook] {
121 for c in state.db.pim_collections(pid, kind).await? {
122 if kind == PimKind::Calendar && c.slug == INBOX {
123 continue;
124 }
125 let url = collection_href(&me.name, kind, &c.slug, None);
126 let (shares, links) = counts.get(&c.id).copied().unwrap_or_default();
127 out.push(PimCollectionInfo {
128 is_default: default == Some(c.id),
129 shares,
130 links,
131 ..info(&c, kind, url, &me.name, None)
132 });
133 }
134 let (slug, generated) = match kind {
135 PimKind::Calendar => (BIRTHDAYS_SLUG, generated_info(BIRTHDAYS)),
136 PimKind::AddressBook => (DIRECTORY_SLUG, generated_info(DIRECTORY)),
137 };
138 let url = collection_href(&me.name, kind, slug, None);
139 out.push(info(&generated, kind, url, &me.name, None));
140 for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? {
141 let url = collection_href(&me.name, kind, &c.slug, Some(c.id));
142 out.push(info(&c, kind, url, &owner, Some(mode)));
143 }
144 }
145 Ok(Json(out))
146}
147
148/// The generated collections are gray, so they never look like one of the
149/// user's own. Keep it out of the web UI's palette.
150const GENERATED_COLOR: &str = "#94a3b8";
151
152/// A generated collection without its members, which listing it needs
153/// not build.
154fn generated_info(id: i64) -> PimCollection {
155 match id {
156 BIRTHDAYS => PimCollection {
157 id,
158 slug: BIRTHDAYS_SLUG.to_string(),
159 displayname: Some("Birthdays".to_string()),
160 color: Some(GENERATED_COLOR.to_string()),
161 components: "VEVENT".to_string(),
162 transparent: true,
163 ..Default::default()
164 },
165 _ => PimCollection {
166 id,
167 slug: DIRECTORY_SLUG.to_string(),
168 displayname: Some("Directory".to_string()),
169 color: Some(GENERATED_COLOR.to_string()),
170 ..Default::default()
171 },
172 }
173}
174
175fn db_kind(kind: PimCollectionKind) -> PimKind {
176 match kind {
177 PimCollectionKind::Calendar => PimKind::Calendar,
178 PimCollectionKind::Addressbook => PimKind::AddressBook,
179 }
180}
181
182/// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`.
183fn valid_color(c: &str) -> bool {
184 c.strip_prefix('#')
185 .is_some_and(|h| [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()))
186}
187
188fn bad_request(msg: &str) -> ApiError {
189 ApiError::new(StatusCode::BAD_REQUEST, msg)
190}
191
192/// A URL segment from a display name: ASCII letters, digits and dashes.
193fn slug_of(name: &str, kind: PimKind) -> String {
194 let mut slug = String::new();
195 for c in name.chars().flat_map(char::to_lowercase) {
196 match c {
197 'a'..='z' | '0'..='9' => slug.push(c),
198 _ if !slug.ends_with('-') && !slug.is_empty() => slug.push('-'),
199 _ => {}
200 }
201 }
202 let slug: String = slug.trim_end_matches('-').chars().take(40).collect();
203 match slug.trim_end_matches('-') {
204 "" => match kind {
205 PimKind::Calendar => "calendar".to_string(),
206 PimKind::AddressBook => "contacts".to_string(),
207 },
208 s => s.to_string(),
209 }
210}
211
212/// POST {PIM_COLLECTIONS}
213pub async fn create(
214 State(state): State<Arc<AppState>>,
215 auth: SessionUser,
216 Json(body): Json<CreatePimCollection>,
217) -> Result<Json<PimCollectionInfo>, ApiError> {
218 let color = body.color.filter(|c| !c.trim().is_empty());
219 if color.as_deref().is_some_and(|c| !valid_color(c)) {
220 return Err(bad_request("invalid color"));
221 }
222 let info = create_collection(
223 &state,
224 &auth.user,
225 db_kind(body.kind),
226 &body.name,
227 color,
228 body.description.filter(|d| !d.trim().is_empty()),
229 &body.components,
230 )
231 .await?;
232 Ok(Json(info))
233}
234
235/// A new own collection, with a slug made from its name.
236async fn create_collection(
237 state: &AppState,
238 me: &User,
239 kind: PimKind,
240 name: &str,
241 color: Option<String>,
242 description: Option<String>,
243 components: &[String],
244) -> Result<PimCollectionInfo, ApiError> {
245 let pid = state.db.principal_of(me.id).await?;
246 let name = name.trim();
247 if name.is_empty() {
248 return Err(bad_request("a name is required"));
249 }
250 if !valid_text(name, MAX_DISPLAYNAME, false) {
251 return Err(bad_request("invalid name"));
252 }
253 if description
254 .as_deref()
255 .is_some_and(|d| !valid_text(d, MAX_DESCRIPTION, true))
256 {
257 return Err(bad_request("invalid description"));
258 }
259 if state.db.pim_collections(pid, kind).await?.len() >= MAX_COLLECTIONS {
260 return Err(ApiError::new(StatusCode::FORBIDDEN, "too many collections"));
261 }
262 let components = match kind {
263 PimKind::Calendar if components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
264 PimKind::Calendar => {
265 let comps: Vec<String> = components
266 .iter()
267 .map(|c| c.trim().to_ascii_uppercase())
268 .collect();
269 if !comps
270 .iter()
271 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()))
272 {
273 return Err(bad_request("unknown component type"));
274 }
275 comps.join(",")
276 }
277 PimKind::AddressBook => String::new(),
278 };
279 let base = slug_of(name, kind);
280 let reserved = |s: &str| {
281 s.starts_with(SHARED_PREFIX) || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&s)
282 };
283 let mut col = PimCollection {
284 displayname: Some(name.to_string()),
285 description,
286 color,
287 components,
288 ..Default::default()
289 };
290 for n in 1..100 {
291 let slug = match n {
292 1 if !reserved(&base) => base.clone(),
293 1 => continue,
294 n => format!("{base}-{n}"),
295 };
296 col.slug = slug.clone();
297 if state.db.pim_create_collection(pid, kind, &col, &[]).await? {
298 let c = state
299 .db
300 .pim_collection(pid, kind, &slug)
301 .await?
302 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
303 let url = collection_href(&me.name, kind, &slug, None);
304 return Ok(info(&c, kind, url, &me.name, None));
305 }
306 }
307 Err(ApiError::new(StatusCode::CONFLICT, "no free name"))
308}
309
310/// PUT {PIM_COLLECTIONS}/{id}
311pub async fn update(
312 State(state): State<Arc<AppState>>,
313 auth: SessionUser,
314 AxumPath(id): AxumPath<i64>,
315 Json(body): Json<UpdatePimCollection>,
316) -> Result<Json<PimCollectionInfo>, ApiError> {
317 let id = own(&state, &auth, id).await?;
318 let (_, kind, mut col) = state
319 .db
320 .pim_collection_by_id(id)
321 .await?
322 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
323 let before = col.clone();
324 if let Some(name) = body.name {
325 let name = name.trim();
326 if name.is_empty() {
327 return Err(bad_request("a name is required"));
328 }
329 if !valid_text(name, MAX_DISPLAYNAME, false) {
330 return Err(bad_request("invalid name"));
331 }
332 col.displayname = Some(name.to_string());
333 }
334 if let Some(color) = body.color {
335 let color = color.trim();
336 if !color.is_empty() && !valid_color(color) {
337 return Err(bad_request("invalid color"));
338 }
339 col.color = (!color.is_empty()).then(|| color.to_string());
340 }
341 if let Some(d) = body.description {
342 if !valid_text(&d, MAX_DESCRIPTION, true) {
343 return Err(bad_request("invalid description"));
344 }
345 col.description = (!d.trim().is_empty()).then(|| d.trim().to_string());
346 }
347 if let Some(t) = body.transparent {
348 if kind != PimKind::Calendar {
349 return Err(bad_request("transparent needs a calendar"));
350 }
351 col.transparent = t;
352 }
353 state
354 .db
355 .pim_patch(PropPlace::Collection(id), Some((&before, &col)), &[], &[])
356 .await?;
357 let url = collection_href(&auth.user.name, kind, &col.slug, None);
358 Ok(Json(info(&col, kind, url, &auth.user.name, None)))
359}
360
361/// DELETE {PIM_COLLECTIONS}/{id}: an own collection, or the loan of a lent
362/// one.
363pub async fn delete(
364 State(state): State<Arc<AppState>>,
365 auth: SessionUser,
366 AxumPath(id): AxumPath<i64>,
367) -> Result<Json<OkResp>, ApiError> {
368 let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
369 let pid = state.db.principal_of(auth.user.id).await?;
370 if generated(id) {
371 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
372 }
373 if owner != pid {
374 let _lock = pim_schedule::LOCK.lock().await;
375 state.db.pim_remove_share(id, auth.user.id).await?;
376 return Ok(Json(OkResp {}));
377 }
378 match delete_own(&state, pid, kind, &col).await? {
379 Ok(()) => Ok(Json(OkResp {})),
380 Err(_) => Err(ApiError::localized(
381 StatusCode::CONFLICT,
382 "the calendar that receives invitations cannot be deleted",
383 "err_default_calendar",
384 )),
385 }
386}
387
388/// The id of a collection the signed-in user owns, or 404.
389async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
390 let pid = state.db.principal_of(auth.user.id).await?;
391 match state.db.pim_collection_by_id(id).await? {
392 // The inbox is not lent: it holds messages, not events.
393 Some((owner, _, c)) if owner == pid && c.slug != INBOX => Ok(id),
394 _ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
395 }
396}
397
398/// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
399pub async fn shares(
400 State(state): State<Arc<AppState>>,
401 auth: SessionUser,
402 AxumPath(id): AxumPath<i64>,
403) -> Result<Json<Vec<PimShareInfo>>, ApiError> {
404 let id = own(&state, &auth, id).await?;
405 let out = state
406 .db
407 .pim_shares(id)
408 .await?
409 .into_iter()
410 .map(|(user_id, user_name, mode)| PimShareInfo {
411 user_id,
412 user_name,
413 mode,
414 })
415 .collect();
416 Ok(Json(out))
417}
418
419/// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}
420///
421/// Every signed-in user already sees all accounts in principal search and
422/// the system address book, so listing them here reveals nothing new.
423pub async fn share_candidates(
424 State(state): State<Arc<AppState>>,
425 auth: SessionUser,
426 AxumPath(id): AxumPath<i64>,
427) -> Result<Json<Vec<PimShareCandidate>>, ApiError> {
428 let id = own(&state, &auth, id).await?;
429 let out = state
430 .db
431 .pim_share_candidates(id, auth.user.id)
432 .await?
433 .into_iter()
434 .map(|(name, display_name)| PimShareCandidate { name, display_name })
435 .collect();
436 Ok(Json(out))
437}
438
439/// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
440pub async fn share(
441 State(state): State<Arc<AppState>>,
442 auth: SessionUser,
443 AxumPath(id): AxumPath<i64>,
444 Json(body): Json<CreatePimShare>,
445) -> Result<Json<PimShareInfo>, ApiError> {
446 // PUT checks the access again under LOCK, so a narrower share applies at
447 // once. Under it, the collection cannot go before the share is written.
448 let _lock = pim_schedule::LOCK.lock().await;
449 let id = own(&state, &auth, id).await?;
450 let name = body.user.trim();
451 let found = match state.db.pim_principal(name).await? {
452 Some(p) => p.user_id.map(|uid| (uid, p.name)),
453 // The lookup hides disabled accounts. Their loans still take a new mode.
454 None => state
455 .db
456 .pim_shares(id)
457 .await?
458 .into_iter()
459 .find(|(_, n, _)| n == name)
460 .map(|(uid, n, _)| (uid, n)),
461 };
462 let Some((user_id, user_name)) = found else {
463 return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
464 };
465 if user_id == auth.user.id {
466 return Err(ApiError::new(
467 StatusCode::BAD_REQUEST,
468 "a collection cannot be shared with its owner",
469 ));
470 }
471 state.db.pim_set_share(id, user_id, body.mode).await?;
472 Ok(Json(PimShareInfo {
473 user_id,
474 user_name,
475 mode: body.mode,
476 }))
477}
478
479/// DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}
480pub async fn unshare(
481 State(state): State<Arc<AppState>>,
482 auth: SessionUser,
483 AxumPath((id, user_id)): AxumPath<(i64, i64)>,
484) -> Result<Json<OkResp>, ApiError> {
485 let id = own(&state, &auth, id).await?;
486 let _lock = pim_schedule::LOCK.lock().await;
487 if !state.db.pim_remove_share(id, user_id).await? {
488 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
489 }
490 Ok(Json(OkResp {}))
491}
492
493/// A collection the signed-in user may read: its owner principal, kind, the
494/// collection, and whether they may also write it. The inbox is not one.
495pub(super) async fn reachable(
496 state: &AppState,
497 auth: &SessionUser,
498 id: i64,
499) -> Result<(i64, PimKind, PimCollection, bool), ApiError> {
500 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "collection not found");
501 let pid = state.db.principal_of(auth.user.id).await?;
502 if generated(id) {
503 let (kind, col) = match id {
504 BIRTHDAYS => (PimKind::Calendar, generated_info(BIRTHDAYS)),
505 DIRECTORY => (PimKind::AddressBook, generated_info(DIRECTORY)),
506 _ => return Err(not_found()),
507 };
508 return Ok((pid, kind, col, false));
509 }
510 let (owner, kind, c) = state
511 .db
512 .pim_collection_by_id(id)
513 .await?
514 .ok_or_else(not_found)?;
515 if c.slug == INBOX {
516 return Err(not_found());
517 }
518 if owner == pid {
519 return Ok((owner, kind, c, true));
520 }
521 match state
522 .db
523 .pim_shared_collection(auth.user.id, kind, id)
524 .await?
525 {
526 Some((_, _, mode)) => Ok((owner, kind, c, mode != PimShareMode::Ro)),
527 None => Err(not_found()),
528 }
529}
530
531/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}
532///
533/// Always a WebP thumbnail, never the stored bytes: those come from a client
534/// and could be HTML or SVG with script. Without a thumbnail cache it is made
535/// on each request; a matching ETag still skips the decode.
536pub async fn photo(
537 State(state): State<Arc<AppState>>,
538 auth: SessionUser,
539 AxumPath((id, name)): AxumPath<(i64, String)>,
540 headers: HeaderMap,
541) -> Result<Response, ApiError> {
542 let no_photo = || ApiError::new(StatusCode::NOT_FOUND, "no photo");
543 let (_, kind, _, _) = reachable(&state, &auth, id).await?;
544 if kind != PimKind::AddressBook {
545 return Err(no_photo());
546 }
547 let (obj, data) = state.db.pim_object(id, &name).await?.ok_or_else(no_photo)?;
548 let cached = [
549 (ETAG, obj.etag.clone()),
550 (CACHE_CONTROL, "private, no-cache".to_string()),
551 ];
552 if headers.get(IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(obj.etag.as_str()) {
553 return Ok((StatusCode::NOT_MODIFIED, cached).into_response());
554 }
555 let image = contact::photo(&String::from_utf8_lossy(&data)).ok_or_else(no_photo)?;
556 let bytes = match &state.thumbs {
557 Some(thumbs) => {
558 thumbs
559 .of_bytes(&format!("pim-photo {}", obj.etag), image)
560 .await
561 }
562 None => crate::thumb::of_image(image).await,
563 }
564 .ok_or_else(no_photo)?;
565 Ok((cached, [(CONTENT_TYPE, "image/webp")], bytes).into_response())
566}
567
568fn extension(kind: PimKind) -> &'static str {
569 match kind {
570 PimKind::Calendar => "ics",
571 PimKind::AddressBook => "vcf",
572 }
573}
574
575pub(super) fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
576 PimLinkInfo {
577 id: link.id,
578 path: format!("{FEED}/{}.{}", link.token, extension(kind)),
579 busy_only: link.busy_only,
580 created_at: link.created_at.clone(),
581 expires_at: link.expires_at.clone(),
582 has_password: link.password_hash.is_some(),
583 }
584}
585
586pub fn feed_entry(r: crate::db::PimLinkWithOwner) -> AdminPimLink {
587 AdminPimLink {
588 link: link_info(&r.link, r.kind),
589 collection_id: r.link.collection_id,
590 collection_name: r.collection_name,
591 kind: wire_kind(r.kind),
592 owner_id: r.owner_id,
593 owner_name: r.owner_name,
594 owner_active: r.owner_active,
595 }
596}
597
598/// GET {PIM_SHARES}
599pub async fn own_shares(
600 State(state): State<Arc<AppState>>,
601 auth: SessionUser,
602) -> Result<Json<PimOwnShares>, ApiError> {
603 let links = state.db.pim_links_with_owner(Some(auth.user.id)).await?;
604 let lends = state.db.pim_lends(auth.user.id).await?;
605 Ok(Json(PimOwnShares {
606 links: links.into_iter().map(feed_entry).collect(),
607 lends: lends
608 .into_iter()
609 .map(
610 |(collection_id, collection_name, kind, user_id, user_name, mode)| PimLend {
611 collection_id,
612 collection_name,
613 kind: wire_kind(kind),
614 share: PimShareInfo {
615 user_id,
616 user_name,
617 mode,
618 },
619 },
620 )
621 .collect(),
622 }))
623}
624
625/// GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
626pub async fn links(
627 State(state): State<Arc<AppState>>,
628 auth: SessionUser,
629 AxumPath(id): AxumPath<i64>,
630) -> Result<Json<Vec<PimLinkInfo>>, ApiError> {
631 let id = own(&state, &auth, id).await?;
632 let (_, kind, _) = state
633 .db
634 .pim_collection_by_id(id)
635 .await?
636 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
637 let links = state.db.pim_links(id).await?;
638 Ok(Json(links.iter().map(|l| link_info(l, kind)).collect()))
639}
640
641/// POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
642pub async fn create_link(
643 State(state): State<Arc<AppState>>,
644 auth: SessionUser,
645 AxumPath(id): AxumPath<i64>,
646 Json(body): Json<CreatePimLink>,
647) -> Result<Json<PimLinkInfo>, ApiError> {
648 let id = own(&state, &auth, id).await?;
649 let (_, kind, _) = state
650 .db
651 .pim_collection_by_id(id)
652 .await?
653 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
654 if body.busy_only && kind != PimKind::Calendar {
655 return Err(ApiError::new(
656 StatusCode::BAD_REQUEST,
657 "busy_only needs a calendar",
658 ));
659 }
660 // As for shares: an unparseable expiry would never expire.
661 if let Some(e) = &body.expires_at
662 && chrono::DateTime::parse_from_rfc3339(e).is_err()
663 {
664 return Err(ApiError::localized(
665 StatusCode::BAD_REQUEST,
666 "expires_at must be an RFC 3339 timestamp",
667 "err_bad_expires_at",
668 ));
669 }
670 let password_hash = match body.password.as_deref().map(str::trim) {
671 Some(pw) if !pw.is_empty() => {
672 validate_password(pw)?;
673 Some(hash_password(pw).await?)
674 }
675 _ => None,
676 };
677 let link = state
678 .db
679 .pim_create_link(
680 id,
681 &auth::short_token(),
682 body.busy_only,
683 body.expires_at.as_deref(),
684 password_hash.as_deref(),
685 )
686 .await?;
687 Ok(Json(link_info(&link, kind)))
688}
689
690/// DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}
691pub async fn delete_link(
692 State(state): State<Arc<AppState>>,
693 auth: SessionUser,
694 AxumPath((id, link_id)): AxumPath<(i64, i64)>,
695) -> Result<Json<OkResp>, ApiError> {
696 let id = own(&state, &auth, id).await?;
697 if !state.db.pim_delete_link(id, link_id).await? {
698 return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
699 }
700 Ok(Json(OkResp {}))
701}
702
703/// GET {FEED}/{token}
704pub async fn feed(
705 State(state): State<Arc<AppState>>,
706 AxumPath(file): AxumPath<String>,
707 headers: HeaderMap,
708) -> Result<Response, ApiError> {
709 let token = file
710 .strip_suffix(".ics")
711 .or_else(|| file.strip_suffix(".vcf"))
712 .unwrap_or(&file);
713 let Some(link) = state.db.pim_link_by_token(token).await? else {
714 return Ok(StatusCode::NOT_FOUND.into_response());
715 };
716 if link.is_expired() {
717 return Ok(StatusCode::GONE.into_response());
718 }
719 // Basic with the user name ignored, like a protected share mount.
720 if let Some(hash) = link.password_hash.clone() {
721 let Some((_, password)) = auth::basic_credentials(&headers) else {
722 return Ok(challenge());
723 };
724 // The hash is of the trimmed password, as for file shares.
725 let password = password.trim();
726 let (pw, id, tok) = (password.to_string(), link.id, link.token.clone());
727 // A negative realm: share ids are positive, and one share's password
728 // must never open a feed with the same id.
729 let ok = auth::verify_cached(-link.id, "", password, move || async move {
730 auth::throttle(&tok).await;
731 let ok = auth::verify_password_async(&pw, &hash).await;
732 auth::record_login(&tok, ok);
733 ok.then_some(id)
734 })
735 .await;
736 if ok.is_none() {
737 return Ok(challenge());
738 }
739 }
740 let Some((owner, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
741 return Ok(StatusCode::NOT_FOUND.into_response());
742 };
743 let etag = format!(
744 "\"feed-{}-{}{}\"",
745 col.id,
746 col.seq,
747 if link.busy_only { "-busy" } else { "" }
748 );
749 let unchanged = headers
750 .get(IF_NONE_MATCH)
751 .and_then(|v| v.to_str().ok())
752 .is_some_and(|v| {
753 v.split(',')
754 .map(|t| t.trim().trim_start_matches("W/"))
755 .any(|t| t == etag || t == "*")
756 });
757 if unchanged {
758 return Ok((StatusCode::NOT_MODIFIED, [(ETAG, etag)]).into_response());
759 }
760 let detail = match link.busy_only {
761 true => Detail::Busy,
762 false => Detail::Public,
763 };
764 let body = render(&state, owner, kind, &col, detail).await?;
765 Ok((
766 [
767 (CONTENT_TYPE, mime(kind).to_string()),
768 (ETAG, etag),
769 (CACHE_CONTROL, "no-cache".to_string()),
770 ],
771 body,
772 )
773 .into_response())
774}
775
776fn mime(kind: PimKind) -> &'static str {
777 match kind {
778 PimKind::Calendar => "text/calendar; charset=utf-8",
779 PimKind::AddressBook => "text/vcard; charset=utf-8",
780 }
781}
782
783async fn render(
784 state: &AppState,
785 owner: i64,
786 kind: PimKind,
787 col: &PimCollection,
788 detail: Detail,
789) -> Result<String, ApiError> {
790 let objects = members_of(state, owner, col.id).await?;
791 let name = name_of(col);
792 blocking(move || -> Result<String, ApiError> {
793 let texts: Vec<String> = objects
794 .into_iter()
795 .map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
796 .collect();
797 let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
798 Ok(match kind {
799 PimKind::Calendar => bundle::calendar(&texts, Some(&name), detail),
800 PimKind::AddressBook => bundle::cards(&texts),
801 })
802 })
803 .await
804}
805
806/// GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}
807pub async fn export(
808 State(state): State<Arc<AppState>>,
809 auth: SessionUser,
810 AxumPath(id): AxumPath<i64>,
811) -> Result<Response, ApiError> {
812 let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
813 let body = render(&state, owner, kind, &col, Detail::All).await?;
814 Ok(download(kind, &name_of(&col), body))
815}
816
817/// GET {PIM_SYSTEM_EXPORT}
818pub async fn export_system(
819 State(state): State<Arc<AppState>>,
820 _auth: SessionUser,
821) -> Result<Response, ApiError> {
822 let (col, body) = system_cards(&state).await?;
823 Ok(download(PimKind::AddressBook, &name_of(&col), body))
824}
825
826async fn system_cards(state: &AppState) -> Result<(PimCollection, String), ApiError> {
827 let col = crate::api::pim::directory_collection(state).await?;
828 let members = crate::api::pim::directory(state).await?;
829 let texts: Vec<String> = members
830 .into_iter()
831 .map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
832 .collect();
833 let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
834 Ok((col, bundle::cards(&texts)))
835}
836
837fn download(kind: PimKind, name: &str, body: String) -> Response {
838 let file = format!("{}.{}", name.replace(['/', '\\'], "_"), extension(kind));
839 (
840 [
841 (CONTENT_TYPE, mime(kind).to_string()),
842 (CONTENT_DISPOSITION, disposition("attachment", &file)),
843 ],
844 body,
845 )
846 .into_response()
847}
848
849/// POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}
850///
851/// Each object goes through the checks of a PUT and is skipped where a PUT
852/// would fail. An object whose UID the collection already has replaces it.
853/// Nothing is sent to attendees or organizers.
854pub async fn import(
855 State(state): State<Arc<AppState>>,
856 auth: SessionUser,
857 AxumPath(id): AxumPath<i64>,
858 body: Body,
859) -> Result<Json<PimImportResult>, ApiError> {
860 let (_, kind, col, writable) = reachable(&state, &auth, id).await?;
861 if !writable {
862 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
863 }
864 let text = read_import(body).await?;
865 let parts = split_import(kind, &text)?;
866 Ok(Json(import_parts(&state, &auth, kind, &col, parts).await?))
867}
868
869#[derive(serde::Deserialize)]
870pub struct ImportNewQuery {
871 kind: PimCollectionKind,
872 name: Option<String>,
873 file: Option<String>,
874 color: Option<String>,
875}
876
877/// POST {PIM_IMPORT_NEW}: a file as a new collection. Its name comes from the
878/// request, else from the file's own name for itself, else from the file
879/// name. When nothing can be imported, the collection is removed again.
880pub async fn import_new(
881 State(state): State<Arc<AppState>>,
882 auth: SessionUser,
883 Query(q): Query<ImportNewQuery>,
884 body: Body,
885) -> Result<Json<PimImportNew>, ApiError> {
886 let kind = db_kind(q.kind);
887 let text = read_import(body).await?;
888 let parts = split_import(kind, &text)?;
889 let (own_name, own_color) = match kind {
890 PimKind::Calendar => bundle::calendar_meta(&text),
891 PimKind::AddressBook => (None, None),
892 };
893 let nonempty = |s: Option<String>| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
894 // A name from the file that cannot be stored falls back to the next one.
895 let usable = |s: Option<String>| nonempty(s).filter(|s| valid_text(s, MAX_DISPLAYNAME, false));
896 let stem = q
897 .file
898 .map(|f| f.rsplit_once('.').map_or(f.clone(), |(s, _)| s.to_string()));
899 let name = nonempty(q.name)
900 .or(usable(own_name))
901 .or(usable(stem))
902 .ok_or_else(|| bad_request("a name is required"))?;
903 // COLOR may be a CSS color name, which the web UI cannot show.
904 let color = own_color
905 .filter(|c| valid_color(c))
906 .or(q.color.filter(|c| valid_color(c)));
907 let pid = state.db.principal_of(auth.user.id).await?;
908 state.db.pim_ensure_defaults(pid).await?;
909 let info = create_collection(&state, &auth.user, kind, &name, color, None, &[]).await?;
910 let (_, _, col) = state
911 .db
912 .pim_collection_by_id(info.id)
913 .await?
914 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
915 let result = import_parts(&state, &auth, kind, &col, parts).await;
916 let keep = matches!(&result, Ok(r) if r.created + r.updated > 0);
917 if !keep {
918 // Empty and never lent or synced: nothing to cancel, nobody to tell.
919 if delete_own(&state, pid, kind, &col).await?.is_err() {
920 return Err(ApiError::new(
921 StatusCode::CONFLICT,
922 "the empty collection could not be removed",
923 ));
924 }
925 }
926 Ok(Json(PimImportNew {
927 collection: keep.then_some(info),
928 result: result?,
929 }))
930}
931
932async fn read_import(body: Body) -> Result<String, ApiError> {
933 let data = axum::body::to_bytes(body, MAX_IMPORT)
934 .await
935 .map_err(|_| ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large"))?
936 .to_vec();
937 // Old phone exports are often Latin-1.
938 Ok(String::from_utf8(data)
939 .unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect()))
940}
941
942/// One text per resource of an import file.
943fn split_import(kind: PimKind, text: &str) -> Result<Vec<String>, ApiError> {
944 // From the content, so importing the same file twice updates.
945 let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string();
946 let parts = match kind {
947 PimKind::Calendar => {
948 bundle::split_calendar(text, &mut new_uid, MAX_SPLIT).ok_or_else(|| {
949 ApiError::new(
950 StatusCode::PAYLOAD_TOO_LARGE,
951 "the file splits into too much data",
952 )
953 })?
954 }
955 PimKind::AddressBook => bundle::split_cards(text, &mut new_uid),
956 };
957 if parts.is_empty() {
958 return Err(ApiError::new(
959 StatusCode::BAD_REQUEST,
960 "the file holds no calendar or address objects",
961 ));
962 }
963 Ok(parts)
964}
965
966/// Each part is stored as a PUT would store it, scheduling included. A part
967/// a PUT would refuse is skipped.
968async fn import_parts(
969 state: &AppState,
970 auth: &SessionUser,
971 kind: PimKind,
972 col: &PimCollection,
973 parts: Vec<String>,
974) -> Result<PimImportResult, ApiError> {
975 let supported: Vec<String> = col.components.split(',').map(str::to_string).collect();
976 let checked = blocking(move || -> Result<_, ApiError> {
977 let supported: Vec<&str> = supported.iter().map(String::as_str).collect();
978 let now = chrono::Utc::now();
979 Ok(parts
980 .into_iter()
981 .map(|part| check_part(kind, &supported, now, part))
982 .collect::<Vec<_>>())
983 })
984 .await?;
985
986 let _lock = pim_schedule::LOCK.lock().await;
987 // The collection or the share may have gone while the file was checked.
988 let (owner, _, _, writable) = reachable(state, auth, col.id).await?;
989 if !writable {
990 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
991 }
992 let may_schedule = pim_views::may_answer(state, auth, owner, col.id).await?;
993 let me = state.db.principal_of(auth.user.id).await?;
994 let dir = Directory::load(state).await?;
995 let owner = dir
996 .get(owner)
997 .cloned()
998 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
999 let w = pim_schedule::Writer {
1000 owner: &owner,
1001 may_schedule,
1002 sent_by: (owner.id != me)
1003 .then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
1004 };
1005 let mut result = PimImportResult {
1006 created: 0,
1007 updated: 0,
1008 skipped_total: 0,
1009 skipped: Vec::new(),
1010 };
1011 let mut skip = |uid: Option<String>, reason: &str| {
1012 result.skipped_total += 1;
1013 if result.skipped.len() < MAX_SKIPPED {
1014 result.skipped.push(PimSkipped {
1015 uid,
1016 reason: reason.to_string(),
1017 });
1018 }
1019 };
1020 // Names given in this import, so a UID seen twice updates its first copy.
1021 let mut names: HashMap<String, String> = HashMap::new();
1022 let mut ops = Vec::new();
1023 let (mut created, mut updated) = (0, 0);
1024 for part in checked {
1025 let (uid, component, data) = match part {
1026 Ok(v) => v,
1027 Err((uid, reason)) => {
1028 skip(uid, &reason);
1029 continue;
1030 }
1031 };
1032 let existing = match names.get(&uid) {
1033 Some(name) => {
1034 // Scheduling reads the stored copy.
1035 state.db.pim_apply(&std::mem::take(&mut ops)).await?;
1036 Some(name.clone())
1037 }
1038 None => state.db.pim_uid_holder(col.id, &uid, "").await?,
1039 };
1040 let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind));
1041 let stored = match kind {
1042 PimKind::Calendar => {
1043 let old = match &existing {
1044 Some(n) => state.db.pim_object(col.id, n).await?.map(|(_, d)| d),
1045 None => None,
1046 };
1047 let at = (col.id, name.as_str());
1048 match pim_schedule::put(state, &dir, &w, at, old.as_deref(), &data).await? {
1049 Ok(s) => s,
1050 Err(condition) => {
1051 skip(Some(uid), &condition.name);
1052 continue;
1053 }
1054 }
1055 }
1056 PimKind::AddressBook => pim_schedule::Stored {
1057 data,
1058 changed: false,
1059 schedule_tag: None,
1060 ops: Vec::new(),
1061 },
1062 };
1063 match existing {
1064 Some(_) => updated += 1,
1065 None => created += 1,
1066 }
1067 names.insert(uid.clone(), name.clone());
1068 ops.push(PimOp::Put {
1069 collection_id: col.id,
1070 obj: PimObject {
1071 name,
1072 uid,
1073 component,
1074 etag: etag_of(&stored.data),
1075 schedule_tag: stored.schedule_tag,
1076 ..Default::default()
1077 },
1078 data: stored.data,
1079 });
1080 // Later parts see the copies and room bookings this one wrote.
1081 if !stored.ops.is_empty() {
1082 ops.extend(stored.ops);
1083 state.db.pim_apply(&std::mem::take(&mut ops)).await?;
1084 }
1085 }
1086 state.db.pim_apply(&ops).await?;
1087 result.created = created;
1088 result.updated = updated;
1089 Ok(result)
1090}
1091
1092/// A skipped import part: its UID if readable, and the reason.
1093type Skip = (Option<String>, String);
1094
1095/// One import part as `(uid, component, data)`, or why it is skipped.
1096fn check_part(
1097 kind: PimKind,
1098 supported: &[&str],
1099 now: chrono::DateTime<chrono::Utc>,
1100 part: String,
1101) -> Result<(String, String, Vec<u8>), Skip> {
1102 // Read from the raw text when the object does not parse as a whole.
1103 let raw_uid = |part: &str| {
1104 part.lines()
1105 .find_map(|l| l.strip_prefix("UID:"))
1106 .map(|u| u.trim().to_string())
1107 };
1108 if part.len() > MAX_RESOURCE_SIZE {
1109 return Err((raw_uid(&part), "max-resource-size".into()));
1110 }
1111 let checked = match kind {
1112 PimKind::Calendar => {
1113 object::calendar(part.as_bytes(), supported).map(|o| (o.uid, o.component.to_string()))
1114 }
1115 PimKind::AddressBook => {
1116 object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
1117 }
1118 };
1119 let (uid, component) = checked.map_err(|invalid| (raw_uid(&part), invalid.condition().name))?;
1120 let data = match kind {
1121 PimKind::Calendar => {
1122 object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
1123 }
1124 PimKind::AddressBook => part.into_bytes(),
1125 };
1126 Ok((uid, component, data))
1127}
1128