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