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