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