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