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