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
1467 let _lock = pim_schedule::LOCK.lock().await;
1468 let db = &self.state.db;
1469 let current = self.member(&col, name).await?;
1470 if refuses(headers, current.as_ref().map(|(o, _)| o)) {
1471 return Ok(status(StatusCode::PRECONDITION_FAILED));
1472 }
1473 if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? {
1474 return Ok(error(
1475 StatusCode::FORBIDDEN,
1476 with_children(
1477 el(ns, "no-uid-conflict"),
1478 hrefs([space.object(*kind, slug, &holder).as_str()]),
1479 ),
1480 ));
1481 }
1482 let stored = match kind {
1483 PimKind::Calendar => {
1484 let dir = Directory::load(self.state).await?;
1485 let owner = self.owner(&col, &dir).await?;
1486 let w = self.writer(&owner, access);
1487 let old = current.as_ref().map(|(_, d)| d.as_slice());
1488 match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, &data).await? {
1489 Ok(s) => s,
1490 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
1491 }
1492 }
1493 PimKind::AddressBook => Stored {
1494 data: data.to_vec(),
1495 changed: false,
1496 schedule_tag: None,
1497 ops: Vec::new(),
1498 },
1499 };
1500 let etag = etag_of(&stored.data);
1501 let mut ops = vec![PimOp::Put {
1502 collection_id: col.id,
1503 obj: PimObject {
1504 name: name.clone(),
1505 uid,
1506 component,
1507 etag: etag.clone(),
1508 schedule_tag: stored.schedule_tag.clone(),
1509 ..Default::default()
1510 },
1511 data: stored.data,
1512 }];
1513 ops.extend(stored.ops);
1514 db.pim_apply(&ops).await?;
1515 let code = match current {
1516 Some(_) => StatusCode::NO_CONTENT,
1517 None => StatusCode::CREATED,
1518 };
1519 let mut r = status(code);
1520 // Only when the stored bytes are the request bytes (RFC 4791, 5.3.4).
1521 if !stored.changed {
1522 r.headers_mut()
1523 .insert(ETAG, etag.parse().expect("hex is a valid header"));
1524 }
1525 with_schedule_tag(&mut r, stored.schedule_tag.as_deref());
1526 Ok(r)
1527 }
1528
1529 /// The signed-in account writing into a calendar of `owner`.
1530 fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> {
1531 Writer {
1532 owner,
1533 may_schedule: access >= Access::Schedule,
1534 sent_by: (access != Access::Own).then(|| self.me.address.clone()),
1535 }
1536 }
1537
1538 /// The principal owning a collection, whose addresses decide how it takes
1539 /// part in the objects there.
1540 async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result<PimPrincipal, ApiError> {
1541 let owner = match self.state.db.pim_collection_by_id(col.id).await? {
1542 Some((id, _, _)) => dir.get(id).cloned(),
1543 None => None,
1544 };
1545 owner.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))
1546 }
1547
1548 async fn delete(&self, target: &Target, headers: &HeaderMap) -> Reply {
1549 let (kind, slug, name) = match target {
1550 Target::Collection(k, _, s) => (k, s, None),
1551 Target::Object(k, _, s, n) => (k, s, Some(n)),
1552 _ => return Ok(status(StatusCode::FORBIDDEN)),
1553 };
1554 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
1555 return Ok(status(StatusCode::NOT_FOUND));
1556 };
1557 let space = self.space();
1558 let href = space.collection(*kind, slug);
1559 let scheduling = *kind == PimKind::Calendar && col.slug != INBOX;
1560 let db = &self.state.db;
1561 let Some(name) = name else {
1562 return Ok(match access {
1563 Access::Own if *kind == PimKind::Calendar && col.slug == INBOX => {
1564 denied(&space.home(*kind), "unbind")
1565 }
1566 Access::Own => {
1567 if scheduling
1568 && db
1569 .pim_calendar_for(space.id, "VEVENT")
1570 .await?
1571 .is_some_and(|d| d.id == col.id)
1572 {
1573 return Ok(error(
1574 StatusCode::FORBIDDEN,
1575 el(CALDAV, "default-calendar-needed"),
1576 ));
1577 }
1578 if scheduling {
1579 let _lock = pim_schedule::LOCK.lock().await;
1580 let dir = Directory::load(self.state).await?;
1581 let owner = self.owner(&col, &dir).await?;
1582 let w = Writer::owner(&owner);
1583 let mut ops = Vec::new();
1584 for (_, data) in db.pim_objects_with_data(col.id).await? {
1585 if let Ok(more) =
1586 pim_schedule::delete(self.state, &dir, &w, &data, true).await?
1587 {
1588 ops.extend(more);
1589 }
1590 }
1591 db.pim_apply(&ops).await?;
1592 }
1593 db.pim_delete_collection(col.id).await?;
1594 status(StatusCode::NO_CONTENT)
1595 }
1596 // Deleting a lent collection only takes it out of this home.
1597 _ if slug.starts_with(SHARED_PREFIX) && space.mine => {
1598 db.pim_remove_share(col.id, self.me.id).await?;
1599 status(StatusCode::NO_CONTENT)
1600 }
1601 _ => denied(&space.home(*kind), "unbind"),
1602 });
1603 };
1604 if access < Access::Write {
1605 return Ok(denied(&href, "unbind"));
1606 }
1607 let _lock = pim_schedule::LOCK.lock().await;
1608 let Some((obj, data)) = self.member(&col, name).await? else {
1609 return Ok(status(StatusCode::NOT_FOUND));
1610 };
1611 if refuses(headers, Some(&obj)) {
1612 return Ok(status(StatusCode::PRECONDITION_FAILED));
1613 }
1614 let mut ops = vec![PimOp::Delete {
1615 collection_id: col.id,
1616 name: name.clone(),
1617 }];
1618 if scheduling {
1619 let dir = Directory::load(self.state).await?;
1620 let owner = self.owner(&col, &dir).await?;
1621 let w = self.writer(&owner, access);
1622 let reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F");
1623 match pim_schedule::delete(self.state, &dir, &w, &data, reply).await? {
1624 Ok(more) => ops.extend(more),
1625 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
1626 }
1627 }
1628 db.pim_apply(&ops).await?;
1629 Ok(status(StatusCode::NO_CONTENT))
1630 }
1631}
1632
1633/// Whether If-Match, If-None-Match or If-Schedule-Tag-Match fails against
1634/// the current object.
1635fn refuses(headers: &HeaderMap, current: Option<&PimObject>) -> bool {
1636 if !precondition(headers).allows(current.map(|o| o.etag.as_str())) {
1637 return true;
1638 }
1639 headers
1640 .get("if-schedule-tag-match")
1641 .and_then(|v| v.to_str().ok())
1642 .is_some_and(|tag| current.and_then(|o| o.schedule_tag.as_deref()) != Some(tag.trim()))
1643}
1644
1645fn with_schedule_tag(r: &mut Response<Body>, tag: Option<&str>) {
1646 if let Some(v) = tag.and_then(|t| t.parse().ok()) {
1647 r.headers_mut().insert("schedule-tag", v);
1648 }
1649}
1650
1651fn precondition(headers: &HeaderMap) -> Precondition {
1652 let header = |name: &str| {
1653 headers
1654 .get(name)
1655 .and_then(|v| v.to_str().ok())
1656 .map(str::to_string)
1657 };
1658 Precondition {
1659 if_match: header("if-match"),
1660 if_none_match: header("if-none-match"),
1661 }
1662}
1663
1664// ---------------------------------------------------------------------------
1665// REPORT
1666// ---------------------------------------------------------------------------
1667
1668impl Cx<'_> {
1669 async fn report(&self, target: &Target, body: Body) -> Reply {
1670 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1671 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1672 };
1673 let report = match report::parse(&body) {
1674 Ok(r) => r,
1675 Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
1676 Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
1677 };
1678 let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
1679 let on_principals = matches!(
1680 target,
1681 Target::Root | Target::Principals | Target::Principal(_)
1682 );
1683 match report {
1684 Report::PrincipalSearch(search) if on_principals => {
1685 return self.principal_search(&search).await;
1686 }
1687 Report::PrincipalSearchPropertySet if on_principals => {
1688 return Ok(search_property_set());
1689 }
1690 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
1691 return unsupported();
1692 }
1693 _ => {}
1694 }
1695 let Target::Collection(kind, _, slug) = target else {
1696 return unsupported();
1697 };
1698 let calendar_report = matches!(
1699 report,
1700 Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
1701 );
1702 let card_report = matches!(
1703 report,
1704 Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
1705 );
1706 if (calendar_report && *kind != PimKind::Calendar)
1707 || (card_report && *kind != PimKind::AddressBook)
1708 {
1709 return unsupported();
1710 }
1711 let Some(Col { c: col, .. }) = self.collection(*kind, slug).await? else {
1712 return Ok(status(StatusCode::NOT_FOUND));
1713 };
1714 // Busy time comes from calendars, never from messages (RFC 6638, 2.3).
1715 if col.slug == INBOX && matches!(report, Report::FreeBusy(_)) {
1716 return unsupported();
1717 }
1718 let floating = col
1719 .timezone
1720 .as_deref()
1721 .and_then(zone::from_vtimezone)
1722 .unwrap_or(Zone::Utc);
1723 let out = Out {
1724 cx: self,
1725 kind: *kind,
1726 col: &col,
1727 };
1728
1729 match report {
1730 Report::CalendarMultiget { props, hrefs }
1731 | Report::AddressbookMultiget { props, hrefs } => {
1732 let mut responses = Vec::new();
1733 for href in hrefs {
1734 let found = match self.own_object(*kind, &href) {
1735 Some((slug, name)) if slug == col.slug => self.member(&col, &name).await?,
1736 _ => None,
1737 };
1738 responses.push(match found {
1739 // The href as the client wrote it, so it can match it.
1740 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
1741 Ok(r) => xml::Response { href, ..r },
1742 Err(TooManyInstances) => return Ok(too_many()),
1743 },
1744 None => xml::Response::status(href, 404),
1745 });
1746 }
1747 Ok(multistatus(&responses, None))
1748 }
1749 Report::CalendarQuery {
1750 props,
1751 filter,
1752 timezone,
1753 } => {
1754 let floating = timezone.unwrap_or(floating);
1755 let mut responses = Vec::new();
1756 for (o, data) in self.members(&col).await? {
1757 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
1758 continue;
1759 };
1760 if filter::matches_calendar(&cal, &filter, &floating) {
1761 match out.object(&o, &data, &props, &floating) {
1762 Ok(r) => responses.push(r),
1763 Err(TooManyInstances) => return Ok(too_many()),
1764 }
1765 }
1766 }
1767 Ok(multistatus(&responses, None))
1768 }
1769 Report::AddressbookQuery {
1770 props,
1771 filter,
1772 limit,
1773 } => {
1774 let mut responses = Vec::new();
1775 let mut truncated = false;
1776 for (o, data) in self.members(&col).await? {
1777 let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
1778 continue;
1779 };
1780 if !filter::matches_card(&card, &filter) {
1781 continue;
1782 }
1783 if limit.is_some_and(|n| responses.len() >= n) {
1784 truncated = true;
1785 break;
1786 }
1787 if let Ok(r) = out.object(&o, &data, &props, &floating) {
1788 responses.push(r);
1789 }
1790 }
1791 if truncated {
1792 responses.push(out.over_limit());
1793 }
1794 Ok(multistatus(&responses, None))
1795 }
1796 Report::SyncCollection {
1797 token,
1798 props,
1799 limit,
1800 } => {
1801 let since = match token.is_empty() {
1802 true => None,
1803 false => match parse_sync_token(&token) {
1804 // The system address book has no change log: only
1805 // its current token is valid.
1806 Some((DIRECTORY, seq)) if col.id == DIRECTORY && seq == col.seq => {
1807 Some(seq)
1808 }
1809 Some((id, seq))
1810 if id == col.id && col.id != DIRECTORY && seq <= col.seq =>
1811 {
1812 Some(seq)
1813 }
1814 _ => {
1815 return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token")));
1816 }
1817 },
1818 };
1819 let mut changes = if col.id == DIRECTORY {
1820 match since {
1821 Some(_) => Vec::new(),
1822 None => self
1823 .members(&col)
1824 .await?
1825 .into_iter()
1826 .map(|(o, _)| (o.name, col.seq, false))
1827 .collect(),
1828 }
1829 } else {
1830 self.state.db.pim_changes(col.id, since).await?
1831 };
1832 let truncated = limit.is_some_and(|n| changes.len() > n);
1833 if let Some(n) = limit {
1834 changes.truncate(n);
1835 }
1836 // A truncated answer hands out the token of its last change, so
1837 // the next sync resumes after it.
1838 let seq = match (truncated, changes.last()) {
1839 (true, Some((_, s, _))) if col.id != DIRECTORY => *s,
1840 _ if col.id == DIRECTORY => col.seq,
1841 (_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
1842 };
1843 let mut responses = Vec::new();
1844 for (name, _, deleted) in changes {
1845 let href = self.space().object(*kind, &col.slug, &name);
1846 let found = match deleted {
1847 true => None,
1848 false => self.member(&col, &name).await?,
1849 };
1850 responses.push(match found {
1851 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
1852 Ok(r) => r,
1853 Err(TooManyInstances) => return Ok(too_many()),
1854 },
1855 None => xml::Response::status(href, 404),
1856 });
1857 }
1858 if truncated {
1859 responses.push(out.over_limit());
1860 }
1861 Ok(multistatus(
1862 &responses,
1863 Some(with_text(el(DAV, "sync-token"), sync_token(col.id, seq))),
1864 ))
1865 }
1866 Report::FreeBusy(range) => {
1867 let mut busy = Vec::new();
1868 for (_, data) in self.members(&col).await? {
1869 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
1870 // ponytail: one period per instance, so a long range over
1871 // a frequent series makes a long answer.
1872 busy.extend(freebusy::busy(&cal, &range, &floating, None));
1873 }
1874 }
1875 let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
1876 Ok((
1877 StatusCode::OK,
1878 [(CONTENT_TYPE, "text/calendar; charset=utf-8")],
1879 body,
1880 )
1881 .into_response())
1882 }
1883 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
1884 unreachable!("answered above")
1885 }
1886 }
1887 }
1888
1889 /// principal-property-search and calendarserver-principal-search.
1890 async fn principal_search(&self, search: &Search) -> Reply {
1891 let mut responses = Vec::new();
1892 let mut truncated = false;
1893 for p in self.state.db.pim_principals().await? {
1894 let view = PrincipalView::of(&p, self.me);
1895 let addresses = view.addresses();
1896 let candidate = Principal {
1897 name: &p.name,
1898 display: p.display(),
1899 addresses: &addresses,
1900 kind: p.kind,
1901 };
1902 if !search.matches(&candidate) {
1903 continue;
1904 }
1905 if search.limit.is_some_and(|n| responses.len() >= n) {
1906 truncated = true;
1907 break;
1908 }
1909 let href = principal_href(&p.name);
1910 responses.push(select(
1911 href,
1912 &search.find,
1913 self.props(&Res::Principal(view)),
1914 ));
1915 }
1916 if truncated {
1917 let mut r = xml::Response::status(format!("{PIM}/principals/"), 507);
1918 r.error = Some(el(DAV, "number-of-matches-within-limits"));
1919 responses.push(r);
1920 }
1921 Ok(multistatus(&responses, None))
1922 }
1923
1924 /// `(collection slug, object name)` of an href to an object of `kind` in
1925 /// the space of this request. Takes a path or a full URL.
1926 fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> {
1927 let path = match href.starts_with('/') {
1928 true => href.to_string(),
1929 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
1930 };
1931 let space = self.space?;
1932 match parse_target(path.strip_prefix(PIM)?)? {
1933 Target::Object(k, owner, slug, name)
1934 if k == kind && owner.eq_ignore_ascii_case(&space.path) =>
1935 {
1936 Some((slug, name))
1937 }
1938 _ => None,
1939 }
1940 }
1941}
1942
1943fn search_property_set() -> Response<Body> {
1944 let body = xml::document(&with_children(
1945 el(DAV, "principal-search-property-set"),
1946 principal::SEARCHABLE.map(|(ns, local, description)| {
1947 with_children(
1948 el(DAV, "principal-search-property"),
1949 [
1950 with_children(el(DAV, "prop"), [el(ns, local)]),
1951 with_attr(
1952 with_text(el(DAV, "description"), description),
1953 "xml:lang",
1954 "en",
1955 ),
1956 ],
1957 )
1958 }),
1959 ));
1960 xml_response(StatusCode::OK, body)
1961}
1962
1963/// What a REPORT answer about one collection needs.
1964struct Out<'a> {
1965 cx: &'a Cx<'a>,
1966 kind: PimKind,
1967 col: &'a PimCollection,
1968}
1969
1970impl Out<'_> {
1971 fn object(
1972 &self,
1973 o: &PimObject,
1974 data: &[u8],
1975 props: &Props,
1976 floating: &Zone,
1977 ) -> Result<xml::Response, TooManyInstances> {
1978 let mut all = self.cx.props(&Res::Object(self.kind, o.clone()));
1979 let raw = String::from_utf8_lossy(data);
1980 if let Some(req) = &props.calendar {
1981 let text = render::calendar_data(&raw, req, floating)?;
1982 all.push(with_text(el(CALDAV, "calendar-data"), text));
1983 }
1984 if let Some(req) = &props.address {
1985 all.push(with_text(
1986 el(CARDDAV, "address-data"),
1987 render::address_data(&raw, req),
1988 ));
1989 }
1990 let href = self.cx.space().object(self.kind, &self.col.slug, &o.name);
1991 Ok(select(href, &props.find, all))
1992 }
1993
1994 /// The response a query or sync adds when a client limit cut it short.
1995 fn over_limit(&self) -> xml::Response {
1996 let href = self.cx.space().collection(self.kind, &self.col.slug);
1997 let mut r = xml::Response::status(href, 507);
1998 r.error = Some(el(DAV, "number-of-matches-within-limits"));
1999 r
2000 }
2001}
2002
2003fn too_many() -> Response<Body> {
2004 error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
2005}
2006
2007/// `(collection id, seq)` of a token [`sync_token`] made.
2008fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
2009 let (id, seq) = token.strip_prefix("urn:fbng:sync:")?.split_once('-')?;
2010 Some((id.parse().ok()?, seq.parse().ok()?))
2011}
2012
2013// ---------------------------------------------------------------------------
2014// POST
2015// ---------------------------------------------------------------------------
2016
2017impl Cx<'_> {
2018 /// A free-busy request to the own scheduling outbox (RFC 6638, 5).
2019 async fn post(&self, target: &Target, body: Body) -> Reply {
2020 let space = match target {
2021 Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX => self.space(),
2022 _ => return Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
2023 };
2024 if !space.mine {
2025 let href = space.collection(PimKind::Calendar, OUTBOX);
2026 return Ok(error(
2027 StatusCode::FORBIDDEN,
2028 need_privilege(&href, CALDAV, "schedule-send-freebusy"),
2029 ));
2030 }
2031 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2032 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2033 };
2034 let request = match freebusy::request(&body) {
2035 Ok(r) => r,
2036 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition.element())),
2037 };
2038 let dir = Directory::load(self.state).await?;
2039 if !dir.is(self.me.pid)(&request.organizer) {
2040 return Ok(error(
2041 StatusCode::FORBIDDEN,
2042 el(CALDAV, "organizer-allowed"),
2043 ));
2044 }
2045 let answers = pim_schedule::free_busy(self.state, &dir, &request).await?;
2046 Ok(xml_response(
2047 StatusCode::OK,
2048 freebusy::schedule_response(&answers),
2049 ))
2050 }
2051}
2052
2053// ---------------------------------------------------------------------------
2054// MOVE
2055// ---------------------------------------------------------------------------
2056
2057impl Cx<'_> {
2058 async fn move_object(&self, target: &Target, headers: &HeaderMap) -> Reply {
2059 let Target::Object(kind, _, slug, name) = target else {
2060 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
2061 };
2062 let destination = headers.get("destination").and_then(|v| v.to_str().ok());
2063 let Some((to_slug, to_name)) = destination.and_then(|d| self.own_object(*kind, d)) else {
2064 return Ok(status(StatusCode::FORBIDDEN));
2065 };
2066 if (&to_slug, &to_name) == (slug, name) {
2067 return Ok(status(StatusCode::FORBIDDEN));
2068 }
2069 let space = self.space();
2070 let Some(from) = self.collection(*kind, slug).await? else {
2071 return Ok(status(StatusCode::NOT_FOUND));
2072 };
2073 let Some(to) = self.collection(*kind, &to_slug).await? else {
2074 return Ok(status(StatusCode::CONFLICT));
2075 };
2076 if from.access < Access::Write || from.c.slug == INBOX {
2077 return Ok(denied(&space.collection(*kind, slug), "unbind"));
2078 }
2079 if to.access < Access::Write || to.c.slug == INBOX {
2080 return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
2081 }
2082 let _lock = pim_schedule::LOCK.lock().await;
2083 let Some((obj, _)) = self.member(&from.c, name).await? else {
2084 return Ok(status(StatusCode::NOT_FOUND));
2085 };
2086 // Moving between calendars schedules nothing (RFC 6638, 3.2.3.4).
2087 if refuses(headers, Some(&obj)) {
2088 return Ok(status(StatusCode::PRECONDITION_FAILED));
2089 }
2090 if *kind == PimKind::Calendar && !to.c.components.split(',').any(|c| c == obj.component) {
2091 return Ok(error(
2092 StatusCode::FORBIDDEN,
2093 el(CALDAV, "supported-calendar-component"),
2094 ));
2095 }
2096 let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
2097 let written = self
2098 .state
2099 .db
2100 .pim_move_object(
2101 from.c.id,
2102 name,
2103 to.c.id,
2104 &to_name,
2105 overwrite,
2106 &precondition(headers),
2107 )
2108 .await?;
2109 Ok(match written {
2110 PimWrite::Created | PimWrite::Updated => {
2111 let code = match written {
2112 PimWrite::Created => StatusCode::CREATED,
2113 _ => StatusCode::NO_CONTENT,
2114 };
2115 let mut r = status(code);
2116 with_schedule_tag(&mut r, obj.schedule_tag.as_deref());
2117 r
2118 }
2119 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
2120 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
2121 PimWrite::UidConflict(holder) => error(
2122 StatusCode::FORBIDDEN,
2123 with_children(
2124 el(kind_ns(*kind), "no-uid-conflict"),
2125 hrefs([space.object(*kind, &to_slug, &holder).as_str()]),
2126 ),
2127 ),
2128 PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
2129 })
2130 }
2131}
2132