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