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