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