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