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