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