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::collections::HashSet;
20use std::sync::Arc;
21
22use api_types::PIM;
23use axum::body::Body;
24use axum::extract::State;
25use axum::http::header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LOCATION};
26use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
27use axum::response::IntoResponse;
28use percent_encoding::{
29 AsciiSet, CONTROLS, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode,
30};
31use pimdav::calcard::icalendar::ICalendar;
32use pimdav::calcard::vcard::VCard;
33use pimdav::principal::{self, Principal, Search, UserType};
34use pimdav::render::{self, TooManyInstances};
35use pimdav::report::{self, Props, Refused, Report};
36use pimdav::xml::{
37 self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr,
38 with_children, with_text,
39};
40use pimdav::zone::{self, Zone};
41use pimdav::{contact, filter, freebusy, object};
42
43use super::common::blocking;
44use super::pim_schedule::{self, Directory, Stored, Writer};
45use sha2::{Digest, Sha256};
46use xmltree::Element;
47
48use crate::db::{
49 DeadProp, PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimShareMode, PimWrite,
50 Precondition, PropPlace, User,
51};
52use crate::error::{ApiError, AppState};
53
54/// Largest object a PUT may store. Contacts carry photos inline.
55pub(super) const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
56
57const MAX_SLUG: usize = 255;
58pub(super) const MAX_COLLECTIONS: usize = 100;
59pub(super) const MAX_DISPLAYNAME: usize = 256;
60pub(super) const MAX_DESCRIPTION: usize = 1024;
61
62/// A trimmed name (`lines` false) or description within `max` characters.
63pub(super) fn valid_text(v: &str, max: usize, lines: bool) -> bool {
64 let v = v.trim();
65 v.chars().count() <= max
66 && !v
67 .chars()
68 .any(|c| c.is_control() && !(lines && matches!(c, '\n' | '\r' | '\t')))
69}
70
71/// Largest XML request body.
72const MAX_XML_SIZE: usize = 1024 * 1024;
73
74/// Largest client property the server stores without interpreting it, and
75/// the most one resource may hold.
76const MAX_DEAD_SIZE: usize = 64 * 1024;
77const MAX_DEAD_PROPS: usize = 100;
78
79/// The domain of the addresses users schedule with. `.invalid` is reserved
80/// (RFC 2606), so nothing sent there can reach anyone.
81pub(super) const MAIL_DOMAIN: &str = "dovenest.invalid";
82
83/// The ids of the generated collections, which no stored one has.
84pub(super) const DIRECTORY: i64 = 0;
85pub(super) const BIRTHDAYS: i64 = -1;
86pub(super) const DIRECTORY_SLUG: &str = "system";
87pub(super) const BIRTHDAYS_SLUG: &str = "birthdays";
88/// The slug prefix of a collection lent to the account.
89pub(super) const SHARED_PREFIX: &str = "shared-";
90/// The scheduling inbox is a stored calendar collection under this slug.
91pub(crate) const INBOX: &str = "inbox";
92/// The scheduling outbox holds nothing and is not stored.
93pub(crate) const OUTBOX: &str = "outbox";
94
95/// Characters escaped in an href segment.
96const SEGMENT: &AsciiSet = &CONTROLS
97 .add(b' ')
98 .add(b'"')
99 .add(b'#')
100 .add(b'%')
101 .add(b'/')
102 .add(b'<')
103 .add(b'>')
104 .add(b'?')
105 .add(b'[')
106 .add(b']')
107 .add(b'`')
108 .add(b'{')
109 .add(b'}');
110
111/// Characters a principal name keeps in the local part of its address. The
112/// rest is percent-encoded: `%` is valid there, `@` and spaces are not
113/// (RFC 5322, 3.2.3).
114const LOCAL: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
115/// The same without the dot, for names where a dot would lead, trail or
116/// repeat.
117const LOCAL_NO_DOT: &AsciiSet = &LOCAL.add(b'.');
118
119type Reply = Result<Response<Body>, ApiError>;
120
121/// Up to this many responses a PROPFIND answer is built in place. Larger ones
122/// go to the blocking pool, so they do not stall the async workers.
123const INLINE_RESPONSES: usize = 64;
124
125/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
126///
127/// 307, not 301: HTTP clients drop the body on a 301, and python-caldav sends
128/// its principal search to the URL it was configured with.
129pub async fn well_known() -> Response<Body> {
130 (
131 StatusCode::TEMPORARY_REDIRECT,
132 [(LOCATION, format!("{PIM}/"))],
133 )
134 .into_response()
135}
136
137/// The `DAV` header of every response here. Apple Calendar looks for it on
138/// PROPFIND responses too, not only on OPTIONS.
139pub(super) const COMPLIANCE: &str =
140 "1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, extended-mkcol";
141
142/// `{PIM}` and everything under it.
143pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
144 let mut r = match super::dav::authenticate(&state, req.headers()).await {
145 Some((user_id, _)) => serve(&state, user_id, req)
146 .await
147 .unwrap_or_else(IntoResponse::into_response),
148 None => super::dav::challenge(),
149 };
150 r.headers_mut()
151 .insert("dav", axum::http::HeaderValue::from_static(COMPLIANCE));
152 r
153}
154
155/// The signed-in account.
156#[derive(Clone)]
157struct Me {
158 id: i64,
159 /// The account's principal, which owns its collections.
160 pid: i64,
161 admin: bool,
162 /// The scheduling address, for SENT-BY when acting for someone else.
163 address: String,
164 /// The own principal href. Spelled as the request spelled the name when
165 /// it named this account: a client that asked for `/ALICE/` must get
166 /// hrefs it recognises.
167 principal: String,
168}
169
170/// The principal whose URLs a request addresses: the signed-in account, or
171/// a room or resource. Another account's principal is readable too.
172#[derive(Clone)]
173struct Space {
174 id: i64,
175 /// The URL segment, as the request spelled it.
176 path: String,
177 display: String,
178 kind: UserType,
179 mine: bool,
180}
181
182impl Space {
183 fn principal(&self) -> String {
184 principal_href(&self.path)
185 }
186
187 fn home(&self, kind: PimKind) -> String {
188 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.path))
189 }
190
191 fn collection(&self, kind: PimKind, slug: &str) -> String {
192 format!("{}{}/", self.home(kind), seg(slug))
193 }
194
195 fn object(&self, kind: PimKind, slug: &str, name: &str) -> String {
196 format!("{}{}", self.collection(kind, slug), seg(name))
197 }
198}
199
200/// The URL of a principal.
201pub(crate) fn principal_href(name: &str) -> String {
202 format!("{PIM}/principals/{}/", seg(name))
203}
204
205/// The principal name of a principal URL, given as a path or a full URL.
206pub(super) fn principal_name(href: &str) -> Option<String> {
207 let path = match href.starts_with('/') {
208 true => href.to_string(),
209 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
210 };
211 match parse_target(path.strip_prefix(PIM)?)? {
212 Target::Principal(name) => Some(name),
213 _ => None,
214 }
215}
216
217/// The URL of a collection in the home of `user`, whether it owns it or
218/// has it lent (`lent_id`).
219pub(crate) fn collection_href(
220 user: &str,
221 kind: PimKind,
222 slug: &str,
223 lent_id: Option<i64>,
224) -> String {
225 let slug = match lent_id {
226 Some(id) => format!("{SHARED_PREFIX}{id}"),
227 None => slug.to_string(),
228 };
229 format!("{PIM}/{}/{}/{}/", kind_segment(kind), seg(user), seg(&slug))
230}
231
232/// What the signed-in account may do with a collection.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
234enum Access {
235 Read,
236 /// Change members, not the collection's own properties.
237 Write,
238 /// Also send scheduling messages as the owner.
239 Schedule,
240 Own,
241}
242
243/// A collection as the signed-in account sees it.
244struct Col {
245 /// `slug` and `displayname` as this account sees them.
246 c: PimCollection,
247 access: Access,
248 /// The principal href of the owner.
249 owner: String,
250}
251
252async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
253 let Some(user) = state.db.find_user_by_id(user_id).await? else {
254 return Ok(super::dav::challenge());
255 };
256 let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
257 let Some(target) = parse_target(path) else {
258 return Ok(status(StatusCode::NOT_FOUND));
259 };
260 let (me, space) = match resolve_space(state, &user, &target).await? {
261 Ok(v) => v,
262 Err(code) => return Ok(status(code)),
263 };
264 state.db.pim_ensure_defaults(me.pid).await?;
265
266 let method = req.method().clone();
267 let (parts, body) = req.into_parts();
268 let cx = Cx {
269 state,
270 me: &me,
271 space: space.as_ref(),
272 };
273 match method.as_str() {
274 "OPTIONS" => Ok(options(&target)),
275 "POST" => cx.post(&target, body).await,
276 "PROPFIND" => cx.propfind(&target, &parts.headers, body).await,
277 "PROPPATCH" => cx.proppatch(&target, body).await,
278 "MKCALENDAR" | "MKCOL" => cx.mkcol(&target, method.as_str(), body).await,
279 "GET" | "HEAD" => {
280 cx.get(&target, &parts.headers, method == Method::HEAD)
281 .await
282 }
283 "PUT" => cx.put(&target, &parts.headers, body).await,
284 "DELETE" => cx.delete(&target, &parts.headers).await,
285 "REPORT" => cx.report(&target, body).await,
286 "MOVE" => cx.move_object(&target, &parts.headers).await,
287 _ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
288 }
289}
290
291/// Who asks, and in whose URL space. Another account's space is off limits
292/// except for its principal.
293async fn resolve_space(
294 state: &AppState,
295 user: &User,
296 target: &Target,
297) -> Result<Result<(Me, Option<Space>), StatusCode>, ApiError> {
298 let mut me = Me {
299 id: user.id,
300 pid: state.db.principal_of(user.id).await?,
301 admin: user.is_admin,
302 address: format!("mailto:{}", mailto(&user.name, UserType::Individual)),
303 principal: principal_href(&user.name),
304 };
305 let Some(segment) = target.owner() else {
306 return Ok(Ok((me, None)));
307 };
308 if segment.eq_ignore_ascii_case(&user.name) {
309 me.principal = principal_href(segment);
310 let space = Space {
311 id: me.pid,
312 path: segment.to_string(),
313 display: user.name.clone(),
314 kind: UserType::Individual,
315 mine: true,
316 };
317 return Ok(Ok((me, Some(space))));
318 }
319 let Some(p) = state.db.pim_principal(segment).await? else {
320 return Ok(Err(StatusCode::NOT_FOUND));
321 };
322 if p.kind == UserType::Individual && !matches!(target, Target::Principal(_)) {
323 return Ok(Err(StatusCode::FORBIDDEN));
324 }
325 let space = Space {
326 id: p.id,
327 path: segment.to_string(),
328 display: p.display().to_string(),
329 kind: p.kind,
330 mine: false,
331 };
332 Ok(Ok((me, Some(space))))
333}
334
335#[derive(Debug)]
336enum Target {
337 Root,
338 Principals,
339 Principal(String),
340 Home(PimKind, String),
341 Collection(PimKind, String, String),
342 Object(PimKind, String, String, String),
343}
344
345impl Target {
346 fn owner(&self) -> Option<&str> {
347 match self {
348 Target::Root | Target::Principals => None,
349 Target::Principal(u)
350 | Target::Home(_, u)
351 | Target::Collection(_, u, _)
352 | Target::Object(_, u, _, _) => Some(u),
353 }
354 }
355}
356
357fn parse_target(path: &str) -> Option<Target> {
358 let segs = path
359 .split('/')
360 .filter(|s| !s.is_empty())
361 .map(|s| {
362 let s = percent_decode_str(s).decode_utf8().ok()?;
363 (s != "." && s != "..").then(|| s.into_owned())
364 })
365 .collect::<Option<Vec<_>>>()?;
366 let kind = |s: &str| match s {
367 "calendars" => Some(PimKind::Calendar),
368 "addressbooks" => Some(PimKind::AddressBook),
369 _ => None,
370 };
371 let mut it = segs.into_iter();
372 let Some(first) = it.next() else {
373 return Some(Target::Root);
374 };
375 let rest: Vec<String> = it.collect();
376 if first == "principals" {
377 let mut rest = rest.into_iter();
378 return match (rest.next(), rest.next()) {
379 (None, _) => Some(Target::Principals),
380 (Some(user), None) => Some(Target::Principal(user)),
381 _ => None,
382 };
383 }
384 let kind = kind(&first)?;
385 let mut rest = rest.into_iter();
386 Some(match (rest.next(), rest.next(), rest.next(), rest.next()) {
387 (Some(u), None, None, None) => Target::Home(kind, u),
388 (Some(u), Some(c), None, None) => Target::Collection(kind, u, c),
389 (Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o),
390 _ => return None,
391 })
392}
393
394fn kind_segment(kind: PimKind) -> &'static str {
395 match kind {
396 PimKind::Calendar => "calendars",
397 PimKind::AddressBook => "addressbooks",
398 }
399}
400
401fn kind_ns(kind: PimKind) -> &'static str {
402 match kind {
403 PimKind::Calendar => CALDAV,
404 PimKind::AddressBook => CARDDAV,
405 }
406}
407
408pub(super) fn seg(s: &str) -> String {
409 utf8_percent_encode(s, SEGMENT).to_string()
410}
411
412fn status(code: StatusCode) -> Response<Body> {
413 code.into_response()
414}
415
416fn xml_response(code: StatusCode, body: String) -> Response<Body> {
417 (
418 code,
419 [(CONTENT_TYPE, "application/xml; charset=utf-8")],
420 body,
421 )
422 .into_response()
423}
424
425/// A failed precondition, named in a `<d:error>` body.
426fn error(code: StatusCode, condition: Element) -> Response<Body> {
427 xml_response(code, xml::error(condition))
428}
429
430/// The condition for a lacking privilege on `href` (RFC 3744, 7.1.1).
431pub(super) fn need_privilege(href: &str, ns: &str, privilege: &str) -> Element {
432 with_children(
433 el(DAV, "need-privileges"),
434 [with_children(
435 el(DAV, "resource"),
436 [
437 with_text(el(DAV, "href"), href),
438 with_children(el(DAV, "privilege"), [el(ns, privilege)]),
439 ],
440 )],
441 )
442}
443
444fn denied(href: &str, privilege: &str) -> Response<Body> {
445 error(StatusCode::FORBIDDEN, need_privilege(href, DAV, privilege))
446}
447
448fn options(target: &Target) -> Response<Body> {
449 let outbox = matches!(target, Target::Collection(PimKind::Calendar, _, s) if s == OUTBOX);
450 let allow = match outbox {
451 true => "OPTIONS, PROPFIND, POST",
452 false => {
453 "OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT"
454 }
455 };
456 (StatusCode::OK, [(ALLOW.as_str(), allow)]).into_response()
457}
458
459async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
460 axum::body::to_bytes(body, limit).await.ok()
461}
462
463pub(super) fn etag_of(data: &[u8]) -> String {
464 format!("\"{}\"", crate::hex(&Sha256::digest(data)[..16]))
465}
466
467/// A stable UUID per principal, for the `urn:uuid:` calendar user address.
468pub(super) fn principal_uuid(id: i64) -> String {
469 let h = crate::hex(&Sha256::digest(format!("dovenest principal {id}"))[..16]);
470 format!(
471 "{}-{}-{}-{}-{}",
472 &h[..8],
473 &h[8..12],
474 &h[12..16],
475 &h[16..20],
476 &h[20..]
477 )
478}
479
480/// The scheduling address of a principal. Rooms and resources use their own
481/// subdomains, so no account name can take their address.
482pub(super) fn mailto(name: &str, kind: UserType) -> String {
483 let domain = match kind {
484 UserType::Individual => MAIL_DOMAIN.to_string(),
485 UserType::Room => format!("rooms.{MAIL_DOMAIN}"),
486 UserType::Resource => format!("resources.{MAIL_DOMAIN}"),
487 };
488 format!("{}@{domain}", local_part(name))
489}
490
491/// A principal name as the local part of an address. Decoding the percent
492/// escapes gives the name back.
493pub(super) fn local_part(name: &str) -> String {
494 let set = match name.starts_with('.') || name.ends_with('.') || name.contains("..") {
495 true => LOCAL_NO_DOT,
496 false => LOCAL,
497 };
498 utf8_percent_encode(name, set).to_string()
499}
500
501/// A principal as PROPFIND and the searches describe it.
502struct PrincipalView {
503 id: i64,
504 /// The URL segment.
505 path: String,
506 display: String,
507 kind: UserType,
508 /// The signed-in account itself.
509 me: bool,
510}
511
512impl PrincipalView {
513 fn of(p: &PimPrincipal, me: &Me) -> Self {
514 PrincipalView {
515 id: p.id,
516 path: p.name.clone(),
517 display: p.display().to_string(),
518 kind: p.kind,
519 me: p.id == me.pid,
520 }
521 }
522
523 /// Only the mailto address: Apple takes the first href in order unless
524 /// one is `preferred`, and an attendee matched by its principal URL gets
525 /// no reply buttons. Scheduling still accepts the principal URL and the
526 /// `urn:uuid:` form.
527 fn addresses(&self) -> Vec<String> {
528 vec![format!("mailto:{}", mailto(&self.path, self.kind))]
529 }
530}
531
532// ---------------------------------------------------------------------------
533// Collections and members
534// ---------------------------------------------------------------------------
535
536/// Whether a collection is generated rather than stored.
537pub(super) fn generated(id: i64) -> bool {
538 id <= DIRECTORY
539}
540
541/// A generated collection. Its CTag and sync token come from `source`, what
542/// its members are built from, so they are known without building them.
543/// Only the current token is valid, so a client resyncs after each change.
544fn generated_collection(
545 id: i64,
546 slug: &str,
547 name: &str,
548 components: &str,
549 source: &str,
550) -> PimCollection {
551 // Bump when the members built from the same source change.
552 const FORMAT: &str = "1";
553 let digest = Sha256::digest(format!("{FORMAT}\n{source}"));
554 PimCollection {
555 id,
556 slug: slug.to_string(),
557 displayname: Some(name.to_string()),
558 components: components.to_string(),
559 seq: i64::from_be_bytes(digest[..8].try_into().expect("8 bytes")) & i64::MAX,
560 ..Default::default()
561 }
562}
563
564pub(super) type Members = Vec<(PimObject, Vec<u8>)>;
565type MemberMap = std::collections::HashMap<String, (PimObject, Vec<u8>)>;
566
567/// The generated system address book.
568pub(super) async fn directory_collection(state: &AppState) -> Result<PimCollection, ApiError> {
569 let source: String = state
570 .db
571 .pim_principals(true)
572 .await?
573 .iter()
574 .map(|p| format!("{}\t{}\t{}\t{:?}\n", p.id, p.name, p.display(), p.kind))
575 .collect();
576 Ok(generated_collection(
577 DIRECTORY,
578 DIRECTORY_SLUG,
579 "Directory",
580 "",
581 &source,
582 ))
583}
584
585/// The members of the system address book: one card per visible principal.
586pub(super) async fn directory(state: &AppState) -> Result<Members, ApiError> {
587 let mut members = Vec::new();
588 for p in state.db.pim_principals(true).await? {
589 let uuid = principal_uuid(p.id);
590 let uid = format!("urn:uuid:{uuid}");
591 let addresses: [String; 0] = [];
592 let view = Principal {
593 name: &p.name,
594 display: p.display(),
595 addresses: &addresses,
596 kind: p.kind,
597 };
598 let data = principal::card(&uid, &view, &mailto(&p.name, p.kind)).into_bytes();
599 members.push((
600 generated_object(format!("{uuid}.vcf"), uid, "VCARD", &data),
601 data,
602 ));
603 }
604 Ok(members)
605}
606
607/// The generated birthday calendar of a principal. It changes whenever one
608/// of the principal's own address books does.
609pub(super) async fn birthdays_collection(
610 state: &AppState,
611 principal: i64,
612) -> Result<PimCollection, ApiError> {
613 let source: String = state
614 .db
615 .pim_collections(principal, PimKind::AddressBook)
616 .await?
617 .iter()
618 .map(|b| format!("{}:{}\n", b.id, b.seq))
619 .collect();
620 let mut col = generated_collection(BIRTHDAYS, BIRTHDAYS_SLUG, "Birthdays", "VEVENT", &source);
621 col.transparent = true;
622 Ok(col)
623}
624
625/// The members of the birthday calendar: the birthdays and anniversaries in
626/// the principal's own address books, not lent ones.
627// ponytail: rebuilt from every contact on each request. Store the events if
628// large address books make it slow.
629pub(super) async fn birthdays(state: &AppState, principal: i64) -> Result<Members, ApiError> {
630 let mut books = Vec::new();
631 for book in state
632 .db
633 .pim_collections(principal, PimKind::AddressBook)
634 .await?
635 {
636 books.push((book.id, state.db.pim_objects_with_data(book.id).await?));
637 }
638 blocking(move || -> Result<Members, ApiError> {
639 let mut members = Vec::new();
640 for (book, objects) in books {
641 for (o, data) in objects {
642 let key = format!("{book}/{}", o.name);
643 for (uid, ics) in contact::dates(&String::from_utf8_lossy(&data), &key) {
644 let data = ics.into_bytes();
645 members.push((
646 generated_object(format!("{uid}.ics"), uid, "VEVENT", &data),
647 data,
648 ));
649 }
650 }
651 }
652 Ok(members)
653 })
654 .await
655}
656
657/// The members of collection `id`, stored or generated. `principal` owns
658/// a generated birthday calendar.
659pub(super) async fn members_of(
660 state: &AppState,
661 principal: i64,
662 id: i64,
663) -> Result<Members, ApiError> {
664 match id {
665 DIRECTORY => directory(state).await,
666 BIRTHDAYS => birthdays(state, principal).await,
667 id => Ok(state.db.pim_objects_with_data(id).await?),
668 }
669}
670
671fn generated_object(name: String, uid: String, component: &str, data: &[u8]) -> PimObject {
672 PimObject {
673 name,
674 uid,
675 component: component.to_string(),
676 etag: etag_of(data),
677 size: data.len() as i64,
678 ..Default::default()
679 }
680}
681
682/// The request context: who asks, and in whose URL space.
683struct Cx<'a> {
684 state: &'a AppState,
685 me: &'a Me,
686 space: Option<&'a Space>,
687}
688
689impl Cx<'_> {
690 fn space(&self) -> &Space {
691 self.space.expect("targets with an owner resolve a space")
692 }
693
694 /// A collection of the space by slug, with the access of the signed-in
695 /// account.
696 async fn collection(&self, kind: PimKind, slug: &str) -> Result<Option<Col>, ApiError> {
697 let space = self.space();
698 let db = &self.state.db;
699 if !space.mine {
700 if slug == INBOX {
701 return Ok(None);
702 }
703 // A room: everyone reads its bookings, admins may change and
704 // answer them.
705 let access = if self.me.admin {
706 Access::Schedule
707 } else {
708 Access::Read
709 };
710 return Ok(db.pim_collection(space.id, kind, slug).await?.map(|c| Col {
711 c,
712 access,
713 owner: space.principal(),
714 }));
715 }
716 if let Some(c) = db.pim_collection(space.id, kind, slug).await? {
717 return Ok(Some(Col {
718 c,
719 access: Access::Own,
720 owner: space.principal(),
721 }));
722 }
723 let generated = match (kind, slug) {
724 (PimKind::AddressBook, DIRECTORY_SLUG) => Some(directory_collection(self.state).await?),
725 (PimKind::Calendar, BIRTHDAYS_SLUG) => {
726 Some(birthdays_collection(self.state, space.id).await?)
727 }
728 _ => None,
729 };
730 if let Some(c) = generated {
731 return Ok(Some(Col {
732 c,
733 access: Access::Read,
734 owner: space.principal(),
735 }));
736 }
737 let Some(id) = slug
738 .strip_prefix(SHARED_PREFIX)
739 .and_then(|id| id.parse().ok())
740 else {
741 return Ok(None);
742 };
743 Ok(db
744 .pim_shared_collection(self.me.id, kind, id)
745 .await?
746 .map(|(c, owner, mode)| lent(c, &owner, mode)))
747 }
748
749 /// Every collection of `kind` in the space's home.
750 async fn collections(&self, kind: PimKind) -> Result<Vec<Col>, ApiError> {
751 let space = self.space();
752 let db = &self.state.db;
753 let own = if space.mine {
754 Access::Own
755 } else if self.me.admin {
756 Access::Schedule
757 } else {
758 Access::Read
759 };
760 let mut out: Vec<Col> = db
761 .pim_collections(space.id, kind)
762 .await?
763 .into_iter()
764 .filter(|c| space.mine || c.slug != INBOX)
765 .map(|c| Col {
766 c,
767 access: own,
768 owner: space.principal(),
769 })
770 .collect();
771 if space.mine {
772 let generated = match kind {
773 PimKind::AddressBook => directory_collection(self.state).await?,
774 PimKind::Calendar => birthdays_collection(self.state, space.id).await?,
775 };
776 out.push(Col {
777 c: generated,
778 access: Access::Read,
779 owner: space.principal(),
780 });
781 for (c, owner, mode) in db.pim_shared_collections(self.me.id, kind).await? {
782 out.push(lent(c, &owner, mode));
783 }
784 }
785 Ok(out)
786 }
787
788 async fn members(&self, c: &PimCollection) -> Result<Members, ApiError> {
789 members_of(self.state, self.space().id, c.id).await
790 }
791
792 async fn member_map(&self, c: &PimCollection) -> Result<MemberMap, ApiError> {
793 Ok(self
794 .members(c)
795 .await?
796 .into_iter()
797 .map(|m| (m.0.name.clone(), m))
798 .collect())
799 }
800
801 /// A generated collection is built as a whole, so a REPORT that looks up
802 /// many of its members builds it once.
803 async fn generated_members(&self, c: &PimCollection) -> Result<Option<MemberMap>, ApiError> {
804 match generated(c.id) {
805 true => Ok(Some(self.member_map(c).await?)),
806 false => Ok(None),
807 }
808 }
809
810 async fn member(
811 &self,
812 c: &PimCollection,
813 name: &str,
814 ) -> Result<Option<(PimObject, Vec<u8>)>, ApiError> {
815 if generated(c.id) {
816 let all = self.members(c).await?;
817 return Ok(all.into_iter().find(|(o, _)| o.name == name));
818 }
819 Ok(self.state.db.pim_object(c.id, name).await?)
820 }
821}
822
823/// Deletes a collection of principal `owner`. A calendar's scheduling
824/// objects are cancelled for their attendees first. `Err` names the
825/// precondition that refuses it: the calendar that receives invitations
826/// stays. Takes [`pim_schedule::LOCK`].
827pub(super) async fn delete_own(
828 state: &AppState,
829 owner: i64,
830 kind: PimKind,
831 col: &PimCollection,
832) -> Result<Result<(), Element>, ApiError> {
833 let db = &state.db;
834 // A PUT checks under the lock that its collection still exists.
835 let _lock = pim_schedule::LOCK.lock().await;
836 if kind == PimKind::Calendar && col.slug != INBOX {
837 if db
838 .pim_calendar_for(owner, "VEVENT")
839 .await?
840 .is_some_and(|d| d.id == col.id)
841 {
842 return Ok(Err(el(CALDAV, "default-calendar-needed")));
843 }
844 let dir = Directory::load(state).await?;
845 let owner = dir
846 .get(owner)
847 .cloned()
848 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
849 let mut ops = match pim_schedule::retract(state, &dir, &owner, &[col.id]).await? {
850 Ok(ops) => ops,
851 Err(refused) => return Ok(Err(refused)),
852 };
853 // The cancellations commit with the delete, so no event goes without
854 // its attendees hearing of it.
855 ops.push(PimOp::DeleteCollection(col.id));
856 db.pim_apply(&ops).await?;
857 return Ok(Ok(()));
858 }
859 db.pim_delete_collection(col.id).await?;
860 Ok(Ok(()))
861}
862
863/// A collection lent to the signed-in account, as it appears in their home.
864fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col {
865 let name = c.displayname.take().unwrap_or_else(|| c.slug.clone());
866 c.displayname = Some(format!("{name} ({owner})"));
867 c.slug = format!("{SHARED_PREFIX}{}", c.id);
868 Col {
869 c,
870 access: match mode {
871 PimShareMode::Ro => Access::Read,
872 PimShareMode::Rw => Access::Write,
873 PimShareMode::RwSchedule => Access::Schedule,
874 },
875 owner: principal_href(owner),
876 }
877}
878
879// ---------------------------------------------------------------------------
880// PROPFIND
881// ---------------------------------------------------------------------------
882
883/// A resource PROPFIND can describe.
884enum Res {
885 Root,
886 Principals,
887 Principal(PrincipalView),
888 /// With its owner's principal href, whether the account may add to it,
889 /// and where its client properties live.
890 Home(String, Access, PropPlace),
891 Collection(PimKind, Col),
892 /// With the href of the calendar that receives new invitations.
893 Inbox(Col, Option<String>),
894 /// With its owner's principal href.
895 Outbox(String),
896 Object(PimKind, PimObject),
897}
898
899impl Cx<'_> {
900 async fn propfind(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
901 // Missing means infinity to RFC 4918, but clients that omit it mean 0.
902 let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) {
903 None | Some("0") => false,
904 Some("1") => true,
905 Some(_) => {
906 return Ok(error(
907 StatusCode::FORBIDDEN,
908 el(DAV, "propfind-finite-depth"),
909 ));
910 }
911 };
912 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
913 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
914 };
915 let Ok(request) = xml::propfind(&body) else {
916 return Ok(status(StatusCode::BAD_REQUEST));
917 };
918
919 let mut list: Vec<(String, Res)> = Vec::new();
920 match target {
921 Target::Root => list.push((format!("{PIM}/"), Res::Root)),
922 Target::Principals => {
923 list.push((format!("{PIM}/principals/"), Res::Principals));
924 if deep {
925 for p in self.state.db.pim_principals(true).await? {
926 list.push((
927 principal_href(&p.name),
928 Res::Principal(PrincipalView::of(&p, self.me)),
929 ));
930 }
931 }
932 }
933 Target::Principal(_) => {
934 let s = self.space();
935 list.push((
936 s.principal(),
937 Res::Principal(PrincipalView {
938 id: s.id,
939 path: s.path.clone(),
940 display: s.display.clone(),
941 kind: s.kind,
942 me: s.mine,
943 }),
944 ));
945 }
946 Target::Home(kind, _) => {
947 let s = self.space();
948 let access = if s.mine { Access::Own } else { Access::Read };
949 let place = PropPlace::Home(s.id, *kind);
950 list.push((s.home(*kind), Res::Home(s.principal(), access, place)));
951 if deep {
952 for col in self.collections(*kind).await? {
953 let href = s.collection(*kind, &col.c.slug);
954 list.push((href, self.res(*kind, col).await?));
955 }
956 if *kind == PimKind::Calendar && s.mine {
957 list.push((s.collection(*kind, OUTBOX), Res::Outbox(s.principal())));
958 }
959 }
960 }
961 Target::Collection(PimKind::Calendar, _, slug)
962 if slug == OUTBOX && self.space().mine =>
963 {
964 let s = self.space();
965 list.push((
966 s.collection(PimKind::Calendar, OUTBOX),
967 Res::Outbox(s.principal()),
968 ));
969 }
970 Target::Collection(kind, _, slug) => {
971 let Some(col) = self.collection(*kind, slug).await? else {
972 return Ok(status(StatusCode::NOT_FOUND));
973 };
974 let objects = match (deep, col.c.id) {
975 (false, _) => Vec::new(),
976 (true, id) if generated(id) => self
977 .members(&col.c)
978 .await?
979 .into_iter()
980 .map(|(o, _)| o)
981 .collect(),
982 (true, id) => self.state.db.pim_objects(id).await?,
983 };
984 let s = self.space();
985 let slug = col.c.slug.clone();
986 list.push((s.collection(*kind, &slug), self.res(*kind, col).await?));
987 for o in objects {
988 list.push((s.object(*kind, &slug, &o.name), Res::Object(*kind, o)));
989 }
990 }
991 Target::Object(kind, _, slug, name) => {
992 let found = match self.collection(*kind, slug).await? {
993 Some(col) => self.member(&col.c, name).await?,
994 None => None,
995 };
996 let Some((o, _)) = found else {
997 return Ok(status(StatusCode::NOT_FOUND));
998 };
999 list.push((
1000 self.space().object(*kind, slug, name),
1001 Res::Object(*kind, o),
1002 ));
1003 }
1004 }
1005
1006 let described_len = list.len();
1007 let mut described = Vec::with_capacity(described_len);
1008 for (href, res) in list {
1009 let dead = self.dead_props(&res).await?;
1010 described.push((href, res, dead));
1011 }
1012 let answer = move |me: &Me, space: Option<&Space>| {
1013 let responses: Vec<_> = described
1014 .into_iter()
1015 .map(|(href, res, dead)| {
1016 let mut all = live_props(me, space, &res);
1017 all.extend(dead);
1018 select(href, &request, all)
1019 })
1020 .collect();
1021 multistatus(&responses, None)
1022 };
1023 // A handoff to the blocking pool costs more than a small answer.
1024 if described_len <= INLINE_RESPONSES {
1025 return Ok(answer(self.me, self.space));
1026 }
1027 let (me, space) = (self.me.clone(), self.space.cloned());
1028 blocking(move || -> Reply { Ok(answer(&me, space.as_ref())) }).await
1029 }
1030
1031 /// The client properties stored for a resource. Those of a principal or
1032 /// home only reach the accounts that may write them: they hold another
1033 /// account's client settings.
1034 async fn dead_props(&self, res: &Res) -> Result<Vec<Element>, ApiError> {
1035 let place = match res {
1036 Res::Principal(p) if p.me || (self.me.admin && p.kind != UserType::Individual) => {
1037 PropPlace::Principal(p.id)
1038 }
1039 Res::Home(_, _, place) if self.may_edit(self.space()) => *place,
1040 Res::Collection(_, col) | Res::Inbox(col, _) if !generated(col.c.id) => {
1041 PropPlace::Collection(col.c.id)
1042 }
1043 _ => return Ok(Vec::new()),
1044 };
1045 Ok(self
1046 .state
1047 .db
1048 .pim_props(place)
1049 .await?
1050 .iter()
1051 .filter_map(|p| Element::parse(p.xml.as_bytes()).ok())
1052 .collect())
1053 }
1054
1055 fn props(&self, res: &Res) -> Vec<Element> {
1056 live_props(self.me, self.space, res)
1057 }
1058}
1059
1060/// Every live property of a resource, with its value.
1061fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
1062 let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
1063 let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h]));
1064 let resourcetype = |types: &[(&str, &str)]| {
1065 with_children(
1066 el(DAV, "resourcetype"),
1067 types.iter().map(|(ns, l)| el(ns, l)),
1068 )
1069 };
1070 let principals = format!("{PIM}/principals/");
1071 let mut out = vec![
1072 href_prop(DAV, "current-user-principal", &me.principal),
1073 href_prop(DAV, "principal-collection-set", &principals),
1074 ];
1075 match res {
1076 Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
1077 Res::Principals => out.extend([
1078 resourcetype(&[(DAV, "collection")]),
1079 privileges(Access::Read),
1080 principal_reports(),
1081 ]),
1082 Res::Principal(p) => {
1083 // The own principal in the spelling of the request.
1084 let href = match p.me {
1085 true => me.principal.clone(),
1086 false => principal_href(&p.path),
1087 };
1088 let addresses = p.addresses();
1089 out.extend([
1090 resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
1091 text(DAV, "displayname", &p.display),
1092 href_prop(DAV, "principal-URL", &href),
1093 with_children(
1094 el(CALDAV, "calendar-user-address-set"),
1095 hrefs(addresses.iter().map(String::as_str))
1096 .into_iter()
1097 .map(|h| with_attr(h, "preferred", "1")),
1098 ),
1099 with_children(
1100 el(CALSERVER, "email-address-set"),
1101 [with_text(
1102 el(CALSERVER, "email-address"),
1103 mailto(&p.path, p.kind),
1104 )],
1105 ),
1106 text(CALDAV, "calendar-user-type", p.kind.as_str()),
1107 privileges(if p.me { Access::Own } else { Access::Read }),
1108 principal_reports(),
1109 ]);
1110 let home = |kind: PimKind| {
1111 let name = match p.me {
1112 true => space
1113 .filter(|s| s.mine)
1114 .map_or(p.path.clone(), |s| s.path.clone()),
1115 false => p.path.clone(),
1116 };
1117 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&name))
1118 };
1119 // Also for other accounts: python-caldav drops a search hit
1120 // without one. Their homes still answer 403.
1121 out.push(href_prop(
1122 CALDAV,
1123 "calendar-home-set",
1124 &home(PimKind::Calendar),
1125 ));
1126 if p.me {
1127 let cal = home(PimKind::Calendar);
1128 out.push(href_prop(
1129 CALDAV,
1130 "schedule-inbox-URL",
1131 &format!("{cal}{INBOX}/"),
1132 ));
1133 out.push(href_prop(
1134 CALDAV,
1135 "schedule-outbox-URL",
1136 &format!("{cal}{OUTBOX}/"),
1137 ));
1138 let book = home(PimKind::AddressBook);
1139 out.push(href_prop(CARDDAV, "addressbook-home-set", &book));
1140 out.push(href_prop(
1141 CARDDAV,
1142 "directory-gateway",
1143 &format!("{book}{DIRECTORY_SLUG}/"),
1144 ));
1145 }
1146 }
1147 Res::Home(owner, access, _) => out.extend([
1148 resourcetype(&[(DAV, "collection")]),
1149 href_prop(DAV, "owner", owner),
1150 privileges(*access),
1151 ]),
1152 Res::Collection(kind, col) => {
1153 let c = &col.c;
1154 let (types, desc) = match kind {
1155 PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
1156 PimKind::AddressBook => (
1157 (CARDDAV, "addressbook"),
1158 (CARDDAV, "addressbook-description"),
1159 ),
1160 };
1161 out.extend([
1162 resourcetype(&[(DAV, "collection"), types]),
1163 href_prop(DAV, "owner", &col.owner),
1164 privileges(col.access),
1165 supported_reports(*kind),
1166 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
1167 text(DAV, "sync-token", &sync_token(c.id, c.seq, None)),
1168 text(
1169 kind_ns(*kind),
1170 "max-resource-size",
1171 &MAX_RESOURCE_SIZE.to_string(),
1172 ),
1173 ]);
1174 if let Some(v) = &c.displayname {
1175 out.push(text(DAV, "displayname", v));
1176 }
1177 if let Some(v) = &c.description {
1178 out.push(text(desc.0, desc.1, v));
1179 }
1180 match kind {
1181 PimKind::Calendar => {
1182 out.push(with_children(
1183 el(CALDAV, "supported-calendar-component-set"),
1184 c.components
1185 .split(',')
1186 .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)),
1187 ));
1188 out.push(with_children(
1189 el(CALDAV, "supported-calendar-data"),
1190 [with_attr(
1191 with_attr(el(CALDAV, "calendar-data"), "content-type", "text/calendar"),
1192 "version",
1193 "2.0",
1194 )],
1195 ));
1196 if let Some(v) = &c.color {
1197 out.push(text(APPLE, "calendar-color", v));
1198 }
1199 if let Some(v) = &c.sort_order {
1200 out.push(text(APPLE, "calendar-order", v));
1201 }
1202 if let Some(v) = &c.timezone {
1203 out.push(text(CALDAV, "calendar-timezone", v));
1204 }
1205 out.push(with_children(
1206 el(CALDAV, "schedule-calendar-transp"),
1207 [el(
1208 CALDAV,
1209 if c.transparent {
1210 "transparent"
1211 } else {
1212 "opaque"
1213 },
1214 )],
1215 ));
1216 }
1217 // 3.0 only: a client told of 4.0 writes 4.0 groups, which
1218 // Apple Contacts on the same account cannot read. A 4.0
1219 // PUT is still stored, and served as 4.0 on request.
1220 PimKind::AddressBook => out.push(with_children(
1221 el(CARDDAV, "supported-address-data"),
1222 [with_attr(
1223 with_attr(
1224 el(CARDDAV, "address-data-type"),
1225 "content-type",
1226 "text/vcard",
1227 ),
1228 "version",
1229 "3.0",
1230 )],
1231 )),
1232 }
1233 }
1234 Res::Inbox(col, default) => {
1235 let c = &col.c;
1236 out.extend([
1237 resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-inbox")]),
1238 href_prop(DAV, "owner", &col.owner),
1239 privilege_set(INBOX_PRIVILEGES),
1240 report_set(&[
1241 (CALDAV, "calendar-multiget"),
1242 (CALDAV, "calendar-query"),
1243 (DAV, "sync-collection"),
1244 ]),
1245 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
1246 text(DAV, "sync-token", &sync_token(c.id, c.seq, None)),
1247 ]);
1248 if let Some(v) = &c.displayname {
1249 out.push(text(DAV, "displayname", v));
1250 }
1251 if let Some(h) = default {
1252 out.push(href_prop(CALDAV, "schedule-default-calendar-URL", h));
1253 }
1254 }
1255 Res::Outbox(owner) => out.extend([
1256 resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-outbox")]),
1257 href_prop(DAV, "owner", owner),
1258 privilege_set(OUTBOX_PRIVILEGES),
1259 ]),
1260 Res::Object(kind, o) => {
1261 if let Some(tag) = &o.schedule_tag {
1262 out.push(text(CALDAV, "schedule-tag", tag));
1263 }
1264 out.extend([
1265 resourcetype(&[]),
1266 text(DAV, "getetag", &o.etag),
1267 text(DAV, "getcontenttype", &content_type(*kind, &o.component)),
1268 text(DAV, "getcontentlength", &o.size.to_string()),
1269 ]);
1270 if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) {
1271 let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1272 out.push(text(DAV, "getlastmodified", &http_date));
1273 }
1274 }
1275 }
1276 out
1277}
1278
1279impl Cx<'_> {
1280 /// How PROPFIND describes a collection. The inbox names the calendar
1281 /// that receives new invitations.
1282 async fn res(&self, kind: PimKind, col: Col) -> Result<Res, ApiError> {
1283 if kind != PimKind::Calendar || col.c.slug != INBOX {
1284 return Ok(Res::Collection(kind, col));
1285 }
1286 let space = self.space();
1287 let default = self
1288 .state
1289 .db
1290 .pim_calendar_for(space.id, "VEVENT")
1291 .await?
1292 .map(|c| space.collection(PimKind::Calendar, &c.slug));
1293 Ok(Res::Inbox(col, default))
1294 }
1295}
1296
1297/// The response for one resource: the requested ones of `all`, and 404 for
1298/// those it lacks.
1299fn select(href: String, request: &Propfind, all: Vec<Element>) -> xml::Response {
1300 let mut r = xml::Response::new(href);
1301 match request {
1302 Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
1303 Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
1304 Propfind::Prop(names) => {
1305 for n in names {
1306 match all.iter().find(|p| Name::of(p) == *n) {
1307 Some(p) => r.push(200, p.clone()),
1308 None => r.push(404, n.element()),
1309 }
1310 }
1311 }
1312 }
1313 if r.propstats.is_empty() {
1314 r.status = Some(200);
1315 }
1316 r
1317}
1318
1319fn multistatus(responses: &[xml::Response], tail: Option<Element>) -> Response<Body> {
1320 xml_response(
1321 StatusCode::MULTI_STATUS,
1322 xml::multistatus_with(&Name::new(DAV, "multistatus"), responses, tail),
1323 )
1324}
1325
1326fn report_set(reports: &[(&str, &str)]) -> Element {
1327 with_children(
1328 el(DAV, "supported-report-set"),
1329 reports.iter().map(|(ns, local)| {
1330 with_children(
1331 el(DAV, "supported-report"),
1332 [with_children(el(DAV, "report"), [el(ns, local)])],
1333 )
1334 }),
1335 )
1336}
1337
1338fn supported_reports(kind: PimKind) -> Element {
1339 report_set(match kind {
1340 PimKind::Calendar => &[
1341 (CALDAV, "calendar-multiget"),
1342 (CALDAV, "calendar-query"),
1343 (CALDAV, "free-busy-query"),
1344 (DAV, "sync-collection"),
1345 ],
1346 PimKind::AddressBook => &[
1347 (CARDDAV, "addressbook-multiget"),
1348 (CARDDAV, "addressbook-query"),
1349 (DAV, "sync-collection"),
1350 ],
1351 })
1352}
1353
1354fn principal_reports() -> Element {
1355 report_set(&[
1356 (DAV, "principal-property-search"),
1357 (DAV, "principal-search-property-set"),
1358 (CALSERVER, "calendarserver-principal-search"),
1359 ])
1360}
1361
1362fn privileges(access: Access) -> Element {
1363 const WRITE: [(&str, &str); 5] = [
1364 (DAV, "read"),
1365 (DAV, "write-content"),
1366 (DAV, "bind"),
1367 (DAV, "unbind"),
1368 (DAV, "read-current-user-privilege-set"),
1369 ];
1370 let names: Vec<(&str, &str)> = match access {
1371 Access::Own => [
1372 "all",
1373 "read",
1374 "write",
1375 "write-properties",
1376 "write-content",
1377 "bind",
1378 "unbind",
1379 "read-current-user-privilege-set",
1380 ]
1381 .map(|n| (DAV, n))
1382 .to_vec(),
1383 // RFC 6638 grants these on the outbox, which a sharee cannot see.
1384 Access::Schedule => [
1385 (CALDAV, "schedule-send"),
1386 (CALDAV, "schedule-send-invite"),
1387 (CALDAV, "schedule-send-reply"),
1388 ]
1389 .into_iter()
1390 .chain(WRITE)
1391 .collect(),
1392 Access::Write => WRITE.to_vec(),
1393 Access::Read => vec![(DAV, "read"), (DAV, "read-current-user-privilege-set")],
1394 };
1395 privilege_set(names)
1396}
1397
1398/// The owner reads and empties the inbox; only the server delivers into it.
1399const INBOX_PRIVILEGES: [(&str, &str); 7] = [
1400 (DAV, "read"),
1401 (DAV, "unbind"),
1402 (DAV, "read-current-user-privilege-set"),
1403 (CALDAV, "schedule-deliver"),
1404 (CALDAV, "schedule-deliver-invite"),
1405 (CALDAV, "schedule-deliver-reply"),
1406 (CALDAV, "schedule-query-freebusy"),
1407];
1408
1409const OUTBOX_PRIVILEGES: [(&str, &str); 6] = [
1410 (DAV, "read"),
1411 (DAV, "read-current-user-privilege-set"),
1412 (CALDAV, "schedule-send"),
1413 (CALDAV, "schedule-send-invite"),
1414 (CALDAV, "schedule-send-reply"),
1415 (CALDAV, "schedule-send-freebusy"),
1416];
1417
1418fn privilege_set<'a>(names: impl IntoIterator<Item = (&'a str, &'a str)>) -> Element {
1419 with_children(
1420 el(DAV, "current-user-privilege-set"),
1421 names
1422 .into_iter()
1423 .map(|(ns, n)| with_children(el(DAV, "privilege"), [el(ns, n)])),
1424 )
1425}
1426
1427/// Carries the collection id, so a token handed out for a deleted
1428/// collection never matches the one that later takes its URL. A cut initial
1429/// sync also carries `issued`, the collection seq it began at.
1430fn sync_token(id: i64, seq: i64, issued: Option<i64>) -> String {
1431 match issued {
1432 Some(i) => format!("urn:dovenest:sync:{id}-{seq}.{i}"),
1433 None => format!("urn:dovenest:sync:{id}-{seq}"),
1434 }
1435}
1436
1437fn content_type(kind: PimKind, component: &str) -> String {
1438 match kind {
1439 PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
1440 PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
1441 }
1442}
1443
1444// ---------------------------------------------------------------------------
1445// PROPPATCH, MKCALENDAR, MKCOL
1446// ---------------------------------------------------------------------------
1447
1448impl Cx<'_> {
1449 async fn proppatch(&self, target: &Target, body: Body) -> Reply {
1450 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1451 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1452 };
1453 let Ok(mut update) = xml::update(&body) else {
1454 return Ok(status(StatusCode::BAD_REQUEST));
1455 };
1456 // Read before the lock, so a slow client cannot hold it.
1457 let _lock = pim_schedule::LOCK.lock().await;
1458 let (href, place, res, mut col) = match target {
1459 Target::Collection(kind, _, slug) => {
1460 let Some(col) = self.collection(*kind, slug).await? else {
1461 return Ok(status(StatusCode::NOT_FOUND));
1462 };
1463 let href = self.space().collection(*kind, slug);
1464 if col.access != Access::Own {
1465 return Ok(denied(&href, "write-properties"));
1466 }
1467 let place = PropPlace::Collection(col.c.id);
1468 let stored = (*kind, col.c.clone());
1469 (href, place, self.res(*kind, col).await?, Some(stored))
1470 }
1471 Target::Home(kind, _) => {
1472 let s = self.space();
1473 if !self.may_edit(s) {
1474 return Ok(denied(&s.home(*kind), "write-properties"));
1475 }
1476 let place = PropPlace::Home(s.id, *kind);
1477 let res = Res::Home(s.principal(), Access::Own, place);
1478 (s.home(*kind), place, res, None)
1479 }
1480 Target::Principal(_) => {
1481 let s = self.space();
1482 if !self.may_edit(s) {
1483 return Ok(denied(&s.principal(), "write-properties"));
1484 }
1485 let view = PrincipalView {
1486 id: s.id,
1487 path: s.path.clone(),
1488 display: s.display.clone(),
1489 kind: s.kind,
1490 me: s.mine,
1491 };
1492 let place = PropPlace::Principal(s.id);
1493 (s.principal(), place, Res::Principal(view), None)
1494 }
1495 _ => return Ok(status(StatusCode::FORBIDDEN)),
1496 };
1497 let before = col.as_ref().map(|(_, c)| c.clone());
1498 // The inbox names the calendar that receives invitations (RFC 6638,
1499 // 9.2). `Some(Err(()))`: it names none of the owner's calendars.
1500 let default_url = Name::new(CALDAV, "schedule-default-calendar-URL");
1501 let mut default = None;
1502 if matches!(res, Res::Inbox(..)) {
1503 if let Some(i) = update.set.iter().position(|p| Name::of(p) == default_url) {
1504 let p = update.set.remove(i);
1505 let href = xml::child(&p, DAV, "href").map(xml::text);
1506 default = Some(match href {
1507 Some(h) => self.receiving_calendar(&h).await?.map(Some).ok_or(()),
1508 None => Err(()),
1509 });
1510 } else if let Some(i) = update.remove.iter().position(|n| *n == default_url) {
1511 update.remove.remove(i);
1512 default = Some(Ok(None));
1513 }
1514 }
1515 let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
1516 let stored = self.state.db.pim_props(place).await?;
1517 let mut patch = apply(
1518 col.as_mut().map(|(k, c)| (*k, c)),
1519 &update,
1520 false,
1521 &live,
1522 &stored,
1523 );
1524 let default_ok = !matches!(default, Some(Err(())));
1525 if !default_ok {
1526 for (code, _) in &mut patch.results {
1527 if *code == 200 {
1528 *code = 424;
1529 }
1530 }
1531 }
1532 let all_ok = patch.ok() && default_ok;
1533 if all_ok {
1534 let db = &self.state.db;
1535 db.pim_patch(
1536 place,
1537 before.as_ref().zip(col.as_ref().map(|(_, c)| c)),
1538 &patch.set,
1539 &patch.remove,
1540 )
1541 .await?;
1542 if let Some(Ok(id)) = default {
1543 db.pim_set_default_calendar(self.space().id, id).await?;
1544 }
1545 }
1546 let mut r = xml::Response::new(href);
1547 r.error = match (default_ok, patch.protected) {
1548 (false, _) => Some(el(CALDAV, "valid-schedule-default-calendar-URL")),
1549 (true, true) => Some(el(DAV, "cannot-modify-protected-property")),
1550 (true, false) => None,
1551 };
1552 for (code, prop) in patch.results {
1553 r.push(code, prop);
1554 }
1555 if let Some(d) = default {
1556 let code = match (d, all_ok) {
1557 (Err(()), _) => 403,
1558 (Ok(_), true) => 200,
1559 (Ok(_), false) => 424,
1560 };
1561 r.push(code, default_url.element());
1562 }
1563 Ok(multistatus(&[r], None))
1564 }
1565
1566 /// The id of the own calendar at `href` that can receive invitations:
1567 /// stored, not the inbox, taking events.
1568 async fn receiving_calendar(&self, href: &str) -> Result<Option<i64>, ApiError> {
1569 let path = match href.starts_with('/') {
1570 true => href.to_string(),
1571 false => match href.parse::<axum::http::Uri>() {
1572 Ok(u) => u.path().to_string(),
1573 Err(_) => return Ok(None),
1574 },
1575 };
1576 let space = self.space();
1577 let slug = match path.strip_prefix(PIM).and_then(parse_target) {
1578 Some(Target::Collection(PimKind::Calendar, owner, slug))
1579 if owner.eq_ignore_ascii_case(&space.path) =>
1580 {
1581 slug
1582 }
1583 _ => return Ok(None),
1584 };
1585 Ok(self
1586 .collection(PimKind::Calendar, &slug)
1587 .await?
1588 .filter(|c| {
1589 c.access == Access::Own
1590 && !generated(c.c.id)
1591 && c.c.slug != INBOX
1592 && c.c.components.split(',').any(|x| x == "VEVENT")
1593 })
1594 .map(|c| c.c.id))
1595 }
1596
1597 /// The owner changes the properties of its principal and homes, admins
1598 /// those of rooms and resources.
1599 fn may_edit(&self, s: &Space) -> bool {
1600 s.mine || (self.me.admin && s.kind != UserType::Individual)
1601 }
1602
1603 async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply {
1604 let Target::Collection(kind, _, slug) = target else {
1605 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1606 };
1607 let space = self.space();
1608 if !space.mine {
1609 return Ok(denied(&space.home(*kind), "bind"));
1610 }
1611 let calendar = method == "MKCALENDAR";
1612 if calendar && *kind != PimKind::Calendar {
1613 return Ok(status(StatusCode::FORBIDDEN));
1614 }
1615 if self.collection(*kind, slug).await?.is_some() {
1616 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1617 }
1618 // Names the home shows for lent and generated collections.
1619 if slug.starts_with(SHARED_PREFIX)
1620 || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
1621 || slug.len() > MAX_SLUG
1622 {
1623 return Ok(status(StatusCode::FORBIDDEN));
1624 }
1625 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1626 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1627 };
1628 let Ok(update) = xml::update(&body) else {
1629 return Ok(status(StatusCode::BAD_REQUEST));
1630 };
1631 // A plain MKCOL makes a plain collection, which a calendar home cannot
1632 // hold. An address book home takes it as an address book.
1633 let typed = update
1634 .set
1635 .iter()
1636 .any(|p| Name::of(p).is(DAV, "resourcetype"));
1637 if !calendar && *kind == PimKind::Calendar && !typed {
1638 return Ok(status(StatusCode::FORBIDDEN));
1639 }
1640 let mut col = PimCollection {
1641 slug: slug.clone(),
1642 components: match kind {
1643 PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(),
1644 PimKind::AddressBook => String::new(),
1645 },
1646 ..Default::default()
1647 };
1648 let res = Res::Collection(
1649 *kind,
1650 Col {
1651 c: col.clone(),
1652 access: Access::Own,
1653 owner: space.principal(),
1654 },
1655 );
1656 let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
1657 let patch = apply(Some((*kind, &mut col)), &update, true, &live, &[]);
1658 if !patch.ok() {
1659 let root = match calendar {
1660 true => Name::new(CALDAV, "mkcalendar-response"),
1661 false => Name::new(DAV, "mkcol-response"),
1662 };
1663 let propstats = group(patch.results);
1664 return Ok(xml_response(
1665 StatusCode::FORBIDDEN,
1666 xml::propstat_document(&root, &propstats),
1667 ));
1668 }
1669 let _lock = pim_schedule::LOCK.lock().await;
1670 let count = self.state.db.pim_collections(self.me.pid, *kind).await?;
1671 if count.iter().filter(|c| c.slug != INBOX).count() >= MAX_COLLECTIONS {
1672 return Ok(status(StatusCode::FORBIDDEN));
1673 }
1674 if !self
1675 .state
1676 .db
1677 .pim_create_collection(self.me.pid, *kind, &col, &patch.set)
1678 .await?
1679 {
1680 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1681 }
1682 Ok(status(StatusCode::CREATED))
1683 }
1684}
1685
1686fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
1687 let mut r = xml::Response::default();
1688 for (code, prop) in results {
1689 r.push(code, prop);
1690 }
1691 r.propstats
1692}
1693
1694/// A property update: each property with its status, and the client
1695/// properties to store and remove.
1696struct Patch {
1697 results: Vec<(u16, Element)>,
1698 set: Vec<DeadProp>,
1699 remove: Vec<(String, String)>,
1700 /// A property the server computes was named.
1701 protected: bool,
1702}
1703
1704impl Patch {
1705 fn ok(&self) -> bool {
1706 self.results.iter().all(|(code, _)| *code == 200)
1707 }
1708}
1709
1710/// DAV properties the server computes on some resource, beyond the ones
1711/// `live` names for the resource at hand.
1712const PROTECTED: [&str; 20] = [
1713 "acl",
1714 "alternate-URI-set",
1715 "creationdate",
1716 "current-user-principal",
1717 "current-user-privilege-set",
1718 "getcontentlength",
1719 "getcontenttype",
1720 "getetag",
1721 "getlastmodified",
1722 "group",
1723 "group-member-set",
1724 "group-membership",
1725 "lockdiscovery",
1726 "owner",
1727 "principal-URL",
1728 "principal-collection-set",
1729 "resourcetype",
1730 "supported-report-set",
1731 "supportedlock",
1732 "sync-token",
1733];
1734
1735/// Applies a PROPPATCH, MKCALENDAR or extended MKCOL body. A collection's
1736/// own properties go into `col`. What the server computes (`live`, or a
1737/// [`PROTECTED`] DAV property) is refused; anything else is stored as the
1738/// client sent it, as clients expect of properties such as Apple's
1739/// `default-alarm-vevent-date`. Nothing may be stored unless all of it is
1740/// allowed: RFC 4918 makes PROPPATCH atomic.
1741fn apply(
1742 mut col: Option<(PimKind, &mut PimCollection)>,
1743 update: &Update,
1744 creating: bool,
1745 live: &[Name],
1746 stored: &[DeadProp],
1747) -> Patch {
1748 let mut patch = Patch {
1749 results: Vec::new(),
1750 set: Vec::new(),
1751 remove: Vec::new(),
1752 protected: false,
1753 };
1754 let is_protected =
1755 |n: &Name| live.contains(n) || (n.ns == DAV && PROTECTED.contains(&n.local.as_str()));
1756 for p in &update.set {
1757 let name = Name::of(p);
1758 let own = col
1759 .as_mut()
1760 .and_then(|(kind, c)| set_own(*kind, c, p, &name, creating));
1761 let code = match own {
1762 Some(true) => 200,
1763 Some(false) => 403,
1764 None if is_protected(&name) => {
1765 patch.protected = true;
1766 403
1767 }
1768 None => {
1769 let xml = xml::document(p);
1770 if xml.len() > MAX_DEAD_SIZE {
1771 507
1772 } else {
1773 patch.set.push(DeadProp {
1774 ns: name.ns.clone(),
1775 name: name.local.clone(),
1776 xml,
1777 });
1778 200
1779 }
1780 }
1781 };
1782 patch.results.push((code, name.element()));
1783 }
1784 for name in &update.remove {
1785 let own = col
1786 .as_mut()
1787 .and_then(|(kind, c)| remove_own(*kind, c, name));
1788 let code = match own {
1789 Some(()) => 200,
1790 None if is_protected(name) => {
1791 patch.protected = true;
1792 403
1793 }
1794 None => {
1795 patch.remove.push((name.ns.clone(), name.local.clone()));
1796 200
1797 }
1798 };
1799 patch.results.push((code, name.element()));
1800 }
1801 let mut names: Vec<(&str, &str)> = stored
1802 .iter()
1803 .map(|p| (p.ns.as_str(), p.name.as_str()))
1804 .chain(patch.set.iter().map(|p| (p.ns.as_str(), p.name.as_str())))
1805 .filter(|n| {
1806 !patch
1807 .remove
1808 .iter()
1809 .any(|(ns, l)| (ns.as_str(), l.as_str()) == *n)
1810 })
1811 .collect();
1812 names.sort_unstable();
1813 names.dedup();
1814 if names.len() > MAX_DEAD_PROPS {
1815 for (code, prop) in &mut patch.results {
1816 let n = Name::of(prop);
1817 if patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local) {
1818 *code = 507;
1819 }
1820 }
1821 }
1822 if !patch.ok() {
1823 for (code, _) in &mut patch.results {
1824 if *code == 200 {
1825 *code = 424;
1826 }
1827 }
1828 }
1829 patch
1830}
1831
1832fn is_color(v: &str) -> bool {
1833 matches!(v.len(), 7 | 9) && v.starts_with('#') && v[1..].bytes().all(|b| b.is_ascii_hexdigit())
1834}
1835
1836/// Sets one of a collection's own properties. `None` if it is none of them,
1837/// `Some(valid)` otherwise.
1838fn set_own(
1839 kind: PimKind,
1840 col: &mut PimCollection,
1841 p: &Element,
1842 name: &Name,
1843 creating: bool,
1844) -> Option<bool> {
1845 let cal = kind == PimKind::Calendar;
1846 let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
1847 let short = |v: &Option<String>, max: usize, lines: bool| {
1848 v.as_ref().is_none_or(|v| valid_text(v, max, lines))
1849 };
1850 Some(match (name.ns.as_str(), name.local.as_str()) {
1851 (DAV, "displayname") => {
1852 let v = value();
1853 let valid = short(&v, MAX_DISPLAYNAME, false);
1854 if valid {
1855 col.displayname = v;
1856 }
1857 valid
1858 }
1859 (CALDAV, "calendar-description") if cal => {
1860 let v = value();
1861 let valid = short(&v, MAX_DESCRIPTION, true);
1862 if valid {
1863 col.description = v;
1864 }
1865 valid
1866 }
1867 (CARDDAV, "addressbook-description") if !cal => {
1868 let v = value();
1869 let valid = short(&v, MAX_DESCRIPTION, true);
1870 if valid {
1871 col.description = v;
1872 }
1873 valid
1874 }
1875 (APPLE, "calendar-color") if cal => {
1876 let v = value();
1877 let valid = v.as_deref().is_none_or(is_color);
1878 if valid {
1879 col.color = v;
1880 }
1881 valid
1882 }
1883 (APPLE, "calendar-order") if cal => {
1884 let v = value();
1885 let valid = v.as_deref().is_none_or(|v| v.parse::<i64>().is_ok());
1886 if valid {
1887 col.sort_order = v;
1888 }
1889 valid
1890 }
1891 (CALDAV, "calendar-timezone") if cal => {
1892 let tz = value();
1893 let valid = tz.as_deref().is_none_or(is_timezone);
1894 if valid {
1895 col.timezone = tz;
1896 }
1897 valid
1898 }
1899 (CALDAV, "schedule-calendar-transp") if cal => {
1900 let transparent = xml::child(p, CALDAV, "transparent").is_some();
1901 let valid = transparent || xml::child(p, CALDAV, "opaque").is_some();
1902 if valid {
1903 col.transparent = transparent;
1904 }
1905 valid
1906 }
1907 (DAV, "resourcetype") if creating => {
1908 let wanted = match kind {
1909 PimKind::Calendar => (CALDAV, "calendar"),
1910 PimKind::AddressBook => (CARDDAV, "addressbook"),
1911 };
1912 xml::child(p, wanted.0, wanted.1).is_some()
1913 }
1914 (CALDAV, "supported-calendar-component-set") if creating && cal => {
1915 let comps: Vec<_> = xml::elements(p)
1916 .filter(|c| Name::of(c).is(CALDAV, "comp"))
1917 .filter_map(|c| c.attributes.get("name"))
1918 .map(|n| n.to_ascii_uppercase())
1919 .collect();
1920 let valid = !comps.is_empty()
1921 && comps
1922 .iter()
1923 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
1924 if valid {
1925 col.components = comps.join(",");
1926 }
1927 valid
1928 }
1929 _ => return None,
1930 })
1931}
1932
1933/// Removes one of a collection's own properties. `None` if it is none of
1934/// them.
1935fn remove_own(kind: PimKind, col: &mut PimCollection, name: &Name) -> Option<()> {
1936 let cal = kind == PimKind::Calendar;
1937 if cal && name.is(CALDAV, "schedule-calendar-transp") {
1938 col.transparent = false;
1939 return Some(());
1940 }
1941 let field = match (name.ns.as_str(), name.local.as_str()) {
1942 (DAV, "displayname") => &mut col.displayname,
1943 (CALDAV, "calendar-description") if cal => &mut col.description,
1944 (CARDDAV, "addressbook-description") if !cal => &mut col.description,
1945 (APPLE, "calendar-color") if cal => &mut col.color,
1946 (APPLE, "calendar-order") if cal => &mut col.sort_order,
1947 (CALDAV, "calendar-timezone") if cal => &mut col.timezone,
1948 _ => return None,
1949 };
1950 *field = None;
1951 Some(())
1952}
1953
1954/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
1955fn is_timezone(v: &str) -> bool {
1956 use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
1957 ICalendar::parse(v).is_ok_and(|c| {
1958 c.components
1959 .iter()
1960 .any(|c| c.component_type == ICalendarComponentType::VTimezone)
1961 })
1962}
1963
1964// ---------------------------------------------------------------------------
1965// Objects
1966// ---------------------------------------------------------------------------
1967
1968impl Cx<'_> {
1969 async fn get(&self, target: &Target, headers: &HeaderMap, head: bool) -> Reply {
1970 let Target::Object(kind, _, slug, name) = target else {
1971 return self.get_collection(target, head).await;
1972 };
1973 let found = match self.collection(*kind, slug).await? {
1974 Some(col) => self.member(&col.c, name).await?,
1975 None => None,
1976 };
1977 let Some((o, mut data)) = found else {
1978 return Ok(status(StatusCode::NOT_FOUND));
1979 };
1980 if *kind == PimKind::AddressBook {
1981 let accept = headers.get("accept").and_then(|v| v.to_str().ok());
1982 let req = render::AddressData {
1983 props: None,
1984 version: Some(render::accepted_version(accept)),
1985 };
1986 data = blocking(move || -> Result<_, ApiError> {
1987 Ok(render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes())
1988 })
1989 .await?;
1990 }
1991 let length = data.len().to_string();
1992 let body = if head {
1993 Body::empty()
1994 } else {
1995 Body::from(data)
1996 };
1997 let mut r = (
1998 StatusCode::OK,
1999 [
2000 (CONTENT_TYPE, content_type(*kind, &o.component)),
2001 (ETAG, o.etag),
2002 (CONTENT_LENGTH, length),
2003 ],
2004 body,
2005 )
2006 .into_response();
2007 with_schedule_tag(&mut r, o.schedule_tag.as_deref());
2008 Ok(r)
2009 }
2010
2011 /// Clients that discover with GET, as RFC 6764 allows, expect a 2xx on
2012 /// every collection on the way.
2013 async fn get_collection(&self, target: &Target, head: bool) -> Reply {
2014 if let Target::Collection(kind, _, slug) = target
2015 && !(*kind == PimKind::Calendar && slug == OUTBOX && self.space().mine)
2016 && self.collection(*kind, slug).await?.is_none()
2017 {
2018 return Ok(status(StatusCode::NOT_FOUND));
2019 }
2020 let body = match head {
2021 true => "",
2022 false => "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n",
2023 };
2024 Ok((
2025 StatusCode::OK,
2026 [(CONTENT_TYPE, "text/plain; charset=utf-8")],
2027 body,
2028 )
2029 .into_response())
2030 }
2031
2032 async fn put(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
2033 let Target::Object(kind, _, slug, name) = target else {
2034 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
2035 };
2036 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
2037 return Ok(status(StatusCode::CONFLICT));
2038 };
2039 let space = self.space();
2040 // The server alone delivers into the inbox.
2041 if access < Access::Write || col.slug == INBOX {
2042 return Ok(denied(&space.collection(*kind, slug), "bind"));
2043 }
2044 let ns = kind_ns(*kind);
2045 let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
2046 return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
2047 };
2048 let (kind_c, components, name_c) = (*kind, col.components.clone(), name.clone());
2049 let (parsed, stamped, data) = blocking(move || -> Result<_, ApiError> {
2050 let parsed = match kind_c {
2051 PimKind::Calendar => {
2052 let supported: Vec<&str> = components.split(',').collect();
2053 object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
2054 }
2055 PimKind::AddressBook => {
2056 object::vcard(&data).map(|uid| (uid.unwrap_or(name_c), "VCARD".into()))
2057 }
2058 };
2059 let stamped = match (&parsed, kind_c) {
2060 (Ok(_), PimKind::Calendar) => object::with_dtstamp(&data, chrono::Utc::now()),
2061 _ => None,
2062 };
2063 Ok((parsed, stamped, data))
2064 })
2065 .await?;
2066 let (uid, component) = match parsed {
2067 Ok(v) => v,
2068 Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
2069 };
2070 let data = stamped.as_deref().unwrap_or(&data);
2071
2072 let _lock = pim_schedule::LOCK.lock().await;
2073 // A DELETE of the collection or of the share may have run meanwhile.
2074 let access = match self.collection(*kind, slug).await? {
2075 Some(now) if now.c.id == col.id => now.access,
2076 _ => return Ok(status(StatusCode::CONFLICT)),
2077 };
2078 if access < Access::Write {
2079 return Ok(denied(&space.collection(*kind, slug), "bind"));
2080 }
2081 let db = &self.state.db;
2082 let current = self.member(&col, name).await?;
2083 if refuses(headers, current.as_ref().map(|(o, _)| o)) {
2084 return Ok(status(StatusCode::PRECONDITION_FAILED));
2085 }
2086 if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? {
2087 return Ok(error(
2088 StatusCode::FORBIDDEN,
2089 with_children(
2090 el(ns, "no-uid-conflict"),
2091 hrefs([space.object(*kind, slug, &holder).as_str()]),
2092 ),
2093 ));
2094 }
2095 let stored = match kind {
2096 PimKind::Calendar => {
2097 let dir = Directory::load(self.state).await?;
2098 let owner = self.owner(&col, &dir).await?;
2099 let w = self.writer(&owner, access);
2100 let old = current.as_ref().map(|(_, d)| d.as_slice());
2101 match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, data).await? {
2102 Ok(s) => s,
2103 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
2104 }
2105 }
2106 PimKind::AddressBook => Stored {
2107 data: data.to_vec(),
2108 changed: false,
2109 schedule_tag: None,
2110 ops: Vec::new(),
2111 },
2112 };
2113 let etag = etag_of(&stored.data);
2114 let mut ops = vec![PimOp::Put {
2115 collection_id: col.id,
2116 obj: PimObject {
2117 name: name.clone(),
2118 uid,
2119 component,
2120 etag: etag.clone(),
2121 schedule_tag: stored.schedule_tag.clone(),
2122 ..Default::default()
2123 },
2124 data: stored.data,
2125 }];
2126 ops.extend(stored.ops);
2127 db.pim_apply(&ops).await?;
2128 let code = match current {
2129 Some(_) => StatusCode::NO_CONTENT,
2130 None => StatusCode::CREATED,
2131 };
2132 let mut r = status(code);
2133 // Only when the stored bytes are the request bytes (RFC 4791, 5.3.4).
2134 if !stored.changed && stamped.is_none() {
2135 r.headers_mut()
2136 .insert(ETAG, etag.parse().expect("hex is a valid header"));
2137 }
2138 with_schedule_tag(&mut r, stored.schedule_tag.as_deref());
2139 Ok(r)
2140 }
2141
2142 /// The signed-in account writing into a calendar of `owner`.
2143 fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> {
2144 Writer {
2145 owner,
2146 may_schedule: access >= Access::Schedule,
2147 sent_by: (access != Access::Own).then(|| self.me.address.clone()),
2148 }
2149 }
2150
2151 /// The principal owning a collection, whose addresses decide how it takes
2152 /// part in the objects there.
2153 async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result<PimPrincipal, ApiError> {
2154 let owner = match self.state.db.pim_collection_by_id(col.id).await? {
2155 Some((id, _, _)) => dir.get(id).cloned(),
2156 None => None,
2157 };
2158 owner.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))
2159 }
2160
2161 async fn delete(&self, target: &Target, headers: &HeaderMap) -> Reply {
2162 let (kind, slug, name) = match target {
2163 Target::Collection(k, _, s) => (k, s, None),
2164 Target::Object(k, _, s, n) => (k, s, Some(n)),
2165 _ => return Ok(status(StatusCode::FORBIDDEN)),
2166 };
2167 // Under the lock, so a revoked share applies at once. `delete_own`
2168 // takes it for a collection.
2169 let _lock = match name {
2170 Some(_) => Some(pim_schedule::LOCK.lock().await),
2171 None => None,
2172 };
2173 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
2174 return Ok(status(StatusCode::NOT_FOUND));
2175 };
2176 let space = self.space();
2177 let href = space.collection(*kind, slug);
2178 let scheduling = *kind == PimKind::Calendar && col.slug != INBOX;
2179 let db = &self.state.db;
2180 let Some(name) = name else {
2181 return Ok(match access {
2182 Access::Own if *kind == PimKind::Calendar && col.slug == INBOX => {
2183 denied(&space.home(*kind), "unbind")
2184 }
2185 Access::Own => match delete_own(self.state, space.id, *kind, &col).await? {
2186 Ok(()) => status(StatusCode::NO_CONTENT),
2187 Err(condition) => error(StatusCode::FORBIDDEN, condition),
2188 },
2189 // Deleting a lent collection only takes it out of this home.
2190 _ if slug.starts_with(SHARED_PREFIX) && space.mine => {
2191 let _lock = pim_schedule::LOCK.lock().await;
2192 db.pim_remove_share(col.id, self.me.id).await?;
2193 status(StatusCode::NO_CONTENT)
2194 }
2195 _ => denied(&space.home(*kind), "unbind"),
2196 });
2197 };
2198 if access < Access::Write {
2199 return Ok(denied(&href, "unbind"));
2200 }
2201 let Some((obj, data)) = self.member(&col, name).await? else {
2202 return Ok(status(StatusCode::NOT_FOUND));
2203 };
2204 if refuses(headers, Some(&obj)) {
2205 return Ok(status(StatusCode::PRECONDITION_FAILED));
2206 }
2207 let mut ops = vec![PimOp::Delete {
2208 collection_id: col.id,
2209 name: name.clone(),
2210 }];
2211 if scheduling {
2212 let dir = Directory::load(self.state).await?;
2213 let owner = self.owner(&col, &dir).await?;
2214 let w = self.writer(&owner, access);
2215 let reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F");
2216 match pim_schedule::delete(self.state, &dir, &w, &data, reply).await? {
2217 Ok(more) => ops.extend(more),
2218 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
2219 }
2220 }
2221 db.pim_apply(&ops).await?;
2222 Ok(status(StatusCode::NO_CONTENT))
2223 }
2224}
2225
2226/// Whether If-Match, If-None-Match or If-Schedule-Tag-Match fails against
2227/// the current object.
2228fn refuses(headers: &HeaderMap, current: Option<&PimObject>) -> bool {
2229 if !precondition(headers).allows(current.map(|o| o.etag.as_str())) {
2230 return true;
2231 }
2232 headers
2233 .get("if-schedule-tag-match")
2234 .and_then(|v| v.to_str().ok())
2235 .is_some_and(|tag| current.and_then(|o| o.schedule_tag.as_deref()) != Some(tag.trim()))
2236}
2237
2238fn with_schedule_tag(r: &mut Response<Body>, tag: Option<&str>) {
2239 if let Some(v) = tag.and_then(|t| t.parse().ok()) {
2240 r.headers_mut().insert("schedule-tag", v);
2241 }
2242}
2243
2244fn precondition(headers: &HeaderMap) -> Precondition {
2245 let header = |name: &str| {
2246 headers
2247 .get(name)
2248 .and_then(|v| v.to_str().ok())
2249 .map(str::to_string)
2250 };
2251 Precondition {
2252 if_match: header("if-match"),
2253 if_none_match: header("if-none-match"),
2254 }
2255}
2256
2257// ---------------------------------------------------------------------------
2258// REPORT
2259// ---------------------------------------------------------------------------
2260
2261impl Cx<'_> {
2262 async fn report(&self, target: &Target, body: Body) -> Reply {
2263 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2264 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2265 };
2266 let report = match report::parse(&body) {
2267 Ok(r) => r,
2268 Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
2269 Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
2270 };
2271 let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
2272 let on_principals = matches!(
2273 target,
2274 Target::Root | Target::Principals | Target::Principal(_)
2275 );
2276 match report {
2277 Report::PrincipalSearch(search) if on_principals => {
2278 return self.principal_search(&search).await;
2279 }
2280 Report::PrincipalSearchPropertySet if on_principals => {
2281 return Ok(search_property_set());
2282 }
2283 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
2284 return unsupported();
2285 }
2286 _ => {}
2287 }
2288 let Target::Collection(kind, _, slug) = target else {
2289 return unsupported();
2290 };
2291 let calendar_report = matches!(
2292 report,
2293 Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
2294 );
2295 let card_report = matches!(
2296 report,
2297 Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
2298 );
2299 if (calendar_report && *kind != PimKind::Calendar)
2300 || (card_report && *kind != PimKind::AddressBook)
2301 {
2302 return unsupported();
2303 }
2304 let Some(Col { c: col, .. }) = self.collection(*kind, slug).await? else {
2305 return Ok(status(StatusCode::NOT_FOUND));
2306 };
2307 // Busy time comes from calendars, never from messages (RFC 6638, 2.3).
2308 if col.slug == INBOX && matches!(report, Report::FreeBusy(_)) {
2309 return unsupported();
2310 }
2311 let floating = col
2312 .timezone
2313 .as_deref()
2314 .and_then(zone::from_vtimezone)
2315 .unwrap_or(Zone::Utc);
2316 let mut out = Out {
2317 me: self.me.clone(),
2318 space: self.space().clone(),
2319 kind: *kind,
2320 col: col.clone(),
2321 expanded: 0,
2322 };
2323
2324 match report {
2325 Report::CalendarMultiget { props, hrefs }
2326 | Report::AddressbookMultiget { props, hrefs } => {
2327 let members = self.generated_members(&col).await?;
2328 let mut seen = HashSet::new();
2329 let mut found = Vec::new();
2330 let mut loaded = 0;
2331 let mut cut = false;
2332 for href in hrefs {
2333 if !seen.insert(href.clone()) {
2334 continue;
2335 }
2336 if found.len() >= MAX_MULTIGET_HREFS || loaded > MAX_MULTIGET_BYTES {
2337 cut = true;
2338 break;
2339 }
2340 let hit = match self.own_object(*kind, &href) {
2341 Some((slug, name)) if slug == col.slug => match &members {
2342 Some(m) => m.get(&name).cloned(),
2343 None => self.state.db.pim_object(col.id, &name).await?,
2344 },
2345 _ => None,
2346 };
2347 loaded += hit.as_ref().map_or(0, |(_, data)| data.len());
2348 found.push((href, hit));
2349 }
2350 blocking(move || -> Reply {
2351 let mut responses = Vec::new();
2352 for (href, hit) in found {
2353 if out.full() {
2354 cut = true;
2355 break;
2356 }
2357 responses.push(match hit {
2358 // The href as the client wrote it, so it can match it.
2359 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
2360 Ok(r) => xml::Response { href, ..r },
2361 Err(TooManyInstances) => return Ok(too_many()),
2362 },
2363 None => xml::Response::status(href, 404),
2364 });
2365 }
2366 if cut {
2367 responses.push(out.over_limit());
2368 }
2369 Ok(multistatus(&responses, None))
2370 })
2371 .await
2372 }
2373 Report::CalendarQuery {
2374 props,
2375 filter,
2376 timezone,
2377 } => {
2378 let floating = timezone.unwrap_or(floating);
2379 let members = self.members(&col).await?;
2380 blocking(move || -> Reply {
2381 let mut responses = Vec::new();
2382 for (o, data) in members {
2383 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref())
2384 else {
2385 continue;
2386 };
2387 if !filter::matches_calendar(&cal, &filter, &floating) {
2388 continue;
2389 }
2390 if out.full() {
2391 responses.push(out.over_limit());
2392 break;
2393 }
2394 match out.object(&o, &data, &props, &floating) {
2395 Ok(r) => responses.push(r),
2396 Err(TooManyInstances) => return Ok(too_many()),
2397 }
2398 }
2399 Ok(multistatus(&responses, None))
2400 })
2401 .await
2402 }
2403 Report::AddressbookQuery {
2404 props,
2405 filter,
2406 limit,
2407 } => {
2408 let members = self.members(&col).await?;
2409 blocking(move || -> Reply {
2410 let mut responses = Vec::new();
2411 let mut truncated = false;
2412 for (o, data) in members {
2413 let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
2414 continue;
2415 };
2416 if !filter::matches_card(&card, &filter) {
2417 continue;
2418 }
2419 if limit.is_some_and(|n| responses.len() >= n) {
2420 truncated = true;
2421 break;
2422 }
2423 if let Ok(r) = out.object(&o, &data, &props, &floating) {
2424 responses.push(r);
2425 }
2426 }
2427 if truncated {
2428 responses.push(out.over_limit());
2429 }
2430 Ok(multistatus(&responses, None))
2431 })
2432 .await
2433 }
2434 Report::SyncCollection {
2435 token,
2436 props,
2437 limit,
2438 } => {
2439 let (since, issued) = match token.is_empty() {
2440 true => (None, None),
2441 false => match parse_sync_token(&token) {
2442 // A generated collection has no change log: only its
2443 // current token is valid.
2444 Some((id, seq, None))
2445 if id == col.id && generated(id) && seq == col.seq =>
2446 {
2447 (Some(seq), None)
2448 }
2449 Some((id, seq, issued))
2450 if id == col.id
2451 && !generated(id)
2452 && seq <= col.seq
2453 && issued.is_none_or(|i| seq <= i && i <= col.seq) =>
2454 {
2455 (Some(seq), issued)
2456 }
2457 _ => return Ok(invalid_sync_token()),
2458 },
2459 };
2460 // A generated collection has no change log to resume a cut
2461 // answer from. It is small, so it always answers in full.
2462 let limit = limit.filter(|_| !generated(col.id));
2463 // The changes come first: a write between the two reads then
2464 // only makes the next sync refetch a member.
2465 let mut changes = match generated(col.id) {
2466 true => Vec::new(),
2467 false => match self.state.db.pim_changes(col.id, since, issued).await? {
2468 Some(c) => c,
2469 None => return Ok(invalid_sync_token()),
2470 },
2471 };
2472 // An initial sync reads every member at once, not one per change.
2473 let mut members = match since {
2474 None => Some(self.member_map(&col).await?),
2475 Some(_) => None,
2476 };
2477 if let (Some(m), true) = (&members, generated(col.id)) {
2478 let mut names: Vec<_> = m.keys().cloned().collect();
2479 names.sort();
2480 changes = names.into_iter().map(|n| (n, col.seq, false)).collect();
2481 }
2482 // The client of a cut initial sync saw nothing deleted before it
2483 // began, so pruning up to there leaves its resume token valid.
2484 let issued = issued.or(since.is_none().then_some(col.seq));
2485 let truncated = limit.is_some_and(|n| changes.len() > n);
2486 if let Some(n) = limit {
2487 changes.truncate(n);
2488 }
2489 // A truncated answer hands out the token of its last change, so
2490 // the next sync resumes after it.
2491 let seq = match (truncated, changes.last()) {
2492 _ if generated(col.id) => col.seq,
2493 (true, Some((_, s, _))) => *s,
2494 (_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
2495 };
2496 let mut found = Vec::with_capacity(changes.len());
2497 for (name, change, deleted) in changes {
2498 let hit = match (deleted, members.as_mut().and_then(|m| m.remove(&name))) {
2499 (true, _) => None,
2500 (false, Some(hit)) => Some(hit),
2501 // Written after the member map was read.
2502 (false, None) if !generated(col.id) => {
2503 self.state.db.pim_object(col.id, &name).await?
2504 }
2505 (false, None) => None,
2506 };
2507 found.push((name, change, hit));
2508 }
2509 let slug = col.slug.clone();
2510 let cuttable = !generated(col.id);
2511 blocking(move || -> Reply {
2512 let (mut responses, mut seq, mut truncated) = (Vec::new(), seq, truncated);
2513 let mut last = seq;
2514 for (name, change, hit) in found {
2515 // Cut like a client limit: the token of the last change answered.
2516 if cuttable && out.full() {
2517 (seq, truncated) = (last, true);
2518 break;
2519 }
2520 last = change;
2521 responses.push(match hit {
2522 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
2523 Ok(r) => r,
2524 Err(TooManyInstances) => return Ok(too_many()),
2525 },
2526 None => {
2527 xml::Response::status(out.space.object(out.kind, &slug, &name), 404)
2528 }
2529 });
2530 }
2531 if truncated {
2532 responses.push(out.over_limit());
2533 }
2534 // Past `issued`, the answer holds every change up to `seq`.
2535 let token = sync_token(col.id, seq, issued.filter(|&i| truncated && seq <= i));
2536 Ok(multistatus(
2537 &responses,
2538 Some(with_text(el(DAV, "sync-token"), token)),
2539 ))
2540 })
2541 .await
2542 }
2543 Report::FreeBusy(range) => {
2544 let members = self.members(&col).await?;
2545 blocking(move || -> Reply {
2546 let mut busy = Vec::new();
2547 for (_, data) in members {
2548 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
2549 // ponytail: one period per instance, so a long range over
2550 // a frequent series makes a long answer.
2551 busy.extend(freebusy::busy(&cal, &range, &floating, None));
2552 }
2553 }
2554 let body =
2555 freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
2556 Ok((
2557 StatusCode::OK,
2558 [(CONTENT_TYPE, "text/calendar; charset=utf-8")],
2559 body,
2560 )
2561 .into_response())
2562 })
2563 .await
2564 }
2565 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
2566 unreachable!("answered above")
2567 }
2568 }
2569 }
2570
2571 /// principal-property-search and calendarserver-principal-search.
2572 async fn principal_search(&self, search: &Search) -> Reply {
2573 let mut responses = Vec::new();
2574 let mut truncated = false;
2575 for p in self.state.db.pim_principals(true).await? {
2576 let view = PrincipalView::of(&p, self.me);
2577 let addresses = view.addresses();
2578 let candidate = Principal {
2579 name: &p.name,
2580 display: p.display(),
2581 addresses: &addresses,
2582 kind: p.kind,
2583 };
2584 if !search.matches(&candidate) {
2585 continue;
2586 }
2587 if search.limit.is_some_and(|n| responses.len() >= n) {
2588 truncated = true;
2589 break;
2590 }
2591 let href = principal_href(&p.name);
2592 responses.push(select(
2593 href,
2594 &search.find,
2595 self.props(&Res::Principal(view)),
2596 ));
2597 }
2598 if truncated {
2599 let mut r = xml::Response::status(format!("{PIM}/principals/"), 507);
2600 r.error = Some(el(DAV, "number-of-matches-within-limits"));
2601 responses.push(r);
2602 }
2603 Ok(multistatus(&responses, None))
2604 }
2605
2606 /// `(collection slug, object name)` of an href to an object of `kind` in
2607 /// the space of this request. Takes a path or a full URL.
2608 fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> {
2609 let path = match href.starts_with('/') {
2610 true => href.to_string(),
2611 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
2612 };
2613 let space = self.space?;
2614 match parse_target(path.strip_prefix(PIM)?)? {
2615 Target::Object(k, owner, slug, name)
2616 if k == kind && owner.eq_ignore_ascii_case(&space.path) =>
2617 {
2618 Some((slug, name))
2619 }
2620 _ => None,
2621 }
2622 }
2623}
2624
2625fn search_property_set() -> Response<Body> {
2626 let body = xml::document(&with_children(
2627 el(DAV, "principal-search-property-set"),
2628 principal::SEARCHABLE.map(|(ns, local, description)| {
2629 with_children(
2630 el(DAV, "principal-search-property"),
2631 [
2632 with_children(el(DAV, "prop"), [el(ns, local)]),
2633 with_attr(
2634 with_text(el(DAV, "description"), description),
2635 "xml:lang",
2636 "en",
2637 ),
2638 ],
2639 )
2640 }),
2641 ));
2642 xml_response(StatusCode::OK, body)
2643}
2644
2645/// Instances `expand` may produce for one REPORT answer, across its objects.
2646/// Beyond it the answer is cut short with a 507, as for a client limit.
2647const MAX_EXPANDED_PER_ANSWER: usize = 20_000;
2648
2649/// Hrefs and object bytes one multiget loads. Beyond them it answers 507.
2650const MAX_MULTIGET_HREFS: usize = 1000;
2651const MAX_MULTIGET_BYTES: usize = 32 * 1024 * 1024;
2652
2653/// What a REPORT answer about one collection needs. Owned, so the answer
2654/// can be built on the blocking pool.
2655struct Out {
2656 me: Me,
2657 space: Space,
2658 kind: PimKind,
2659 col: PimCollection,
2660 /// Instances `expand` produced for this answer so far.
2661 expanded: usize,
2662}
2663
2664impl Out {
2665 fn object(
2666 &mut self,
2667 o: &PimObject,
2668 data: &[u8],
2669 props: &Props,
2670 floating: &Zone,
2671 ) -> Result<xml::Response, TooManyInstances> {
2672 let mut all = live_props(
2673 &self.me,
2674 Some(&self.space),
2675 &Res::Object(self.kind, o.clone()),
2676 );
2677 let raw = String::from_utf8_lossy(data);
2678 if let Some(req) = &props.calendar {
2679 let (text, instances) = render::calendar_data(&raw, req, floating)?;
2680 self.expanded += instances;
2681 all.push(with_text(el(CALDAV, "calendar-data"), text));
2682 }
2683 if let Some(req) = &props.address {
2684 all.push(with_text(
2685 el(CARDDAV, "address-data"),
2686 render::address_data(&raw, req),
2687 ));
2688 }
2689 let href = self.space.object(self.kind, &self.col.slug, &o.name);
2690 Ok(select(href, &props.find, all))
2691 }
2692
2693 fn full(&self) -> bool {
2694 self.expanded > MAX_EXPANDED_PER_ANSWER
2695 }
2696
2697 /// The response a query or sync adds when a limit cut it short.
2698 fn over_limit(&self) -> xml::Response {
2699 let href = self.space.collection(self.kind, &self.col.slug);
2700 let mut r = xml::Response::status(href, 507);
2701 r.error = Some(el(DAV, "number-of-matches-within-limits"));
2702 r
2703 }
2704}
2705
2706fn invalid_sync_token() -> Response<Body> {
2707 error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))
2708}
2709
2710fn too_many() -> Response<Body> {
2711 error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
2712}
2713
2714/// `(collection id, seq, issued)` of a token [`sync_token`] made.
2715fn parse_sync_token(token: &str) -> Option<(i64, i64, Option<i64>)> {
2716 let rest = token.strip_prefix("urn:dovenest:sync:")?;
2717 let (rest, issued) = match rest.split_once('.') {
2718 Some((r, i)) => (r, Some(i.parse().ok()?)),
2719 None => (rest, None),
2720 };
2721 // The birthday calendar's id is negative.
2722 let (id, seq) = rest.rsplit_once('-')?;
2723 Some((id.parse().ok()?, seq.parse().ok()?, issued))
2724}
2725
2726// ---------------------------------------------------------------------------
2727// POST
2728// ---------------------------------------------------------------------------
2729
2730impl Cx<'_> {
2731 /// A free-busy request to the own scheduling outbox (RFC 6638, 5).
2732 async fn post(&self, target: &Target, body: Body) -> Reply {
2733 let space = match target {
2734 Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX => self.space(),
2735 _ => return Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
2736 };
2737 if !space.mine {
2738 let href = space.collection(PimKind::Calendar, OUTBOX);
2739 return Ok(error(
2740 StatusCode::FORBIDDEN,
2741 need_privilege(&href, CALDAV, "schedule-send-freebusy"),
2742 ));
2743 }
2744 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2745 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2746 };
2747 let request = match freebusy::request(&body) {
2748 Ok(r) => r,
2749 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition.element())),
2750 };
2751 let dir = Directory::load(self.state).await?;
2752 if !dir.is(self.me.pid)(&request.organizer) {
2753 return Ok(error(
2754 StatusCode::FORBIDDEN,
2755 el(CALDAV, "organizer-allowed"),
2756 ));
2757 }
2758 let answers = pim_schedule::free_busy(self.state, &dir, &request).await?;
2759 Ok(xml_response(
2760 StatusCode::OK,
2761 freebusy::schedule_response(&answers),
2762 ))
2763 }
2764}
2765
2766// ---------------------------------------------------------------------------
2767// MOVE
2768// ---------------------------------------------------------------------------
2769
2770impl Cx<'_> {
2771 async fn move_object(&self, target: &Target, headers: &HeaderMap) -> Reply {
2772 let Target::Object(kind, _, slug, name) = target else {
2773 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
2774 };
2775 let destination = headers.get("destination").and_then(|v| v.to_str().ok());
2776 let Some((to_slug, to_name)) = destination.and_then(|d| self.own_object(*kind, d)) else {
2777 return Ok(status(StatusCode::FORBIDDEN));
2778 };
2779 if (&to_slug, &to_name) == (slug, name) {
2780 return Ok(status(StatusCode::FORBIDDEN));
2781 }
2782 let space = self.space();
2783 let _lock = pim_schedule::LOCK.lock().await;
2784 let Some(from) = self.collection(*kind, slug).await? else {
2785 return Ok(status(StatusCode::NOT_FOUND));
2786 };
2787 let Some(to) = self.collection(*kind, &to_slug).await? else {
2788 return Ok(status(StatusCode::CONFLICT));
2789 };
2790 if from.access < Access::Write || from.c.slug == INBOX {
2791 return Ok(denied(&space.collection(*kind, slug), "unbind"));
2792 }
2793 if to.access < Access::Write || to.c.slug == INBOX {
2794 return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
2795 }
2796 // A meeting stays in its organizer's calendars (`elsewhere` allows one
2797 // scheduling object per UID and principal), so an object never changes owner. Clients fall back to
2798 // PUT and DELETE, which schedule as usual.
2799 if !from.owner.eq_ignore_ascii_case(&to.owner) {
2800 return Ok(status(StatusCode::FORBIDDEN));
2801 }
2802 let Some((obj, _)) = self.member(&from.c, name).await? else {
2803 return Ok(status(StatusCode::NOT_FOUND));
2804 };
2805 // Moving between calendars schedules nothing (RFC 6638, 3.2.3.4).
2806 if refuses(headers, Some(&obj)) {
2807 return Ok(status(StatusCode::PRECONDITION_FAILED));
2808 }
2809 if *kind == PimKind::Calendar && !to.c.components.split(',').any(|c| c == obj.component) {
2810 return Ok(error(
2811 StatusCode::FORBIDDEN,
2812 el(CALDAV, "supported-calendar-component"),
2813 ));
2814 }
2815 let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
2816 // Overwriting a meeting would drop it without telling its attendees.
2817 if overwrite
2818 && self
2819 .member(&to.c, &to_name)
2820 .await?
2821 .is_some_and(|(o, _)| o.schedule_tag.is_some())
2822 {
2823 return Ok(status(StatusCode::FORBIDDEN));
2824 }
2825 let written = self
2826 .state
2827 .db
2828 .pim_move_object(
2829 from.c.id,
2830 name,
2831 to.c.id,
2832 &to_name,
2833 overwrite,
2834 &precondition(headers),
2835 )
2836 .await?;
2837 Ok(match written {
2838 PimWrite::Created | PimWrite::Updated => {
2839 let code = match written {
2840 PimWrite::Created => StatusCode::CREATED,
2841 _ => StatusCode::NO_CONTENT,
2842 };
2843 let mut r = status(code);
2844 with_schedule_tag(&mut r, obj.schedule_tag.as_deref());
2845 r
2846 }
2847 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
2848 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
2849 PimWrite::UidConflict(holder) => error(
2850 StatusCode::FORBIDDEN,
2851 with_children(
2852 el(kind_ns(*kind), "no-uid-conflict"),
2853 hrefs([space.object(*kind, &to_slug, &holder).as_str()]),
2854 ),
2855 ),
2856 PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
2857 })
2858 }
2859}
2860