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