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