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