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