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