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