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