pim.rs
⎇
Raw
1//! CalDAV and CardDAV.
2//!
3//! URL layout under [`PIM`]:
4//!
5//! * `/principals/` and `/principals/{name}/`: accounts, rooms and resources
6//! * `/calendars/{name}/` and `/addressbooks/{name}/`, the homes
7//! * `/calendars/{name}/{collection}/` and `.../{collection}/{object}`, the
8//! same for address books
9//!
10//! A home also shows the collections lent to its account, as
11//! `shared-{collection id}`, and the address book home shows the generated
12//! system address book as `system`. The calendar home holds the scheduling
13//! `inbox` and `outbox`, and the generated `birthdays` calendar. A room's home
14//! holds its bookings.
15//!
16//! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto
17//! the store and assembles the responses.
18
19use std::sync::Arc;
20
21use api_types::PIM;
22use axum::body::Body;
23use axum::extract::State;
24use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION};
25use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
26use axum::response::IntoResponse;
27use percent_encoding::{
28 AsciiSet, CONTROLS, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode,
29};
30use pimdav::calcard::icalendar::ICalendar;
31use pimdav::calcard::vcard::VCard;
32use pimdav::principal::{self, Principal, Search, UserType};
33use pimdav::render::{self, TooManyInstances};
34use pimdav::report::{self, Props, Refused, Report};
35use pimdav::xml::{
36 self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr,
37 with_children, with_text,
38};
39use pimdav::zone::{self, Zone};
40use pimdav::{contact, filter, freebusy, object};
41
42use super::pim_schedule::{self, Directory, Stored, Writer};
43use sha2::{Digest, Sha256};
44use xmltree::Element;
45
46use crate::db::{
47 DeadProp, PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimShareMode, PimWrite,
48 Precondition, PropPlace, User,
49};
50use crate::error::{ApiError, AppState};
51
52/// Largest object a PUT may store. Contacts carry photos inline.
53const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
54
55/// Largest XML request body.
56const MAX_XML_SIZE: usize = 1024 * 1024;
57
58/// Largest client property the server stores without interpreting it, and
59/// the most one resource may hold.
60const MAX_DEAD_SIZE: usize = 64 * 1024;
61const MAX_DEAD_PROPS: usize = 100;
62
63/// The domain of the addresses users schedule with. `.invalid` is reserved
64/// (RFC 2606), so nothing sent there can reach anyone.
65pub(super) const MAIL_DOMAIN: &str = "dovenest.invalid";
66
67/// The ids of the generated collections, which no stored one has.
68pub(super) const DIRECTORY: i64 = 0;
69pub(super) const BIRTHDAYS: i64 = -1;
70pub(super) const DIRECTORY_SLUG: &str = "system";
71pub(super) const BIRTHDAYS_SLUG: &str = "birthdays";
72/// The slug prefix of a collection lent to the account.
73pub(super) const SHARED_PREFIX: &str = "shared-";
74/// The scheduling inbox is a stored calendar collection under this slug.
75pub(crate) const INBOX: &str = "inbox";
76/// The scheduling outbox holds nothing and is not stored.
77pub(crate) const OUTBOX: &str = "outbox";
78
79/// Characters escaped in an href segment.
80const SEGMENT: &AsciiSet = &CONTROLS
81 .add(b' ')
82 .add(b'"')
83 .add(b'#')
84 .add(b'%')
85 .add(b'/')
86 .add(b'<')
87 .add(b'>')
88 .add(b'?')
89 .add(b'[')
90 .add(b']')
91 .add(b'`')
92 .add(b'{')
93 .add(b'}');
94
95/// Characters a principal name keeps in the local part of its address. The
96/// rest is percent-encoded: `%` is valid there, `@` and spaces are not
97/// (RFC 5322, 3.2.3).
98const LOCAL: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
99/// The same without the dot, for names where a dot would lead, trail or
100/// repeat.
101const LOCAL_NO_DOT: &AsciiSet = &LOCAL.add(b'.');
102
103type Reply = Result<Response<Body>, ApiError>;
104
105/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
106///
107/// 307, not 301: HTTP clients drop the body on a 301, and python-caldav sends
108/// its principal search to the URL it was configured with.
109pub async fn well_known() -> Response<Body> {
110 (
111 StatusCode::TEMPORARY_REDIRECT,
112 [(LOCATION, format!("{PIM}/"))],
113 )
114 .into_response()
115}
116
117/// The `DAV` header of every response here. Apple Calendar looks for it on
118/// PROPFIND responses too, not only on OPTIONS.
119pub(super) const COMPLIANCE: &str =
120 "1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, extended-mkcol";
121
122/// `{PIM}` and everything under it.
123pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
124 let mut r = match super::dav::authenticate(&state, req.headers()).await {
125 Some((user_id, _)) => serve(&state, user_id, req)
126 .await
127 .unwrap_or_else(IntoResponse::into_response),
128 None => super::dav::challenge(),
129 };
130 r.headers_mut()
131 .insert("dav", axum::http::HeaderValue::from_static(COMPLIANCE));
132 r
133}
134
135/// The signed-in account.
136struct Me {
137 id: i64,
138 /// The account's principal, which owns its collections.
139 pid: i64,
140 admin: bool,
141 /// The scheduling address, for SENT-BY when acting for someone else.
142 address: String,
143 /// The own principal href. Spelled as the request spelled the name when
144 /// it named this account: a client that asked for `/ALICE/` must get
145 /// hrefs it recognises.
146 principal: String,
147}
148
149/// The principal whose URLs a request addresses: the signed-in account, or
150/// a room or resource. Another account's principal is readable too.
151struct Space {
152 id: i64,
153 /// The URL segment, as the request spelled it.
154 path: String,
155 display: String,
156 kind: UserType,
157 mine: bool,
158}
159
160impl Space {
161 fn principal(&self) -> String {
162 principal_href(&self.path)
163 }
164
165 fn home(&self, kind: PimKind) -> String {
166 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.path))
167 }
168
169 fn collection(&self, kind: PimKind, slug: &str) -> String {
170 format!("{}{}/", self.home(kind), seg(slug))
171 }
172
173 fn object(&self, kind: PimKind, slug: &str, name: &str) -> String {
174 format!("{}{}", self.collection(kind, slug), seg(name))
175 }
176}
177
178/// The URL of a principal.
179pub(crate) fn principal_href(name: &str) -> String {
180 format!("{PIM}/principals/{}/", seg(name))
181}
182
183/// The principal name of a principal URL, given as a path or a full URL.
184pub(super) fn principal_name(href: &str) -> Option<String> {
185 let path = match href.starts_with('/') {
186 true => href.to_string(),
187 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
188 };
189 match parse_target(path.strip_prefix(PIM)?)? {
190 Target::Principal(name) => Some(name),
191 _ => None,
192 }
193}
194
195/// The URL of a collection in the home of `user`, whether it owns it or
196/// has it lent (`lent_id`).
197pub(crate) fn collection_href(
198 user: &str,
199 kind: PimKind,
200 slug: &str,
201 lent_id: Option<i64>,
202) -> String {
203 let slug = match lent_id {
204 Some(id) => format!("{SHARED_PREFIX}{id}"),
205 None => slug.to_string(),
206 };
207 format!("{PIM}/{}/{}/{}/", kind_segment(kind), seg(user), seg(&slug))
208}
209
210/// What the signed-in account may do with a collection.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
212enum Access {
213 Read,
214 /// Change members, not the collection's own properties.
215 Write,
216 /// Also send scheduling messages as the owner.
217 Schedule,
218 Own,
219}
220
221/// A collection as the signed-in account sees it.
222struct Col {
223 /// `slug` and `displayname` as this account sees them.
224 c: PimCollection,
225 access: Access,
226 /// The principal href of the owner.
227 owner: String,
228}
229
230async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
231 let Some(user) = state.db.find_user_by_id(user_id).await? else {
232 return Ok(status(StatusCode::UNAUTHORIZED));
233 };
234 let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
235 let Some(target) = parse_target(path) else {
236 return Ok(status(StatusCode::NOT_FOUND));
237 };
238 let (me, space) = match resolve_space(state, &user, &target).await? {
239 Ok(v) => v,
240 Err(code) => return Ok(status(code)),
241 };
242 state.db.pim_ensure_defaults(me.pid).await?;
243
244 let method = req.method().clone();
245 let (parts, body) = req.into_parts();
246 let cx = Cx {
247 state,
248 me: &me,
249 space: space.as_ref(),
250 };
251 match method.as_str() {
252 "OPTIONS" => Ok(options(&target)),
253 "POST" => cx.post(&target, body).await,
254 "PROPFIND" => cx.propfind(&target, &parts.headers, body).await,
255 "PROPPATCH" => cx.proppatch(&target, body).await,
256 "MKCALENDAR" | "MKCOL" => cx.mkcol(&target, method.as_str(), body).await,
257 "GET" | "HEAD" => {
258 cx.get(&target, &parts.headers, method == Method::HEAD)
259 .await
260 }
261 "PUT" => cx.put(&target, &parts.headers, body).await,
262 "DELETE" => cx.delete(&target, &parts.headers).await,
263 "REPORT" => cx.report(&target, body).await,
264 "MOVE" => cx.move_object(&target, &parts.headers).await,
265 _ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
266 }
267}
268
269/// Who asks, and in whose URL space. Another account's space is off limits
270/// except for its principal.
271async fn resolve_space(
272 state: &AppState,
273 user: &User,
274 target: &Target,
275) -> Result<Result<(Me, Option<Space>), StatusCode>, ApiError> {
276 let mut me = Me {
277 id: user.id,
278 pid: state.db.principal_of(user.id).await?,
279 admin: user.is_admin,
280 address: format!("mailto:{}", mailto(&user.name, UserType::Individual)),
281 principal: principal_href(&user.name),
282 };
283 let Some(segment) = target.owner() else {
284 return Ok(Ok((me, None)));
285 };
286 if segment.eq_ignore_ascii_case(&user.name) {
287 me.principal = principal_href(segment);
288 let space = Space {
289 id: me.pid,
290 path: segment.to_string(),
291 display: user.name.clone(),
292 kind: UserType::Individual,
293 mine: true,
294 };
295 return Ok(Ok((me, Some(space))));
296 }
297 let Some(p) = state.db.pim_principal(segment).await? else {
298 return Ok(Err(StatusCode::NOT_FOUND));
299 };
300 if p.kind == UserType::Individual && !matches!(target, Target::Principal(_)) {
301 return Ok(Err(StatusCode::FORBIDDEN));
302 }
303 let space = Space {
304 id: p.id,
305 path: segment.to_string(),
306 display: p.display().to_string(),
307 kind: p.kind,
308 mine: false,
309 };
310 Ok(Ok((me, Some(space))))
311}
312
313#[derive(Debug)]
314enum Target {
315 Root,
316 Principals,
317 Principal(String),
318 Home(PimKind, String),
319 Collection(PimKind, String, String),
320 Object(PimKind, String, String, String),
321}
322
323impl Target {
324 fn owner(&self) -> Option<&str> {
325 match self {
326 Target::Root | Target::Principals => None,
327 Target::Principal(u)
328 | Target::Home(_, u)
329 | Target::Collection(_, u, _)
330 | Target::Object(_, u, _, _) => Some(u),
331 }
332 }
333}
334
335fn parse_target(path: &str) -> Option<Target> {
336 let segs = path
337 .split('/')
338 .filter(|s| !s.is_empty())
339 .map(|s| {
340 let s = percent_decode_str(s).decode_utf8().ok()?;
341 (s != "." && s != "..").then(|| s.into_owned())
342 })
343 .collect::<Option<Vec<_>>>()?;
344 let kind = |s: &str| match s {
345 "calendars" => Some(PimKind::Calendar),
346 "addressbooks" => Some(PimKind::AddressBook),
347 _ => None,
348 };
349 let mut it = segs.into_iter();
350 let Some(first) = it.next() else {
351 return Some(Target::Root);
352 };
353 let rest: Vec<String> = it.collect();
354 if first == "principals" {
355 let mut rest = rest.into_iter();
356 return match (rest.next(), rest.next()) {
357 (None, _) => Some(Target::Principals),
358 (Some(user), None) => Some(Target::Principal(user)),
359 _ => None,
360 };
361 }
362 let kind = kind(&first)?;
363 let mut rest = rest.into_iter();
364 Some(match (rest.next(), rest.next(), rest.next(), rest.next()) {
365 (Some(u), None, None, None) => Target::Home(kind, u),
366 (Some(u), Some(c), None, None) => Target::Collection(kind, u, c),
367 (Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o),
368 _ => return None,
369 })
370}
371
372fn kind_segment(kind: PimKind) -> &'static str {
373 match kind {
374 PimKind::Calendar => "calendars",
375 PimKind::AddressBook => "addressbooks",
376 }
377}
378
379fn kind_ns(kind: PimKind) -> &'static str {
380 match kind {
381 PimKind::Calendar => CALDAV,
382 PimKind::AddressBook => CARDDAV,
383 }
384}
385
386pub(super) fn seg(s: &str) -> String {
387 utf8_percent_encode(s, SEGMENT).to_string()
388}
389
390fn status(code: StatusCode) -> Response<Body> {
391 code.into_response()
392}
393
394fn xml_response(code: StatusCode, body: String) -> Response<Body> {
395 (
396 code,
397 [(CONTENT_TYPE, "application/xml; charset=utf-8")],
398 body,
399 )
400 .into_response()
401}
402
403/// A failed precondition, named in a `<d:error>` body.
404fn error(code: StatusCode, condition: Element) -> Response<Body> {
405 xml_response(code, xml::error(condition))
406}
407
408/// The condition for a lacking privilege on `href` (RFC 3744, 7.1.1).
409pub(super) fn need_privilege(href: &str, ns: &str, privilege: &str) -> Element {
410 with_children(
411 el(DAV, "need-privileges"),
412 [with_children(
413 el(DAV, "resource"),
414 [
415 with_text(el(DAV, "href"), href),
416 with_children(el(DAV, "privilege"), [el(ns, privilege)]),
417 ],
418 )],
419 )
420}
421
422fn denied(href: &str, privilege: &str) -> Response<Body> {
423 error(StatusCode::FORBIDDEN, need_privilege(href, DAV, privilege))
424}
425
426fn options(target: &Target) -> Response<Body> {
427 let outbox = matches!(target, Target::Collection(PimKind::Calendar, _, s) if s == OUTBOX);
428 let allow = match outbox {
429 true => "OPTIONS, PROPFIND, POST",
430 false => {
431 "OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT"
432 }
433 };
434 (StatusCode::OK, [(ALLOW.as_str(), allow)]).into_response()
435}
436
437async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
438 axum::body::to_bytes(body, limit).await.ok()
439}
440
441pub(super) fn etag_of(data: &[u8]) -> String {
442 format!("\"{}\"", crate::hex(&Sha256::digest(data)[..16]))
443}
444
445/// A stable UUID per principal, for the `urn:uuid:` calendar user address.
446pub(super) fn principal_uuid(id: i64) -> String {
447 let h = crate::hex(&Sha256::digest(format!("dovenest principal {id}"))[..16]);
448 format!(
449 "{}-{}-{}-{}-{}",
450 &h[..8],
451 &h[8..12],
452 &h[12..16],
453 &h[16..20],
454 &h[20..]
455 )
456}
457
458/// The scheduling address of a principal. Rooms and resources use their own
459/// subdomains, so no account name can take their address.
460pub(super) fn mailto(name: &str, kind: UserType) -> String {
461 let domain = match kind {
462 UserType::Individual => MAIL_DOMAIN.to_string(),
463 UserType::Room => format!("rooms.{MAIL_DOMAIN}"),
464 UserType::Resource => format!("resources.{MAIL_DOMAIN}"),
465 };
466 format!("{}@{domain}", local_part(name))
467}
468
469/// A principal name as the local part of an address. Decoding the percent
470/// escapes gives the name back.
471pub(super) fn local_part(name: &str) -> String {
472 let set = match name.starts_with('.') || name.ends_with('.') || name.contains("..") {
473 true => LOCAL_NO_DOT,
474 false => LOCAL,
475 };
476 utf8_percent_encode(name, set).to_string()
477}
478
479/// A principal as PROPFIND and the searches describe it.
480struct PrincipalView {
481 id: i64,
482 /// The URL segment.
483 path: String,
484 display: String,
485 kind: UserType,
486 /// The signed-in account itself.
487 me: bool,
488}
489
490impl PrincipalView {
491 fn of(p: &PimPrincipal, me: &Me) -> Self {
492 PrincipalView {
493 id: p.id,
494 path: p.name.clone(),
495 display: p.display().to_string(),
496 kind: p.kind,
497 me: p.id == me.pid,
498 }
499 }
500
501 /// Only the mailto address: Apple takes the first href in order unless
502 /// one is `preferred`, and an attendee matched by its principal URL gets
503 /// no reply buttons. Scheduling still accepts the principal URL and the
504 /// `urn:uuid:` form.
505 fn addresses(&self) -> Vec<String> {
506 vec![format!("mailto:{}", mailto(&self.path, self.kind))]
507 }
508}
509
510// ---------------------------------------------------------------------------
511// Collections and members
512// ---------------------------------------------------------------------------
513
514/// Whether a collection is generated rather than stored.
515pub(super) fn generated(id: i64) -> bool {
516 id <= DIRECTORY
517}
518
519/// A generated collection. Its members' ETags stand in for a change counter:
520/// any change to them changes the CTag and the sync token. Only the current
521/// token is valid, so a client resyncs after each change.
522fn generated_collection(
523 id: i64,
524 slug: &str,
525 name: &str,
526 components: &str,
527 members: &[(PimObject, Vec<u8>)],
528) -> PimCollection {
529 let digest = Sha256::digest(
530 members
531 .iter()
532 .map(|(o, _)| o.etag.as_str())
533 .collect::<String>(),
534 );
535 PimCollection {
536 id,
537 slug: slug.to_string(),
538 displayname: Some(name.to_string()),
539 components: components.to_string(),
540 seq: i64::from_be_bytes(digest[..8].try_into().expect("8 bytes")) & i64::MAX,
541 ..Default::default()
542 }
543}
544
545pub(super) type Members = Vec<(PimObject, Vec<u8>)>;
546
547/// The generated system address book: one card per visible principal.
548pub(super) async fn directory(state: &AppState) -> Result<(PimCollection, Members), ApiError> {
549 let mut members = Vec::new();
550 for p in state.db.pim_principals().await? {
551 let uuid = principal_uuid(p.id);
552 let uid = format!("urn:uuid:{uuid}");
553 let addresses: [String; 0] = [];
554 let view = Principal {
555 name: &p.name,
556 display: p.display(),
557 addresses: &addresses,
558 kind: p.kind,
559 };
560 let data = principal::card(&uid, &view, &mailto(&p.name, p.kind)).into_bytes();
561 members.push((
562 generated_object(format!("{uuid}.vcf"), uid, "VCARD", &data),
563 data,
564 ));
565 }
566 let col = generated_collection(DIRECTORY, DIRECTORY_SLUG, "Directory", "", &members);
567 Ok((col, members))
568}
569
570/// The generated birthday calendar of a principal: the birthdays and
571/// anniversaries in its own address books, not lent ones.
572// ponytail: rebuilt from every contact on each request. Store the events if
573// large address books make it slow.
574pub(super) async fn birthdays(
575 state: &AppState,
576 principal: i64,
577) -> Result<(PimCollection, Members), ApiError> {
578 let mut members = Vec::new();
579 for book in state
580 .db
581 .pim_collections(principal, PimKind::AddressBook)
582 .await?
583 {
584 for (o, data) in state.db.pim_objects_with_data(book.id).await? {
585 let key = format!("{}/{}", book.id, o.name);
586 for (uid, ics) in contact::dates(&String::from_utf8_lossy(&data), &key) {
587 let data = ics.into_bytes();
588 members.push((
589 generated_object(format!("{uid}.ics"), uid, "VEVENT", &data),
590 data,
591 ));
592 }
593 }
594 }
595 let mut col = generated_collection(BIRTHDAYS, BIRTHDAYS_SLUG, "Birthdays", "VEVENT", &members);
596 col.transparent = true;
597 Ok((col, members))
598}
599
600/// The members of collection `id`, stored or generated. `principal` owns
601/// a generated birthday calendar.
602pub(super) async fn members_of(
603 state: &AppState,
604 principal: i64,
605 id: i64,
606) -> Result<Members, ApiError> {
607 match id {
608 DIRECTORY => Ok(directory(state).await?.1),
609 BIRTHDAYS => Ok(birthdays(state, principal).await?.1),
610 id => Ok(state.db.pim_objects_with_data(id).await?),
611 }
612}
613
614fn generated_object(name: String, uid: String, component: &str, data: &[u8]) -> PimObject {
615 PimObject {
616 name,
617 uid,
618 component: component.to_string(),
619 etag: etag_of(data),
620 size: data.len() as i64,
621 ..Default::default()
622 }
623}
624
625/// The request context: who asks, and in whose URL space.
626struct Cx<'a> {
627 state: &'a AppState,
628 me: &'a Me,
629 space: Option<&'a Space>,
630}
631
632impl Cx<'_> {
633 fn space(&self) -> &Space {
634 self.space.expect("targets with an owner resolve a space")
635 }
636
637 /// A collection of the space by slug, with the access of the signed-in
638 /// account.
639 async fn collection(&self, kind: PimKind, slug: &str) -> Result<Option<Col>, ApiError> {
640 let space = self.space();
641 let db = &self.state.db;
642 if !space.mine {
643 if slug == INBOX {
644 return Ok(None);
645 }
646 // A room: everyone reads its bookings, admins may change and
647 // answer them.
648 let access = if self.me.admin {
649 Access::Schedule
650 } else {
651 Access::Read
652 };
653 return Ok(db.pim_collection(space.id, kind, slug).await?.map(|c| Col {
654 c,
655 access,
656 owner: space.principal(),
657 }));
658 }
659 if let Some(c) = db.pim_collection(space.id, kind, slug).await? {
660 return Ok(Some(Col {
661 c,
662 access: Access::Own,
663 owner: space.principal(),
664 }));
665 }
666 let generated = match (kind, slug) {
667 (PimKind::AddressBook, DIRECTORY_SLUG) => Some(directory(self.state).await?.0),
668 (PimKind::Calendar, BIRTHDAYS_SLUG) => Some(birthdays(self.state, space.id).await?.0),
669 _ => None,
670 };
671 if let Some(c) = generated {
672 return Ok(Some(Col {
673 c,
674 access: Access::Read,
675 owner: space.principal(),
676 }));
677 }
678 let Some(id) = slug
679 .strip_prefix(SHARED_PREFIX)
680 .and_then(|id| id.parse().ok())
681 else {
682 return Ok(None);
683 };
684 Ok(db
685 .pim_shared_collection(self.me.id, kind, id)
686 .await?
687 .map(|(c, owner, mode)| lent(c, &owner, mode)))
688 }
689
690 /// Every collection of `kind` in the space's home.
691 async fn collections(&self, kind: PimKind) -> Result<Vec<Col>, ApiError> {
692 let space = self.space();
693 let db = &self.state.db;
694 let own = if space.mine {
695 Access::Own
696 } else if self.me.admin {
697 Access::Schedule
698 } else {
699 Access::Read
700 };
701 let mut out: Vec<Col> = db
702 .pim_collections(space.id, kind)
703 .await?
704 .into_iter()
705 .filter(|c| space.mine || c.slug != INBOX)
706 .map(|c| Col {
707 c,
708 access: own,
709 owner: space.principal(),
710 })
711 .collect();
712 if space.mine {
713 let generated = match kind {
714 PimKind::AddressBook => directory(self.state).await?.0,
715 PimKind::Calendar => birthdays(self.state, space.id).await?.0,
716 };
717 out.push(Col {
718 c: generated,
719 access: Access::Read,
720 owner: space.principal(),
721 });
722 for (c, owner, mode) in db.pim_shared_collections(self.me.id, kind).await? {
723 out.push(lent(c, &owner, mode));
724 }
725 }
726 Ok(out)
727 }
728
729 async fn members(&self, c: &PimCollection) -> Result<Members, ApiError> {
730 members_of(self.state, self.space().id, c.id).await
731 }
732
733 async fn member(
734 &self,
735 c: &PimCollection,
736 name: &str,
737 ) -> Result<Option<(PimObject, Vec<u8>)>, ApiError> {
738 if generated(c.id) {
739 let all = self.members(c).await?;
740 return Ok(all.into_iter().find(|(o, _)| o.name == name));
741 }
742 Ok(self.state.db.pim_object(c.id, name).await?)
743 }
744}
745
746/// Deletes a collection of principal `owner`. A calendar's scheduling
747/// objects are cancelled for their attendees first. `Err` names the
748/// precondition that refuses it: the calendar that receives invitations
749/// stays.
750pub(super) async fn delete_own(
751 state: &AppState,
752 owner: i64,
753 kind: PimKind,
754 col: &PimCollection,
755) -> Result<Result<(), Element>, ApiError> {
756 let db = &state.db;
757 if kind == PimKind::Calendar && col.slug != INBOX {
758 if db
759 .pim_calendar_for(owner, "VEVENT")
760 .await?
761 .is_some_and(|d| d.id == col.id)
762 {
763 return Ok(Err(el(CALDAV, "default-calendar-needed")));
764 }
765 let _lock = pim_schedule::LOCK.lock().await;
766 let dir = Directory::load(state).await?;
767 let owner = dir
768 .get(owner)
769 .cloned()
770 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
771 let w = Writer::owner(&owner);
772 let mut ops = Vec::new();
773 for (_, data) in db.pim_objects_with_data(col.id).await? {
774 if let Ok(more) = pim_schedule::delete(state, &dir, &w, &data, true).await? {
775 ops.extend(more);
776 }
777 }
778 db.pim_apply(&ops).await?;
779 }
780 db.pim_delete_collection(col.id).await?;
781 Ok(Ok(()))
782}
783
784/// A collection lent to the signed-in account, as it appears in their home.
785fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col {
786 let name = c.displayname.take().unwrap_or_else(|| c.slug.clone());
787 c.displayname = Some(format!("{name} ({owner})"));
788 c.slug = format!("{SHARED_PREFIX}{}", c.id);
789 Col {
790 c,
791 access: match mode {
792 PimShareMode::Ro => Access::Read,
793 PimShareMode::Rw => Access::Write,
794 PimShareMode::RwSchedule => Access::Schedule,
795 },
796 owner: principal_href(owner),
797 }
798}
799
800// ---------------------------------------------------------------------------
801// PROPFIND
802// ---------------------------------------------------------------------------
803
804/// A resource PROPFIND can describe.
805enum Res {
806 Root,
807 Principals,
808 Principal(PrincipalView),
809 /// With its owner's principal href, whether the account may add to it,
810 /// and where its client properties live.
811 Home(String, Access, PropPlace),
812 Collection(PimKind, Col),
813 /// With the href of the calendar that receives new invitations.
814 Inbox(Col, Option<String>),
815 /// With its owner's principal href.
816 Outbox(String),
817 Object(PimKind, PimObject),
818}
819
820impl Cx<'_> {
821 async fn propfind(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
822 // Missing means infinity to RFC 4918, but clients that omit it mean 0.
823 let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) {
824 None | Some("0") => false,
825 Some("1") => true,
826 Some(_) => {
827 return Ok(error(
828 StatusCode::FORBIDDEN,
829 el(DAV, "propfind-finite-depth"),
830 ));
831 }
832 };
833 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
834 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
835 };
836 let Ok(request) = xml::propfind(&body) else {
837 return Ok(status(StatusCode::BAD_REQUEST));
838 };
839
840 let mut list: Vec<(String, Res)> = Vec::new();
841 match target {
842 Target::Root => list.push((format!("{PIM}/"), Res::Root)),
843 Target::Principals => {
844 list.push((format!("{PIM}/principals/"), Res::Principals));
845 if deep {
846 for p in self.state.db.pim_principals().await? {
847 list.push((
848 principal_href(&p.name),
849 Res::Principal(PrincipalView::of(&p, self.me)),
850 ));
851 }
852 }
853 }
854 Target::Principal(_) => {
855 let s = self.space();
856 list.push((
857 s.principal(),
858 Res::Principal(PrincipalView {
859 id: s.id,
860 path: s.path.clone(),
861 display: s.display.clone(),
862 kind: s.kind,
863 me: s.mine,
864 }),
865 ));
866 }
867 Target::Home(kind, _) => {
868 let s = self.space();
869 let access = if s.mine { Access::Own } else { Access::Read };
870 let place = PropPlace::Home(s.id, *kind);
871 list.push((s.home(*kind), Res::Home(s.principal(), access, place)));
872 if deep {
873 for col in self.collections(*kind).await? {
874 let href = s.collection(*kind, &col.c.slug);
875 list.push((href, self.res(*kind, col).await?));
876 }
877 if *kind == PimKind::Calendar && s.mine {
878 list.push((s.collection(*kind, OUTBOX), Res::Outbox(s.principal())));
879 }
880 }
881 }
882 Target::Collection(PimKind::Calendar, _, slug)
883 if slug == OUTBOX && self.space().mine =>
884 {
885 let s = self.space();
886 list.push((
887 s.collection(PimKind::Calendar, OUTBOX),
888 Res::Outbox(s.principal()),
889 ));
890 }
891 Target::Collection(kind, _, slug) => {
892 let Some(col) = self.collection(*kind, slug).await? else {
893 return Ok(status(StatusCode::NOT_FOUND));
894 };
895 let objects = match (deep, col.c.id) {
896 (false, _) => Vec::new(),
897 (true, id) if generated(id) => self
898 .members(&col.c)
899 .await?
900 .into_iter()
901 .map(|(o, _)| o)
902 .collect(),
903 (true, id) => self.state.db.pim_objects(id).await?,
904 };
905 let s = self.space();
906 let slug = col.c.slug.clone();
907 list.push((s.collection(*kind, &slug), self.res(*kind, col).await?));
908 for o in objects {
909 list.push((s.object(*kind, &slug, &o.name), Res::Object(*kind, o)));
910 }
911 }
912 Target::Object(kind, _, slug, name) => {
913 let found = match self.collection(*kind, slug).await? {
914 Some(col) => self.member(&col.c, name).await?,
915 None => None,
916 };
917 let Some((o, _)) = found else {
918 return Ok(status(StatusCode::NOT_FOUND));
919 };
920 list.push((
921 self.space().object(*kind, slug, name),
922 Res::Object(*kind, o),
923 ));
924 }
925 }
926
927 let mut responses = Vec::with_capacity(list.len());
928 for (href, res) in list {
929 let mut all = self.props(&res);
930 all.extend(self.dead_props(&res).await?);
931 responses.push(select(href, &request, all));
932 }
933 Ok(multistatus(&responses, None))
934 }
935
936 /// The client properties stored for a resource.
937 async fn dead_props(&self, res: &Res) -> Result<Vec<Element>, ApiError> {
938 let place = match res {
939 Res::Principal(p) => PropPlace::Principal(p.id),
940 Res::Home(_, _, place) => *place,
941 Res::Collection(_, col) | Res::Inbox(col, _) if !generated(col.c.id) => {
942 PropPlace::Collection(col.c.id)
943 }
944 _ => return Ok(Vec::new()),
945 };
946 Ok(self
947 .state
948 .db
949 .pim_props(place)
950 .await?
951 .iter()
952 .filter_map(|p| Element::parse(p.xml.as_bytes()).ok())
953 .collect())
954 }
955
956 /// Every live property of a resource, with its value.
957 fn props(&self, res: &Res) -> Vec<Element> {
958 let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
959 let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h]));
960 let resourcetype = |types: &[(&str, &str)]| {
961 with_children(
962 el(DAV, "resourcetype"),
963 types.iter().map(|(ns, l)| el(ns, l)),
964 )
965 };
966 let principals = format!("{PIM}/principals/");
967 let mut out = vec![
968 href_prop(DAV, "current-user-principal", &self.me.principal),
969 href_prop(DAV, "principal-collection-set", &principals),
970 ];
971 match res {
972 Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
973 Res::Principals => out.extend([
974 resourcetype(&[(DAV, "collection")]),
975 privileges(Access::Read),
976 principal_reports(),
977 ]),
978 Res::Principal(p) => {
979 // The own principal in the spelling of the request.
980 let href = match p.me {
981 true => self.me.principal.clone(),
982 false => principal_href(&p.path),
983 };
984 let addresses = p.addresses();
985 out.extend([
986 resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
987 text(DAV, "displayname", &p.display),
988 href_prop(DAV, "principal-URL", &href),
989 with_children(
990 el(CALDAV, "calendar-user-address-set"),
991 hrefs(addresses.iter().map(String::as_str))
992 .into_iter()
993 .map(|h| with_attr(h, "preferred", "1")),
994 ),
995 with_children(
996 el(CALSERVER, "email-address-set"),
997 [with_text(
998 el(CALSERVER, "email-address"),
999 mailto(&p.path, p.kind),
1000 )],
1001 ),
1002 text(CALDAV, "calendar-user-type", p.kind.as_str()),
1003 privileges(if p.me { Access::Own } else { Access::Read }),
1004 principal_reports(),
1005 ]);
1006 let home = |kind: PimKind| {
1007 let name = match p.me {
1008 true => self.space.map_or(p.path.clone(), |s| s.path.clone()),
1009 false => p.path.clone(),
1010 };
1011 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&name))
1012 };
1013 // Also for other accounts: python-caldav drops a search hit
1014 // without one. Their homes still answer 403.
1015 out.push(href_prop(
1016 CALDAV,
1017 "calendar-home-set",
1018 &home(PimKind::Calendar),
1019 ));
1020 if p.me {
1021 let cal = home(PimKind::Calendar);
1022 out.push(href_prop(
1023 CALDAV,
1024 "schedule-inbox-URL",
1025 &format!("{cal}{INBOX}/"),
1026 ));
1027 out.push(href_prop(
1028 CALDAV,
1029 "schedule-outbox-URL",
1030 &format!("{cal}{OUTBOX}/"),
1031 ));
1032 let book = home(PimKind::AddressBook);
1033 out.push(href_prop(CARDDAV, "addressbook-home-set", &book));
1034 out.push(href_prop(
1035 CARDDAV,
1036 "directory-gateway",
1037 &format!("{book}{DIRECTORY_SLUG}/"),
1038 ));
1039 }
1040 }
1041 Res::Home(owner, access, _) => out.extend([
1042 resourcetype(&[(DAV, "collection")]),
1043 href_prop(DAV, "owner", owner),
1044 privileges(*access),
1045 ]),
1046 Res::Collection(kind, col) => {
1047 let c = &col.c;
1048 let (types, desc) = match kind {
1049 PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
1050 PimKind::AddressBook => (
1051 (CARDDAV, "addressbook"),
1052 (CARDDAV, "addressbook-description"),
1053 ),
1054 };
1055 out.extend([
1056 resourcetype(&[(DAV, "collection"), types]),
1057 href_prop(DAV, "owner", &col.owner),
1058 privileges(col.access),
1059 supported_reports(*kind),
1060 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
1061 text(DAV, "sync-token", &sync_token(c.id, c.seq)),
1062 text(
1063 kind_ns(*kind),
1064 "max-resource-size",
1065 &MAX_RESOURCE_SIZE.to_string(),
1066 ),
1067 ]);
1068 if let Some(v) = &c.displayname {
1069 out.push(text(DAV, "displayname", v));
1070 }
1071 if let Some(v) = &c.description {
1072 out.push(text(desc.0, desc.1, v));
1073 }
1074 match kind {
1075 PimKind::Calendar => {
1076 out.push(with_children(
1077 el(CALDAV, "supported-calendar-component-set"),
1078 c.components
1079 .split(',')
1080 .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)),
1081 ));
1082 out.push(with_children(
1083 el(CALDAV, "supported-calendar-data"),
1084 [with_attr(
1085 with_attr(
1086 el(CALDAV, "calendar-data"),
1087 "content-type",
1088 "text/calendar",
1089 ),
1090 "version",
1091 "2.0",
1092 )],
1093 ));
1094 if let Some(v) = &c.color {
1095 out.push(text(APPLE, "calendar-color", v));
1096 }
1097 if let Some(v) = &c.sort_order {
1098 out.push(text(APPLE, "calendar-order", v));
1099 }
1100 if let Some(v) = &c.timezone {
1101 out.push(text(CALDAV, "calendar-timezone", v));
1102 }
1103 out.push(with_children(
1104 el(CALDAV, "schedule-calendar-transp"),
1105 [el(
1106 CALDAV,
1107 if c.transparent {
1108 "transparent"
1109 } else {
1110 "opaque"
1111 },
1112 )],
1113 ));
1114 }
1115 // 3.0 only: a client told of 4.0 writes 4.0 groups, which
1116 // Apple Contacts on the same account cannot read. A 4.0
1117 // PUT is still stored, and served as 4.0 on request.
1118 PimKind::AddressBook => out.push(with_children(
1119 el(CARDDAV, "supported-address-data"),
1120 [with_attr(
1121 with_attr(
1122 el(CARDDAV, "address-data-type"),
1123 "content-type",
1124 "text/vcard",
1125 ),
1126 "version",
1127 "3.0",
1128 )],
1129 )),
1130 }
1131 }
1132 Res::Inbox(col, default) => {
1133 let c = &col.c;
1134 out.extend([
1135 resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-inbox")]),
1136 href_prop(DAV, "owner", &col.owner),
1137 privilege_set(INBOX_PRIVILEGES),
1138 report_set(&[
1139 (CALDAV, "calendar-multiget"),
1140 (CALDAV, "calendar-query"),
1141 (DAV, "sync-collection"),
1142 ]),
1143 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
1144 text(DAV, "sync-token", &sync_token(c.id, c.seq)),
1145 ]);
1146 if let Some(v) = &c.displayname {
1147 out.push(text(DAV, "displayname", v));
1148 }
1149 if let Some(h) = default {
1150 out.push(href_prop(CALDAV, "schedule-default-calendar-URL", h));
1151 }
1152 }
1153 Res::Outbox(owner) => out.extend([
1154 resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-outbox")]),
1155 href_prop(DAV, "owner", owner),
1156 privilege_set(OUTBOX_PRIVILEGES),
1157 ]),
1158 Res::Object(kind, o) => {
1159 if let Some(tag) = &o.schedule_tag {
1160 out.push(text(CALDAV, "schedule-tag", tag));
1161 }
1162 out.extend([
1163 resourcetype(&[]),
1164 text(DAV, "getetag", &o.etag),
1165 text(DAV, "getcontenttype", &content_type(*kind, &o.component)),
1166 text(DAV, "getcontentlength", &o.size.to_string()),
1167 ]);
1168 if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) {
1169 let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1170 out.push(text(DAV, "getlastmodified", &http_date));
1171 }
1172 }
1173 }
1174 out
1175 }
1176}
1177
1178impl Cx<'_> {
1179 /// How PROPFIND describes a collection. The inbox names the calendar
1180 /// that receives new invitations.
1181 async fn res(&self, kind: PimKind, col: Col) -> Result<Res, ApiError> {
1182 if kind != PimKind::Calendar || col.c.slug != INBOX {
1183 return Ok(Res::Collection(kind, col));
1184 }
1185 let space = self.space();
1186 let default = self
1187 .state
1188 .db
1189 .pim_calendar_for(space.id, "VEVENT")
1190 .await?
1191 .map(|c| space.collection(PimKind::Calendar, &c.slug));
1192 Ok(Res::Inbox(col, default))
1193 }
1194}
1195
1196/// The response for one resource: the requested ones of `all`, and 404 for
1197/// those it lacks.
1198fn select(href: String, request: &Propfind, all: Vec<Element>) -> xml::Response {
1199 let mut r = xml::Response::new(href);
1200 match request {
1201 Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
1202 Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
1203 Propfind::Prop(names) => {
1204 for n in names {
1205 match all.iter().find(|p| Name::of(p) == *n) {
1206 Some(p) => r.push(200, p.clone()),
1207 None => r.push(404, n.element()),
1208 }
1209 }
1210 }
1211 }
1212 if r.propstats.is_empty() {
1213 r.status = Some(200);
1214 }
1215 r
1216}
1217
1218fn multistatus(responses: &[xml::Response], tail: Option<Element>) -> Response<Body> {
1219 xml_response(
1220 StatusCode::MULTI_STATUS,
1221 xml::multistatus_with(&Name::new(DAV, "multistatus"), responses, tail),
1222 )
1223}
1224
1225fn report_set(reports: &[(&str, &str)]) -> Element {
1226 with_children(
1227 el(DAV, "supported-report-set"),
1228 reports.iter().map(|(ns, local)| {
1229 with_children(
1230 el(DAV, "supported-report"),
1231 [with_children(el(DAV, "report"), [el(ns, local)])],
1232 )
1233 }),
1234 )
1235}
1236
1237fn supported_reports(kind: PimKind) -> Element {
1238 report_set(match kind {
1239 PimKind::Calendar => &[
1240 (CALDAV, "calendar-multiget"),
1241 (CALDAV, "calendar-query"),
1242 (CALDAV, "free-busy-query"),
1243 (DAV, "sync-collection"),
1244 ],
1245 PimKind::AddressBook => &[
1246 (CARDDAV, "addressbook-multiget"),
1247 (CARDDAV, "addressbook-query"),
1248 (DAV, "sync-collection"),
1249 ],
1250 })
1251}
1252
1253fn principal_reports() -> Element {
1254 report_set(&[
1255 (DAV, "principal-property-search"),
1256 (DAV, "principal-search-property-set"),
1257 (CALSERVER, "calendarserver-principal-search"),
1258 ])
1259}
1260
1261fn privileges(access: Access) -> Element {
1262 const WRITE: [(&str, &str); 5] = [
1263 (DAV, "read"),
1264 (DAV, "write-content"),
1265 (DAV, "bind"),
1266 (DAV, "unbind"),
1267 (DAV, "read-current-user-privilege-set"),
1268 ];
1269 let names: Vec<(&str, &str)> = match access {
1270 Access::Own => [
1271 "all",
1272 "read",
1273 "write",
1274 "write-properties",
1275 "write-content",
1276 "bind",
1277 "unbind",
1278 "read-current-user-privilege-set",
1279 ]
1280 .map(|n| (DAV, n))
1281 .to_vec(),
1282 // RFC 6638 grants these on the outbox, which a sharee cannot see.
1283 Access::Schedule => [
1284 (CALDAV, "schedule-send"),
1285 (CALDAV, "schedule-send-invite"),
1286 (CALDAV, "schedule-send-reply"),
1287 ]
1288 .into_iter()
1289 .chain(WRITE)
1290 .collect(),
1291 Access::Write => WRITE.to_vec(),
1292 Access::Read => vec![(DAV, "read"), (DAV, "read-current-user-privilege-set")],
1293 };
1294 privilege_set(names)
1295}
1296
1297/// The owner reads and empties the inbox; only the server delivers into it.
1298const INBOX_PRIVILEGES: [(&str, &str); 7] = [
1299 (DAV, "read"),
1300 (DAV, "unbind"),
1301 (DAV, "read-current-user-privilege-set"),
1302 (CALDAV, "schedule-deliver"),
1303 (CALDAV, "schedule-deliver-invite"),
1304 (CALDAV, "schedule-deliver-reply"),
1305 (CALDAV, "schedule-query-freebusy"),
1306];
1307
1308const OUTBOX_PRIVILEGES: [(&str, &str); 6] = [
1309 (DAV, "read"),
1310 (DAV, "read-current-user-privilege-set"),
1311 (CALDAV, "schedule-send"),
1312 (CALDAV, "schedule-send-invite"),
1313 (CALDAV, "schedule-send-reply"),
1314 (CALDAV, "schedule-send-freebusy"),
1315];
1316
1317fn privilege_set<'a>(names: impl IntoIterator<Item = (&'a str, &'a str)>) -> Element {
1318 with_children(
1319 el(DAV, "current-user-privilege-set"),
1320 names
1321 .into_iter()
1322 .map(|(ns, n)| with_children(el(DAV, "privilege"), [el(ns, n)])),
1323 )
1324}
1325
1326/// Carries the collection id, so a token handed out for a deleted
1327/// collection never matches the one that later takes its URL.
1328fn sync_token(id: i64, seq: i64) -> String {
1329 format!("urn:dovenest:sync:{id}-{seq}")
1330}
1331
1332fn content_type(kind: PimKind, component: &str) -> String {
1333 match kind {
1334 PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
1335 PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
1336 }
1337}
1338
1339// ---------------------------------------------------------------------------
1340// PROPPATCH, MKCALENDAR, MKCOL
1341// ---------------------------------------------------------------------------
1342
1343impl Cx<'_> {
1344 async fn proppatch(&self, target: &Target, body: Body) -> Reply {
1345 let (href, place, res, mut col) = match target {
1346 Target::Collection(kind, _, slug) => {
1347 let Some(col) = self.collection(*kind, slug).await? else {
1348 return Ok(status(StatusCode::NOT_FOUND));
1349 };
1350 let href = self.space().collection(*kind, slug);
1351 if col.access != Access::Own {
1352 return Ok(denied(&href, "write-properties"));
1353 }
1354 let place = PropPlace::Collection(col.c.id);
1355 let stored = (*kind, col.c.clone());
1356 (href, place, self.res(*kind, col).await?, Some(stored))
1357 }
1358 Target::Home(kind, _) => {
1359 let s = self.space();
1360 if !self.may_edit(s) {
1361 return Ok(denied(&s.home(*kind), "write-properties"));
1362 }
1363 let place = PropPlace::Home(s.id, *kind);
1364 let res = Res::Home(s.principal(), Access::Own, place);
1365 (s.home(*kind), place, res, None)
1366 }
1367 Target::Principal(_) => {
1368 let s = self.space();
1369 if !self.may_edit(s) {
1370 return Ok(denied(&s.principal(), "write-properties"));
1371 }
1372 let view = PrincipalView {
1373 id: s.id,
1374 path: s.path.clone(),
1375 display: s.display.clone(),
1376 kind: s.kind,
1377 me: s.mine,
1378 };
1379 let place = PropPlace::Principal(s.id);
1380 (s.principal(), place, Res::Principal(view), None)
1381 }
1382 _ => return Ok(status(StatusCode::FORBIDDEN)),
1383 };
1384 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1385 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1386 };
1387 let Ok(update) = xml::update(&body) else {
1388 return Ok(status(StatusCode::BAD_REQUEST));
1389 };
1390 let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
1391 let stored = self.state.db.pim_props(place).await?;
1392 let patch = apply(
1393 col.as_mut().map(|(k, c)| (*k, c)),
1394 &update,
1395 false,
1396 &live,
1397 &stored,
1398 );
1399 if patch.ok() {
1400 let db = &self.state.db;
1401 db.pim_patch(
1402 place,
1403 col.as_ref().map(|(_, c)| c),
1404 &patch.set,
1405 &patch.remove,
1406 )
1407 .await?;
1408 }
1409 let mut r = xml::Response::new(href);
1410 r.error = patch
1411 .protected
1412 .then(|| el(DAV, "cannot-modify-protected-property"));
1413 for (code, prop) in patch.results {
1414 r.push(code, prop);
1415 }
1416 Ok(multistatus(&[r], None))
1417 }
1418
1419 /// The owner changes the properties of its principal and homes, admins
1420 /// those of rooms and resources.
1421 fn may_edit(&self, s: &Space) -> bool {
1422 s.mine || (self.me.admin && s.kind != UserType::Individual)
1423 }
1424
1425 async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply {
1426 let Target::Collection(kind, _, slug) = target else {
1427 return Ok(status(StatusCode::FORBIDDEN));
1428 };
1429 let space = self.space();
1430 if !space.mine {
1431 return Ok(denied(&space.home(*kind), "bind"));
1432 }
1433 let calendar = method == "MKCALENDAR";
1434 if calendar && *kind != PimKind::Calendar {
1435 return Ok(status(StatusCode::FORBIDDEN));
1436 }
1437 if self.collection(*kind, slug).await?.is_some() {
1438 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1439 }
1440 // Names the home shows for lent and generated collections.
1441 if slug.starts_with(SHARED_PREFIX)
1442 || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
1443 {
1444 return Ok(status(StatusCode::FORBIDDEN));
1445 }
1446 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1447 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1448 };
1449 let Ok(update) = xml::update(&body) else {
1450 return Ok(status(StatusCode::BAD_REQUEST));
1451 };
1452 // A plain MKCOL makes a plain collection, which a calendar home cannot
1453 // hold. An address book home takes it as an address book.
1454 let typed = update
1455 .set
1456 .iter()
1457 .any(|p| Name::of(p).is(DAV, "resourcetype"));
1458 if !calendar && *kind == PimKind::Calendar && !typed {
1459 return Ok(status(StatusCode::FORBIDDEN));
1460 }
1461 let mut col = PimCollection {
1462 slug: slug.clone(),
1463 components: match kind {
1464 PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(),
1465 PimKind::AddressBook => String::new(),
1466 },
1467 ..Default::default()
1468 };
1469 let res = Res::Collection(
1470 *kind,
1471 Col {
1472 c: col.clone(),
1473 access: Access::Own,
1474 owner: space.principal(),
1475 },
1476 );
1477 let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
1478 let patch = apply(Some((*kind, &mut col)), &update, true, &live, &[]);
1479 if !patch.ok() {
1480 let root = match calendar {
1481 true => Name::new(CALDAV, "mkcalendar-response"),
1482 false => Name::new(DAV, "mkcol-response"),
1483 };
1484 let propstats = group(patch.results);
1485 return Ok(xml_response(
1486 StatusCode::FORBIDDEN,
1487 xml::propstat_document(&root, &propstats),
1488 ));
1489 }
1490 if !self
1491 .state
1492 .db
1493 .pim_create_collection(self.me.pid, *kind, &col, &patch.set)
1494 .await?
1495 {
1496 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1497 }
1498 Ok(status(StatusCode::CREATED))
1499 }
1500}
1501
1502fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
1503 let mut r = xml::Response::default();
1504 for (code, prop) in results {
1505 r.push(code, prop);
1506 }
1507 r.propstats
1508}
1509
1510/// A property update: each property with its status, and the client
1511/// properties to store and remove.
1512struct Patch {
1513 results: Vec<(u16, Element)>,
1514 set: Vec<DeadProp>,
1515 remove: Vec<(String, String)>,
1516 /// A property the server computes was named.
1517 protected: bool,
1518}
1519
1520impl Patch {
1521 fn ok(&self) -> bool {
1522 self.results.iter().all(|(code, _)| *code == 200)
1523 }
1524}
1525
1526/// DAV properties the server computes on some resource, beyond the ones
1527/// `live` names for the resource at hand.
1528const PROTECTED: [&str; 20] = [
1529 "acl",
1530 "alternate-URI-set",
1531 "creationdate",
1532 "current-user-principal",
1533 "current-user-privilege-set",
1534 "getcontentlength",
1535 "getcontenttype",
1536 "getetag",
1537 "getlastmodified",
1538 "group",
1539 "group-member-set",
1540 "group-membership",
1541 "lockdiscovery",
1542 "owner",
1543 "principal-URL",
1544 "principal-collection-set",
1545 "resourcetype",
1546 "supported-report-set",
1547 "supportedlock",
1548 "sync-token",
1549];
1550
1551/// Applies a PROPPATCH, MKCALENDAR or extended MKCOL body. A collection's
1552/// own properties go into `col`. What the server computes (`live`, or a
1553/// [`PROTECTED`] DAV property) is refused; anything else is stored as the
1554/// client sent it, as clients expect of properties such as Apple's
1555/// `default-alarm-vevent-date`. Nothing may be stored unless all of it is
1556/// allowed: RFC 4918 makes PROPPATCH atomic.
1557fn apply(
1558 mut col: Option<(PimKind, &mut PimCollection)>,
1559 update: &Update,
1560 creating: bool,
1561 live: &[Name],
1562 stored: &[DeadProp],
1563) -> Patch {
1564 let mut patch = Patch {
1565 results: Vec::new(),
1566 set: Vec::new(),
1567 remove: Vec::new(),
1568 protected: false,
1569 };
1570 let is_protected =
1571 |n: &Name| live.contains(n) || (n.ns == DAV && PROTECTED.contains(&n.local.as_str()));
1572 for p in &update.set {
1573 let name = Name::of(p);
1574 let own = col
1575 .as_mut()
1576 .and_then(|(kind, c)| set_own(*kind, c, p, &name, creating));
1577 let code = match own {
1578 Some(true) => 200,
1579 Some(false) => 403,
1580 None if is_protected(&name) => {
1581 patch.protected = true;
1582 403
1583 }
1584 None => {
1585 let xml = xml::document(p);
1586 if xml.len() > MAX_DEAD_SIZE {
1587 507
1588 } else {
1589 patch.set.push(DeadProp {
1590 ns: name.ns.clone(),
1591 name: name.local.clone(),
1592 xml,
1593 });
1594 200
1595 }
1596 }
1597 };
1598 patch.results.push((code, name.element()));
1599 }
1600 for name in &update.remove {
1601 let own = col
1602 .as_mut()
1603 .and_then(|(kind, c)| remove_own(*kind, c, name));
1604 let code = match own {
1605 Some(()) => 200,
1606 None if is_protected(name) => {
1607 patch.protected = true;
1608 403
1609 }
1610 None => {
1611 patch.remove.push((name.ns.clone(), name.local.clone()));
1612 200
1613 }
1614 };
1615 patch.results.push((code, name.element()));
1616 }
1617 let mut names: Vec<(&str, &str)> = stored
1618 .iter()
1619 .map(|p| (p.ns.as_str(), p.name.as_str()))
1620 .chain(patch.set.iter().map(|p| (p.ns.as_str(), p.name.as_str())))
1621 .filter(|n| {
1622 !patch
1623 .remove
1624 .iter()
1625 .any(|(ns, l)| (ns.as_str(), l.as_str()) == *n)
1626 })
1627 .collect();
1628 names.sort_unstable();
1629 names.dedup();
1630 if names.len() > MAX_DEAD_PROPS {
1631 for (code, prop) in &mut patch.results {
1632 let n = Name::of(prop);
1633 if patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local) {
1634 *code = 507;
1635 }
1636 }
1637 }
1638 if !patch.ok() {
1639 for (code, _) in &mut patch.results {
1640 if *code == 200 {
1641 *code = 424;
1642 }
1643 }
1644 }
1645 patch
1646}
1647
1648/// Sets one of a collection's own properties. `None` if it is none of them,
1649/// `Some(valid)` otherwise.
1650fn set_own(
1651 kind: PimKind,
1652 col: &mut PimCollection,
1653 p: &Element,
1654 name: &Name,
1655 creating: bool,
1656) -> Option<bool> {
1657 let cal = kind == PimKind::Calendar;
1658 let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
1659 Some(match (name.ns.as_str(), name.local.as_str()) {
1660 (DAV, "displayname") => {
1661 col.displayname = value();
1662 true
1663 }
1664 (CALDAV, "calendar-description") if cal => {
1665 col.description = value();
1666 true
1667 }
1668 (CARDDAV, "addressbook-description") if !cal => {
1669 col.description = value();
1670 true
1671 }
1672 (APPLE, "calendar-color") if cal => {
1673 col.color = value();
1674 true
1675 }
1676 (APPLE, "calendar-order") if cal => {
1677 col.sort_order = value();
1678 true
1679 }
1680 (CALDAV, "calendar-timezone") if cal => {
1681 let tz = value();
1682 let valid = tz.as_deref().is_none_or(is_timezone);
1683 if valid {
1684 col.timezone = tz;
1685 }
1686 valid
1687 }
1688 (CALDAV, "schedule-calendar-transp") if cal => {
1689 let transparent = xml::child(p, CALDAV, "transparent").is_some();
1690 let valid = transparent || xml::child(p, CALDAV, "opaque").is_some();
1691 if valid {
1692 col.transparent = transparent;
1693 }
1694 valid
1695 }
1696 (DAV, "resourcetype") if creating => {
1697 let wanted = match kind {
1698 PimKind::Calendar => (CALDAV, "calendar"),
1699 PimKind::AddressBook => (CARDDAV, "addressbook"),
1700 };
1701 xml::child(p, wanted.0, wanted.1).is_some()
1702 }
1703 (CALDAV, "supported-calendar-component-set") if creating && cal => {
1704 let comps: Vec<_> = xml::elements(p)
1705 .filter(|c| Name::of(c).is(CALDAV, "comp"))
1706 .filter_map(|c| c.attributes.get("name"))
1707 .map(|n| n.to_ascii_uppercase())
1708 .collect();
1709 let valid = !comps.is_empty()
1710 && comps
1711 .iter()
1712 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
1713 if valid {
1714 col.components = comps.join(",");
1715 }
1716 valid
1717 }
1718 _ => return None,
1719 })
1720}
1721
1722/// Removes one of a collection's own properties. `None` if it is none of
1723/// them.
1724fn remove_own(kind: PimKind, col: &mut PimCollection, name: &Name) -> Option<()> {
1725 let cal = kind == PimKind::Calendar;
1726 if cal && name.is(CALDAV, "schedule-calendar-transp") {
1727 col.transparent = false;
1728 return Some(());
1729 }
1730 let field = match (name.ns.as_str(), name.local.as_str()) {
1731 (DAV, "displayname") => &mut col.displayname,
1732 (CALDAV, "calendar-description") if cal => &mut col.description,
1733 (CARDDAV, "addressbook-description") if !cal => &mut col.description,
1734 (APPLE, "calendar-color") if cal => &mut col.color,
1735 (APPLE, "calendar-order") if cal => &mut col.sort_order,
1736 (CALDAV, "calendar-timezone") if cal => &mut col.timezone,
1737 _ => return None,
1738 };
1739 *field = None;
1740 Some(())
1741}
1742
1743/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
1744fn is_timezone(v: &str) -> bool {
1745 use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
1746 ICalendar::parse(v).is_ok_and(|c| {
1747 c.components
1748 .iter()
1749 .any(|c| c.component_type == ICalendarComponentType::VTimezone)
1750 })
1751}
1752
1753// ---------------------------------------------------------------------------
1754// Objects
1755// ---------------------------------------------------------------------------
1756
1757impl Cx<'_> {
1758 async fn get(&self, target: &Target, headers: &HeaderMap, head: bool) -> Reply {
1759 let Target::Object(kind, _, slug, name) = target else {
1760 return self.get_collection(target, head).await;
1761 };
1762 let found = match self.collection(*kind, slug).await? {
1763 Some(col) => self.member(&col.c, name).await?,
1764 None => None,
1765 };
1766 let Some((o, mut data)) = found else {
1767 return Ok(status(StatusCode::NOT_FOUND));
1768 };
1769 if *kind == PimKind::AddressBook {
1770 let accept = headers.get("accept").and_then(|v| v.to_str().ok());
1771 let req = render::AddressData {
1772 props: None,
1773 version: Some(render::accepted_version(accept)),
1774 };
1775 data = render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes();
1776 }
1777 let body = if head {
1778 Body::empty()
1779 } else {
1780 Body::from(data)
1781 };
1782 let mut r = (
1783 StatusCode::OK,
1784 [
1785 (CONTENT_TYPE, content_type(*kind, &o.component)),
1786 (ETAG, o.etag),
1787 ],
1788 body,
1789 )
1790 .into_response();
1791 with_schedule_tag(&mut r, o.schedule_tag.as_deref());
1792 Ok(r)
1793 }
1794
1795 /// Clients that discover with GET, as RFC 6764 allows, expect a 2xx on
1796 /// every collection on the way.
1797 async fn get_collection(&self, target: &Target, head: bool) -> Reply {
1798 if let Target::Collection(kind, _, slug) = target
1799 && !(*kind == PimKind::Calendar && slug == OUTBOX && self.space().mine)
1800 && self.collection(*kind, slug).await?.is_none()
1801 {
1802 return Ok(status(StatusCode::NOT_FOUND));
1803 }
1804 let body = match head {
1805 true => "",
1806 false => "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n",
1807 };
1808 Ok((
1809 StatusCode::OK,
1810 [(CONTENT_TYPE, "text/plain; charset=utf-8")],
1811 body,
1812 )
1813 .into_response())
1814 }
1815
1816 async fn put(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
1817 let Target::Object(kind, _, slug, name) = target else {
1818 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1819 };
1820 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
1821 return Ok(status(StatusCode::CONFLICT));
1822 };
1823 let space = self.space();
1824 // The server alone delivers into the inbox.
1825 if access < Access::Write || col.slug == INBOX {
1826 return Ok(denied(&space.collection(*kind, slug), "bind"));
1827 }
1828 let ns = kind_ns(*kind);
1829 let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
1830 return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
1831 };
1832 let parsed = match kind {
1833 PimKind::Calendar => {
1834 let supported: Vec<&str> = col.components.split(',').collect();
1835 object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
1836 }
1837 PimKind::AddressBook => object::vcard(&data)
1838 .map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into())),
1839 };
1840 let (uid, component) = match parsed {
1841 Ok(v) => v,
1842 Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
1843 };
1844 let stamped = match kind {
1845 PimKind::Calendar => object::with_dtstamp(&data, chrono::Utc::now()),
1846 PimKind::AddressBook => None,
1847 };
1848 let data = stamped.as_deref().unwrap_or(&data);
1849
1850 let _lock = pim_schedule::LOCK.lock().await;
1851 let db = &self.state.db;
1852 let current = self.member(&col, name).await?;
1853 if refuses(headers, current.as_ref().map(|(o, _)| o)) {
1854 return Ok(status(StatusCode::PRECONDITION_FAILED));
1855 }
1856 if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? {
1857 return Ok(error(
1858 StatusCode::FORBIDDEN,
1859 with_children(
1860 el(ns, "no-uid-conflict"),
1861 hrefs([space.object(*kind, slug, &holder).as_str()]),
1862 ),
1863 ));
1864 }
1865 let stored = match kind {
1866 PimKind::Calendar => {
1867 let dir = Directory::load(self.state).await?;
1868 let owner = self.owner(&col, &dir).await?;
1869 let w = self.writer(&owner, access);
1870 let old = current.as_ref().map(|(_, d)| d.as_slice());
1871 match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, data).await? {
1872 Ok(s) => s,
1873 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
1874 }
1875 }
1876 PimKind::AddressBook => Stored {
1877 data: data.to_vec(),
1878 changed: false,
1879 schedule_tag: None,
1880 ops: Vec::new(),
1881 },
1882 };
1883 let etag = etag_of(&stored.data);
1884 let mut ops = vec![PimOp::Put {
1885 collection_id: col.id,
1886 obj: PimObject {
1887 name: name.clone(),
1888 uid,
1889 component,
1890 etag: etag.clone(),
1891 schedule_tag: stored.schedule_tag.clone(),
1892 ..Default::default()
1893 },
1894 data: stored.data,
1895 }];
1896 ops.extend(stored.ops);
1897 db.pim_apply(&ops).await?;
1898 let code = match current {
1899 Some(_) => StatusCode::NO_CONTENT,
1900 None => StatusCode::CREATED,
1901 };
1902 let mut r = status(code);
1903 // Only when the stored bytes are the request bytes (RFC 4791, 5.3.4).
1904 if !stored.changed && stamped.is_none() {
1905 r.headers_mut()
1906 .insert(ETAG, etag.parse().expect("hex is a valid header"));
1907 }
1908 with_schedule_tag(&mut r, stored.schedule_tag.as_deref());
1909 Ok(r)
1910 }
1911
1912 /// The signed-in account writing into a calendar of `owner`.
1913 fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> {
1914 Writer {
1915 owner,
1916 may_schedule: access >= Access::Schedule,
1917 sent_by: (access != Access::Own).then(|| self.me.address.clone()),
1918 }
1919 }
1920
1921 /// The principal owning a collection, whose addresses decide how it takes
1922 /// part in the objects there.
1923 async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result<PimPrincipal, ApiError> {
1924 let owner = match self.state.db.pim_collection_by_id(col.id).await? {
1925 Some((id, _, _)) => dir.get(id).cloned(),
1926 None => None,
1927 };
1928 owner.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))
1929 }
1930
1931 async fn delete(&self, target: &Target, headers: &HeaderMap) -> Reply {
1932 let (kind, slug, name) = match target {
1933 Target::Collection(k, _, s) => (k, s, None),
1934 Target::Object(k, _, s, n) => (k, s, Some(n)),
1935 _ => return Ok(status(StatusCode::FORBIDDEN)),
1936 };
1937 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
1938 return Ok(status(StatusCode::NOT_FOUND));
1939 };
1940 let space = self.space();
1941 let href = space.collection(*kind, slug);
1942 let scheduling = *kind == PimKind::Calendar && col.slug != INBOX;
1943 let db = &self.state.db;
1944 let Some(name) = name else {
1945 return Ok(match access {
1946 Access::Own if *kind == PimKind::Calendar && col.slug == INBOX => {
1947 denied(&space.home(*kind), "unbind")
1948 }
1949 Access::Own => match delete_own(self.state, space.id, *kind, &col).await? {
1950 Ok(()) => status(StatusCode::NO_CONTENT),
1951 Err(condition) => error(StatusCode::FORBIDDEN, condition),
1952 },
1953 // Deleting a lent collection only takes it out of this home.
1954 _ if slug.starts_with(SHARED_PREFIX) && space.mine => {
1955 db.pim_remove_share(col.id, self.me.id).await?;
1956 status(StatusCode::NO_CONTENT)
1957 }
1958 _ => denied(&space.home(*kind), "unbind"),
1959 });
1960 };
1961 if access < Access::Write {
1962 return Ok(denied(&href, "unbind"));
1963 }
1964 let _lock = pim_schedule::LOCK.lock().await;
1965 let Some((obj, data)) = self.member(&col, name).await? else {
1966 return Ok(status(StatusCode::NOT_FOUND));
1967 };
1968 if refuses(headers, Some(&obj)) {
1969 return Ok(status(StatusCode::PRECONDITION_FAILED));
1970 }
1971 let mut ops = vec![PimOp::Delete {
1972 collection_id: col.id,
1973 name: name.clone(),
1974 }];
1975 if scheduling {
1976 let dir = Directory::load(self.state).await?;
1977 let owner = self.owner(&col, &dir).await?;
1978 let w = self.writer(&owner, access);
1979 let reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F");
1980 match pim_schedule::delete(self.state, &dir, &w, &data, reply).await? {
1981 Ok(more) => ops.extend(more),
1982 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
1983 }
1984 }
1985 db.pim_apply(&ops).await?;
1986 Ok(status(StatusCode::NO_CONTENT))
1987 }
1988}
1989
1990/// Whether If-Match, If-None-Match or If-Schedule-Tag-Match fails against
1991/// the current object.
1992fn refuses(headers: &HeaderMap, current: Option<&PimObject>) -> bool {
1993 if !precondition(headers).allows(current.map(|o| o.etag.as_str())) {
1994 return true;
1995 }
1996 headers
1997 .get("if-schedule-tag-match")
1998 .and_then(|v| v.to_str().ok())
1999 .is_some_and(|tag| current.and_then(|o| o.schedule_tag.as_deref()) != Some(tag.trim()))
2000}
2001
2002fn with_schedule_tag(r: &mut Response<Body>, tag: Option<&str>) {
2003 if let Some(v) = tag.and_then(|t| t.parse().ok()) {
2004 r.headers_mut().insert("schedule-tag", v);
2005 }
2006}
2007
2008fn precondition(headers: &HeaderMap) -> Precondition {
2009 let header = |name: &str| {
2010 headers
2011 .get(name)
2012 .and_then(|v| v.to_str().ok())
2013 .map(str::to_string)
2014 };
2015 Precondition {
2016 if_match: header("if-match"),
2017 if_none_match: header("if-none-match"),
2018 }
2019}
2020
2021// ---------------------------------------------------------------------------
2022// REPORT
2023// ---------------------------------------------------------------------------
2024
2025impl Cx<'_> {
2026 async fn report(&self, target: &Target, body: Body) -> Reply {
2027 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2028 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2029 };
2030 let report = match report::parse(&body) {
2031 Ok(r) => r,
2032 Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
2033 Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
2034 };
2035 let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
2036 let on_principals = matches!(
2037 target,
2038 Target::Root | Target::Principals | Target::Principal(_)
2039 );
2040 match report {
2041 Report::PrincipalSearch(search) if on_principals => {
2042 return self.principal_search(&search).await;
2043 }
2044 Report::PrincipalSearchPropertySet if on_principals => {
2045 return Ok(search_property_set());
2046 }
2047 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
2048 return unsupported();
2049 }
2050 _ => {}
2051 }
2052 let Target::Collection(kind, _, slug) = target else {
2053 return unsupported();
2054 };
2055 let calendar_report = matches!(
2056 report,
2057 Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
2058 );
2059 let card_report = matches!(
2060 report,
2061 Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
2062 );
2063 if (calendar_report && *kind != PimKind::Calendar)
2064 || (card_report && *kind != PimKind::AddressBook)
2065 {
2066 return unsupported();
2067 }
2068 let Some(Col { c: col, .. }) = self.collection(*kind, slug).await? else {
2069 return Ok(status(StatusCode::NOT_FOUND));
2070 };
2071 // Busy time comes from calendars, never from messages (RFC 6638, 2.3).
2072 if col.slug == INBOX && matches!(report, Report::FreeBusy(_)) {
2073 return unsupported();
2074 }
2075 let floating = col
2076 .timezone
2077 .as_deref()
2078 .and_then(zone::from_vtimezone)
2079 .unwrap_or(Zone::Utc);
2080 let out = Out {
2081 cx: self,
2082 kind: *kind,
2083 col: &col,
2084 };
2085
2086 match report {
2087 Report::CalendarMultiget { props, hrefs }
2088 | Report::AddressbookMultiget { props, hrefs } => {
2089 let mut responses = Vec::new();
2090 for href in hrefs {
2091 let found = match self.own_object(*kind, &href) {
2092 Some((slug, name)) if slug == col.slug => self.member(&col, &name).await?,
2093 _ => None,
2094 };
2095 responses.push(match found {
2096 // The href as the client wrote it, so it can match it.
2097 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
2098 Ok(r) => xml::Response { href, ..r },
2099 Err(TooManyInstances) => return Ok(too_many()),
2100 },
2101 None => xml::Response::status(href, 404),
2102 });
2103 }
2104 Ok(multistatus(&responses, None))
2105 }
2106 Report::CalendarQuery {
2107 props,
2108 filter,
2109 timezone,
2110 } => {
2111 let floating = timezone.unwrap_or(floating);
2112 let mut responses = Vec::new();
2113 for (o, data) in self.members(&col).await? {
2114 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
2115 continue;
2116 };
2117 if filter::matches_calendar(&cal, &filter, &floating) {
2118 match out.object(&o, &data, &props, &floating) {
2119 Ok(r) => responses.push(r),
2120 Err(TooManyInstances) => return Ok(too_many()),
2121 }
2122 }
2123 }
2124 Ok(multistatus(&responses, None))
2125 }
2126 Report::AddressbookQuery {
2127 props,
2128 filter,
2129 limit,
2130 } => {
2131 let mut responses = Vec::new();
2132 let mut truncated = false;
2133 for (o, data) in self.members(&col).await? {
2134 let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
2135 continue;
2136 };
2137 if !filter::matches_card(&card, &filter) {
2138 continue;
2139 }
2140 if limit.is_some_and(|n| responses.len() >= n) {
2141 truncated = true;
2142 break;
2143 }
2144 if let Ok(r) = out.object(&o, &data, &props, &floating) {
2145 responses.push(r);
2146 }
2147 }
2148 if truncated {
2149 responses.push(out.over_limit());
2150 }
2151 Ok(multistatus(&responses, None))
2152 }
2153 Report::SyncCollection {
2154 token,
2155 props,
2156 limit,
2157 } => {
2158 let since = match token.is_empty() {
2159 true => None,
2160 false => match parse_sync_token(&token) {
2161 // A generated collection has no change log: only its
2162 // current token is valid.
2163 Some((id, seq)) if id == col.id && generated(id) && seq == col.seq => {
2164 Some(seq)
2165 }
2166 Some((id, seq)) if id == col.id && !generated(id) && seq <= col.seq => {
2167 Some(seq)
2168 }
2169 _ => {
2170 return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token")));
2171 }
2172 },
2173 };
2174 let mut changes = if generated(col.id) {
2175 match since {
2176 Some(_) => Vec::new(),
2177 None => self
2178 .members(&col)
2179 .await?
2180 .into_iter()
2181 .map(|(o, _)| (o.name, col.seq, false))
2182 .collect(),
2183 }
2184 } else {
2185 self.state.db.pim_changes(col.id, since).await?
2186 };
2187 let truncated = limit.is_some_and(|n| changes.len() > n);
2188 if let Some(n) = limit {
2189 changes.truncate(n);
2190 }
2191 // A truncated answer hands out the token of its last change, so
2192 // the next sync resumes after it.
2193 let seq = match (truncated, changes.last()) {
2194 (true, Some((_, s, _))) if !generated(col.id) => *s,
2195 _ if generated(col.id) => col.seq,
2196 (_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
2197 };
2198 let mut responses = Vec::new();
2199 for (name, _, deleted) in changes {
2200 let href = self.space().object(*kind, &col.slug, &name);
2201 let found = match deleted {
2202 true => None,
2203 false => self.member(&col, &name).await?,
2204 };
2205 responses.push(match found {
2206 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
2207 Ok(r) => r,
2208 Err(TooManyInstances) => return Ok(too_many()),
2209 },
2210 None => xml::Response::status(href, 404),
2211 });
2212 }
2213 if truncated {
2214 responses.push(out.over_limit());
2215 }
2216 Ok(multistatus(
2217 &responses,
2218 Some(with_text(el(DAV, "sync-token"), sync_token(col.id, seq))),
2219 ))
2220 }
2221 Report::FreeBusy(range) => {
2222 let mut busy = Vec::new();
2223 for (_, data) in self.members(&col).await? {
2224 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
2225 // ponytail: one period per instance, so a long range over
2226 // a frequent series makes a long answer.
2227 busy.extend(freebusy::busy(&cal, &range, &floating, None));
2228 }
2229 }
2230 let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
2231 Ok((
2232 StatusCode::OK,
2233 [(CONTENT_TYPE, "text/calendar; charset=utf-8")],
2234 body,
2235 )
2236 .into_response())
2237 }
2238 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
2239 unreachable!("answered above")
2240 }
2241 }
2242 }
2243
2244 /// principal-property-search and calendarserver-principal-search.
2245 async fn principal_search(&self, search: &Search) -> Reply {
2246 let mut responses = Vec::new();
2247 let mut truncated = false;
2248 for p in self.state.db.pim_principals().await? {
2249 let view = PrincipalView::of(&p, self.me);
2250 let addresses = view.addresses();
2251 let candidate = Principal {
2252 name: &p.name,
2253 display: p.display(),
2254 addresses: &addresses,
2255 kind: p.kind,
2256 };
2257 if !search.matches(&candidate) {
2258 continue;
2259 }
2260 if search.limit.is_some_and(|n| responses.len() >= n) {
2261 truncated = true;
2262 break;
2263 }
2264 let href = principal_href(&p.name);
2265 responses.push(select(
2266 href,
2267 &search.find,
2268 self.props(&Res::Principal(view)),
2269 ));
2270 }
2271 if truncated {
2272 let mut r = xml::Response::status(format!("{PIM}/principals/"), 507);
2273 r.error = Some(el(DAV, "number-of-matches-within-limits"));
2274 responses.push(r);
2275 }
2276 Ok(multistatus(&responses, None))
2277 }
2278
2279 /// `(collection slug, object name)` of an href to an object of `kind` in
2280 /// the space of this request. Takes a path or a full URL.
2281 fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> {
2282 let path = match href.starts_with('/') {
2283 true => href.to_string(),
2284 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
2285 };
2286 let space = self.space?;
2287 match parse_target(path.strip_prefix(PIM)?)? {
2288 Target::Object(k, owner, slug, name)
2289 if k == kind && owner.eq_ignore_ascii_case(&space.path) =>
2290 {
2291 Some((slug, name))
2292 }
2293 _ => None,
2294 }
2295 }
2296}
2297
2298fn search_property_set() -> Response<Body> {
2299 let body = xml::document(&with_children(
2300 el(DAV, "principal-search-property-set"),
2301 principal::SEARCHABLE.map(|(ns, local, description)| {
2302 with_children(
2303 el(DAV, "principal-search-property"),
2304 [
2305 with_children(el(DAV, "prop"), [el(ns, local)]),
2306 with_attr(
2307 with_text(el(DAV, "description"), description),
2308 "xml:lang",
2309 "en",
2310 ),
2311 ],
2312 )
2313 }),
2314 ));
2315 xml_response(StatusCode::OK, body)
2316}
2317
2318/// What a REPORT answer about one collection needs.
2319struct Out<'a> {
2320 cx: &'a Cx<'a>,
2321 kind: PimKind,
2322 col: &'a PimCollection,
2323}
2324
2325impl Out<'_> {
2326 fn object(
2327 &self,
2328 o: &PimObject,
2329 data: &[u8],
2330 props: &Props,
2331 floating: &Zone,
2332 ) -> Result<xml::Response, TooManyInstances> {
2333 let mut all = self.cx.props(&Res::Object(self.kind, o.clone()));
2334 let raw = String::from_utf8_lossy(data);
2335 if let Some(req) = &props.calendar {
2336 let text = render::calendar_data(&raw, req, floating)?;
2337 all.push(with_text(el(CALDAV, "calendar-data"), text));
2338 }
2339 if let Some(req) = &props.address {
2340 all.push(with_text(
2341 el(CARDDAV, "address-data"),
2342 render::address_data(&raw, req),
2343 ));
2344 }
2345 let href = self.cx.space().object(self.kind, &self.col.slug, &o.name);
2346 Ok(select(href, &props.find, all))
2347 }
2348
2349 /// The response a query or sync adds when a client limit cut it short.
2350 fn over_limit(&self) -> xml::Response {
2351 let href = self.cx.space().collection(self.kind, &self.col.slug);
2352 let mut r = xml::Response::status(href, 507);
2353 r.error = Some(el(DAV, "number-of-matches-within-limits"));
2354 r
2355 }
2356}
2357
2358fn too_many() -> Response<Body> {
2359 error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
2360}
2361
2362/// `(collection id, seq)` of a token [`sync_token`] made.
2363fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
2364 // The birthday calendar's id is negative.
2365 let (id, seq) = token.strip_prefix("urn:dovenest:sync:")?.rsplit_once('-')?;
2366 Some((id.parse().ok()?, seq.parse().ok()?))
2367}
2368
2369// ---------------------------------------------------------------------------
2370// POST
2371// ---------------------------------------------------------------------------
2372
2373impl Cx<'_> {
2374 /// A free-busy request to the own scheduling outbox (RFC 6638, 5).
2375 async fn post(&self, target: &Target, body: Body) -> Reply {
2376 let space = match target {
2377 Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX => self.space(),
2378 _ => return Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
2379 };
2380 if !space.mine {
2381 let href = space.collection(PimKind::Calendar, OUTBOX);
2382 return Ok(error(
2383 StatusCode::FORBIDDEN,
2384 need_privilege(&href, CALDAV, "schedule-send-freebusy"),
2385 ));
2386 }
2387 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2388 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2389 };
2390 let request = match freebusy::request(&body) {
2391 Ok(r) => r,
2392 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition.element())),
2393 };
2394 let dir = Directory::load(self.state).await?;
2395 if !dir.is(self.me.pid)(&request.organizer) {
2396 return Ok(error(
2397 StatusCode::FORBIDDEN,
2398 el(CALDAV, "organizer-allowed"),
2399 ));
2400 }
2401 let answers = pim_schedule::free_busy(self.state, &dir, &request).await?;
2402 Ok(xml_response(
2403 StatusCode::OK,
2404 freebusy::schedule_response(&answers),
2405 ))
2406 }
2407}
2408
2409// ---------------------------------------------------------------------------
2410// MOVE
2411// ---------------------------------------------------------------------------
2412
2413impl Cx<'_> {
2414 async fn move_object(&self, target: &Target, headers: &HeaderMap) -> Reply {
2415 let Target::Object(kind, _, slug, name) = target else {
2416 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
2417 };
2418 let destination = headers.get("destination").and_then(|v| v.to_str().ok());
2419 let Some((to_slug, to_name)) = destination.and_then(|d| self.own_object(*kind, d)) else {
2420 return Ok(status(StatusCode::FORBIDDEN));
2421 };
2422 if (&to_slug, &to_name) == (slug, name) {
2423 return Ok(status(StatusCode::FORBIDDEN));
2424 }
2425 let space = self.space();
2426 let Some(from) = self.collection(*kind, slug).await? else {
2427 return Ok(status(StatusCode::NOT_FOUND));
2428 };
2429 let Some(to) = self.collection(*kind, &to_slug).await? else {
2430 return Ok(status(StatusCode::CONFLICT));
2431 };
2432 if from.access < Access::Write || from.c.slug == INBOX {
2433 return Ok(denied(&space.collection(*kind, slug), "unbind"));
2434 }
2435 if to.access < Access::Write || to.c.slug == INBOX {
2436 return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
2437 }
2438 let _lock = pim_schedule::LOCK.lock().await;
2439 let Some((obj, _)) = self.member(&from.c, name).await? else {
2440 return Ok(status(StatusCode::NOT_FOUND));
2441 };
2442 // Moving between calendars schedules nothing (RFC 6638, 3.2.3.4).
2443 if refuses(headers, Some(&obj)) {
2444 return Ok(status(StatusCode::PRECONDITION_FAILED));
2445 }
2446 if *kind == PimKind::Calendar && !to.c.components.split(',').any(|c| c == obj.component) {
2447 return Ok(error(
2448 StatusCode::FORBIDDEN,
2449 el(CALDAV, "supported-calendar-component"),
2450 ));
2451 }
2452 let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
2453 let written = self
2454 .state
2455 .db
2456 .pim_move_object(
2457 from.c.id,
2458 name,
2459 to.c.id,
2460 &to_name,
2461 overwrite,
2462 &precondition(headers),
2463 )
2464 .await?;
2465 Ok(match written {
2466 PimWrite::Created | PimWrite::Updated => {
2467 let code = match written {
2468 PimWrite::Created => StatusCode::CREATED,
2469 _ => StatusCode::NO_CONTENT,
2470 };
2471 let mut r = status(code);
2472 with_schedule_tag(&mut r, obj.schedule_tag.as_deref());
2473 r
2474 }
2475 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
2476 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
2477 PimWrite::UidConflict(holder) => error(
2478 StatusCode::FORBIDDEN,
2479 with_children(
2480 el(kind_ns(*kind), "no-uid-conflict"),
2481 hrefs([space.object(*kind, &to_slug, &holder).as_str()]),
2482 ),
2483 ),
2484 PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
2485 })
2486 }
2487}
2488