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 kind == PimKind::Calendar && col.slug != INBOX {
860 if db
861 .pim_calendar_for(owner, "VEVENT")
862 .await?
863 .is_some_and(|d| d.id == col.id)
864 {
865 return Ok(Err(el(CALDAV, "default-calendar-needed")));
866 }
867 let dir = Directory::load(state).await?;
868 let owner = dir
869 .get(owner)
870 .cloned()
871 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
872 let mut ops = match pim_schedule::retract(state, &dir, &owner, &[col.id]).await? {
873 Ok(ops) => ops,
874 Err(refused) => return Ok(Err(refused)),
875 };
876 // The cancellations commit with the delete, so no event goes without
877 // its attendees hearing of it.
878 ops.push(PimOp::DeleteCollection(col.id));
879 db.pim_apply(&ops).await?;
880 return Ok(Ok(()));
881 }
882 db.pim_delete_collection(col.id).await?;
883 Ok(Ok(()))
884}
885
886/// A collection lent to the signed-in account, as it appears in their home.
887fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col {
888 let name = c.displayname.take().unwrap_or_else(|| c.slug.clone());
889 c.displayname = Some(format!("{name} ({owner})"));
890 c.slug = format!("{SHARED_PREFIX}{}", c.id);
891 Col {
892 c,
893 access: match mode {
894 PimShareMode::Ro => Access::Read,
895 PimShareMode::Rw => Access::Write,
896 PimShareMode::RwSchedule => Access::Schedule,
897 },
898 owner: principal_href(owner),
899 }
900}
901
902// ---------------------------------------------------------------------------
903// PROPFIND
904// ---------------------------------------------------------------------------
905
906/// A resource PROPFIND can describe.
907enum Res {
908 Root,
909 Principals,
910 Principal(PrincipalView),
911 /// With its owner's principal href, whether the account may add to it,
912 /// and where its client properties live.
913 Home(String, Access, PropPlace),
914 Collection(PimKind, Col),
915 /// With the href of the calendar that receives new invitations.
916 Inbox(Col, Option<String>),
917 /// With its owner's principal href.
918 Outbox(String),
919 Object(PimKind, PimObject),
920}
921
922impl Cx<'_> {
923 async fn propfind(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
924 // Missing means infinity to RFC 4918, but clients that omit it mean 0.
925 let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) {
926 None | Some("0") => false,
927 Some("1") => true,
928 Some(_) => {
929 return Ok(error(
930 StatusCode::FORBIDDEN,
931 el(DAV, "propfind-finite-depth"),
932 ));
933 }
934 };
935 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
936 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
937 };
938 let Ok(request) = xml::propfind(&body) else {
939 return Ok(status(StatusCode::BAD_REQUEST));
940 };
941
942 let mut list: Vec<(String, Res)> = Vec::new();
943 match target {
944 Target::Root => list.push((format!("{PIM}/"), Res::Root)),
945 Target::Principals => {
946 list.push((format!("{PIM}/principals/"), Res::Principals));
947 if deep {
948 for p in self.state.db.pim_principals(true).await? {
949 list.push((
950 principal_href(&p.name),
951 Res::Principal(PrincipalView::of(&p, self.me)),
952 ));
953 }
954 }
955 }
956 Target::Principal(_) => {
957 let s = self.space();
958 list.push((
959 s.principal(),
960 Res::Principal(PrincipalView {
961 id: s.id,
962 path: s.path.clone(),
963 display: s.display.clone(),
964 kind: s.kind,
965 me: s.mine,
966 }),
967 ));
968 }
969 Target::Home(kind, _) => {
970 let s = self.space();
971 let access = if s.mine { Access::Own } else { Access::Read };
972 let place = PropPlace::Home(s.id, *kind);
973 list.push((s.home(*kind), Res::Home(s.principal(), access, place)));
974 if deep {
975 for col in self.collections(*kind).await? {
976 let href = s.collection(*kind, &col.c.slug);
977 list.push((href, self.res(*kind, col).await?));
978 }
979 if *kind == PimKind::Calendar && s.mine {
980 list.push((s.collection(*kind, OUTBOX), Res::Outbox(s.principal())));
981 }
982 }
983 }
984 Target::Collection(PimKind::Calendar, _, slug)
985 if slug == OUTBOX && self.space().mine =>
986 {
987 let s = self.space();
988 list.push((
989 s.collection(PimKind::Calendar, OUTBOX),
990 Res::Outbox(s.principal()),
991 ));
992 }
993 Target::Collection(kind, _, slug) => {
994 let Some(col) = self.collection(*kind, slug).await? else {
995 return Ok(status(StatusCode::NOT_FOUND));
996 };
997 let objects = match (deep, col.c.id) {
998 (false, _) => Vec::new(),
999 (true, id) if generated(id) => self
1000 .members(&col.c)
1001 .await?
1002 .into_iter()
1003 .map(|(o, _)| o)
1004 .collect(),
1005 (true, id) => self.state.db.pim_objects(id).await?,
1006 };
1007 let s = self.space();
1008 let slug = col.c.slug.clone();
1009 list.push((s.collection(*kind, &slug), self.res(*kind, col).await?));
1010 for o in objects {
1011 list.push((s.object(*kind, &slug, &o.name), Res::Object(*kind, o)));
1012 }
1013 }
1014 Target::Object(kind, _, slug, name) => {
1015 let found = match self.collection(*kind, slug).await? {
1016 Some(col) => self.member(&col.c, name).await?,
1017 None => None,
1018 };
1019 let Some((o, _)) = found else {
1020 return Ok(status(StatusCode::NOT_FOUND));
1021 };
1022 list.push((
1023 self.space().object(*kind, slug, name),
1024 Res::Object(*kind, o),
1025 ));
1026 }
1027 }
1028
1029 let described_len = list.len();
1030 let mut described = Vec::with_capacity(described_len);
1031 for (href, res) in list {
1032 let dead = self.dead_props(&res).await?;
1033 described.push((href, res, dead));
1034 }
1035 let answer = move |me: &Me, space: Option<&Space>| {
1036 let responses: Vec<_> = described
1037 .into_iter()
1038 .map(|(href, res, dead)| {
1039 let mut all = live_props(me, space, &res);
1040 all.extend(dead);
1041 select(href, &request, all)
1042 })
1043 .collect();
1044 multistatus(&responses, None)
1045 };
1046 // A handoff to the blocking pool costs more than a small answer.
1047 if described_len <= INLINE_RESPONSES {
1048 return Ok(answer(self.me, self.space));
1049 }
1050 let (me, space) = (self.me.clone(), self.space.cloned());
1051 blocking(move || -> Reply { Ok(answer(&me, space.as_ref())) }).await
1052 }
1053
1054 /// The client properties stored for a resource. Those of a principal or
1055 /// home only reach the accounts that may write them: they hold another
1056 /// account's client settings.
1057 async fn dead_props(&self, res: &Res) -> Result<Vec<Element>, ApiError> {
1058 let place = match res {
1059 Res::Principal(p) if p.me || (self.me.admin && p.kind != UserType::Individual) => {
1060 PropPlace::Principal(p.id)
1061 }
1062 Res::Home(_, _, place) if self.may_edit(self.space()) => *place,
1063 Res::Collection(_, col) | Res::Inbox(col, _) if !generated(col.c.id) => {
1064 PropPlace::Collection(col.c.id)
1065 }
1066 _ => return Ok(Vec::new()),
1067 };
1068 Ok(self
1069 .state
1070 .db
1071 .pim_props(place)
1072 .await?
1073 .iter()
1074 .filter_map(|p| Element::parse(p.xml.as_bytes()).ok())
1075 .collect())
1076 }
1077
1078 fn props(&self, res: &Res) -> Vec<Element> {
1079 live_props(self.me, self.space, res)
1080 }
1081}
1082
1083/// Every live property of a resource, with its value.
1084fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
1085 let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
1086 let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h]));
1087 let resourcetype = |types: &[(&str, &str)]| {
1088 with_children(
1089 el(DAV, "resourcetype"),
1090 types.iter().map(|(ns, l)| el(ns, l)),
1091 )
1092 };
1093 let principals = format!("{PIM}/principals/");
1094 let mut out = vec![
1095 href_prop(DAV, "current-user-principal", &me.principal),
1096 href_prop(DAV, "principal-collection-set", &principals),
1097 ];
1098 match res {
1099 Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
1100 Res::Principals => out.extend([
1101 resourcetype(&[(DAV, "collection")]),
1102 privileges(Access::Read),
1103 principal_reports(),
1104 ]),
1105 Res::Principal(p) => {
1106 // The own principal in the spelling of the request.
1107 let href = match p.me {
1108 true => me.principal.clone(),
1109 false => principal_href(&p.path),
1110 };
1111 let addresses = p.addresses();
1112 out.extend([
1113 resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
1114 text(DAV, "displayname", &p.display),
1115 href_prop(DAV, "principal-URL", &href),
1116 with_children(
1117 el(CALDAV, "calendar-user-address-set"),
1118 hrefs(addresses.iter().map(String::as_str))
1119 .into_iter()
1120 .map(|h| with_attr(h, "preferred", "1")),
1121 ),
1122 with_children(
1123 el(CALSERVER, "email-address-set"),
1124 [with_text(
1125 el(CALSERVER, "email-address"),
1126 mailto(&p.path, p.kind),
1127 )],
1128 ),
1129 text(CALDAV, "calendar-user-type", p.kind.as_str()),
1130 privileges(if p.me { Access::Own } else { Access::Read }),
1131 principal_reports(),
1132 ]);
1133 let home = |kind: PimKind| {
1134 let name = match p.me {
1135 true => space
1136 .filter(|s| s.mine)
1137 .map_or(p.path.clone(), |s| s.path.clone()),
1138 false => p.path.clone(),
1139 };
1140 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&name))
1141 };
1142 // Also for other accounts: python-caldav drops a search hit
1143 // without one. Their homes still answer 403.
1144 out.push(href_prop(
1145 CALDAV,
1146 "calendar-home-set",
1147 &home(PimKind::Calendar),
1148 ));
1149 if p.me {
1150 let cal = home(PimKind::Calendar);
1151 out.push(href_prop(
1152 CALDAV,
1153 "schedule-inbox-URL",
1154 &format!("{cal}{INBOX}/"),
1155 ));
1156 out.push(href_prop(
1157 CALDAV,
1158 "schedule-outbox-URL",
1159 &format!("{cal}{OUTBOX}/"),
1160 ));
1161 let book = home(PimKind::AddressBook);
1162 out.push(href_prop(CARDDAV, "addressbook-home-set", &book));
1163 out.push(href_prop(
1164 CARDDAV,
1165 "directory-gateway",
1166 &format!("{book}{DIRECTORY_SLUG}/"),
1167 ));
1168 }
1169 }
1170 Res::Home(owner, access, _) => out.extend([
1171 resourcetype(&[(DAV, "collection")]),
1172 href_prop(DAV, "owner", owner),
1173 privileges(*access),
1174 ]),
1175 Res::Collection(kind, col) => {
1176 let c = &col.c;
1177 let (types, desc) = match kind {
1178 PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
1179 PimKind::AddressBook => (
1180 (CARDDAV, "addressbook"),
1181 (CARDDAV, "addressbook-description"),
1182 ),
1183 };
1184 out.extend([
1185 resourcetype(&[(DAV, "collection"), types]),
1186 href_prop(DAV, "owner", &col.owner),
1187 privileges(col.access),
1188 supported_reports(*kind),
1189 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
1190 text(DAV, "sync-token", &sync_token(c.id, c.seq, None)),
1191 text(
1192 kind_ns(*kind),
1193 "max-resource-size",
1194 &MAX_RESOURCE_SIZE.to_string(),
1195 ),
1196 ]);
1197 if let Some(v) = &c.displayname {
1198 out.push(text(DAV, "displayname", v));
1199 }
1200 if let Some(v) = &c.description {
1201 out.push(text(desc.0, desc.1, v));
1202 }
1203 match kind {
1204 PimKind::Calendar => {
1205 out.push(with_children(
1206 el(CALDAV, "supported-calendar-component-set"),
1207 c.components
1208 .split(',')
1209 .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)),
1210 ));
1211 out.push(with_children(
1212 el(CALDAV, "supported-calendar-data"),
1213 [with_attr(
1214 with_attr(el(CALDAV, "calendar-data"), "content-type", "text/calendar"),
1215 "version",
1216 "2.0",
1217 )],
1218 ));
1219 if let Some(v) = &c.color {
1220 out.push(text(APPLE, "calendar-color", v));
1221 }
1222 if let Some(v) = &c.sort_order {
1223 out.push(text(APPLE, "calendar-order", v));
1224 }
1225 if let Some(v) = &c.timezone {
1226 out.push(text(CALDAV, "calendar-timezone", v));
1227 }
1228 out.push(with_children(
1229 el(CALDAV, "schedule-calendar-transp"),
1230 [el(
1231 CALDAV,
1232 if c.transparent {
1233 "transparent"
1234 } else {
1235 "opaque"
1236 },
1237 )],
1238 ));
1239 }
1240 // 3.0 only: a client told of 4.0 writes 4.0 groups, which
1241 // Apple Contacts on the same account cannot read. A 4.0
1242 // PUT is still stored, and served as 4.0 on request.
1243 PimKind::AddressBook => out.push(with_children(
1244 el(CARDDAV, "supported-address-data"),
1245 [with_attr(
1246 with_attr(
1247 el(CARDDAV, "address-data-type"),
1248 "content-type",
1249 "text/vcard",
1250 ),
1251 "version",
1252 "3.0",
1253 )],
1254 )),
1255 }
1256 }
1257 Res::Inbox(col, default) => {
1258 let c = &col.c;
1259 out.extend([
1260 resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-inbox")]),
1261 href_prop(DAV, "owner", &col.owner),
1262 privilege_set(INBOX_PRIVILEGES),
1263 report_set(&[
1264 (CALDAV, "calendar-multiget"),
1265 (CALDAV, "calendar-query"),
1266 (DAV, "sync-collection"),
1267 ]),
1268 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
1269 text(DAV, "sync-token", &sync_token(c.id, c.seq, None)),
1270 ]);
1271 if let Some(v) = &c.displayname {
1272 out.push(text(DAV, "displayname", v));
1273 }
1274 if let Some(h) = default {
1275 out.push(href_prop(CALDAV, "schedule-default-calendar-URL", h));
1276 }
1277 }
1278 Res::Outbox(owner) => out.extend([
1279 resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-outbox")]),
1280 href_prop(DAV, "owner", owner),
1281 privilege_set(OUTBOX_PRIVILEGES),
1282 ]),
1283 Res::Object(kind, o) => {
1284 if let Some(tag) = &o.schedule_tag {
1285 out.push(text(CALDAV, "schedule-tag", tag));
1286 }
1287 out.extend([
1288 resourcetype(&[]),
1289 text(DAV, "getetag", &o.etag),
1290 text(DAV, "getcontenttype", &content_type(*kind, &o.component)),
1291 text(DAV, "getcontentlength", &o.size.to_string()),
1292 ]);
1293 if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) {
1294 let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1295 out.push(text(DAV, "getlastmodified", &http_date));
1296 }
1297 }
1298 }
1299 out
1300}
1301
1302impl Cx<'_> {
1303 /// How PROPFIND describes a collection. The inbox names the calendar
1304 /// that receives new invitations.
1305 async fn res(&self, kind: PimKind, col: Col) -> Result<Res, ApiError> {
1306 if kind != PimKind::Calendar || col.c.slug != INBOX {
1307 return Ok(Res::Collection(kind, col));
1308 }
1309 let space = self.space();
1310 let default = self
1311 .state
1312 .db
1313 .pim_calendar_for(space.id, "VEVENT")
1314 .await?
1315 .map(|c| space.collection(PimKind::Calendar, &c.slug));
1316 Ok(Res::Inbox(col, default))
1317 }
1318}
1319
1320/// The response for one resource: the requested ones of `all`, and 404 for
1321/// those it lacks.
1322fn select(href: String, request: &Propfind, all: Vec<Element>) -> xml::Response {
1323 let mut r = xml::Response::new(href);
1324 match request {
1325 Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
1326 Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
1327 Propfind::Prop(names) => {
1328 for n in names {
1329 match all.iter().find(|p| Name::of(p) == *n) {
1330 Some(p) => r.push(200, p.clone()),
1331 None => r.push(404, n.element()),
1332 }
1333 }
1334 }
1335 }
1336 if r.propstats.is_empty() {
1337 r.status = Some(200);
1338 }
1339 r
1340}
1341
1342fn multistatus(responses: &[xml::Response], tail: Option<Element>) -> Response<Body> {
1343 xml_response(
1344 StatusCode::MULTI_STATUS,
1345 xml::multistatus_with(&Name::new(DAV, "multistatus"), responses, tail),
1346 )
1347}
1348
1349fn report_set(reports: &[(&str, &str)]) -> Element {
1350 with_children(
1351 el(DAV, "supported-report-set"),
1352 reports.iter().map(|(ns, local)| {
1353 with_children(
1354 el(DAV, "supported-report"),
1355 [with_children(el(DAV, "report"), [el(ns, local)])],
1356 )
1357 }),
1358 )
1359}
1360
1361fn supported_reports(kind: PimKind) -> Element {
1362 report_set(match kind {
1363 PimKind::Calendar => &[
1364 (CALDAV, "calendar-multiget"),
1365 (CALDAV, "calendar-query"),
1366 (CALDAV, "free-busy-query"),
1367 (DAV, "sync-collection"),
1368 ],
1369 PimKind::AddressBook => &[
1370 (CARDDAV, "addressbook-multiget"),
1371 (CARDDAV, "addressbook-query"),
1372 (DAV, "sync-collection"),
1373 ],
1374 })
1375}
1376
1377fn principal_reports() -> Element {
1378 report_set(&[
1379 (DAV, "principal-property-search"),
1380 (DAV, "principal-search-property-set"),
1381 (CALSERVER, "calendarserver-principal-search"),
1382 ])
1383}
1384
1385fn privileges(access: Access) -> Element {
1386 const WRITE: [(&str, &str); 5] = [
1387 (DAV, "read"),
1388 (DAV, "write-content"),
1389 (DAV, "bind"),
1390 (DAV, "unbind"),
1391 (DAV, "read-current-user-privilege-set"),
1392 ];
1393 let names: Vec<(&str, &str)> = match access {
1394 Access::Own => [
1395 "all",
1396 "read",
1397 "write",
1398 "write-properties",
1399 "write-content",
1400 "bind",
1401 "unbind",
1402 "read-current-user-privilege-set",
1403 ]
1404 .map(|n| (DAV, n))
1405 .to_vec(),
1406 // RFC 6638 grants these on the outbox, which a sharee cannot see.
1407 Access::Schedule => [
1408 (CALDAV, "schedule-send"),
1409 (CALDAV, "schedule-send-invite"),
1410 (CALDAV, "schedule-send-reply"),
1411 ]
1412 .into_iter()
1413 .chain(WRITE)
1414 .collect(),
1415 Access::Write => WRITE.to_vec(),
1416 Access::Read => vec![(DAV, "read"), (DAV, "read-current-user-privilege-set")],
1417 };
1418 privilege_set(names)
1419}
1420
1421/// The owner reads and empties the inbox; only the server delivers into it.
1422const INBOX_PRIVILEGES: [(&str, &str); 7] = [
1423 (DAV, "read"),
1424 (DAV, "unbind"),
1425 (DAV, "read-current-user-privilege-set"),
1426 (CALDAV, "schedule-deliver"),
1427 (CALDAV, "schedule-deliver-invite"),
1428 (CALDAV, "schedule-deliver-reply"),
1429 (CALDAV, "schedule-query-freebusy"),
1430];
1431
1432const OUTBOX_PRIVILEGES: [(&str, &str); 6] = [
1433 (DAV, "read"),
1434 (DAV, "read-current-user-privilege-set"),
1435 (CALDAV, "schedule-send"),
1436 (CALDAV, "schedule-send-invite"),
1437 (CALDAV, "schedule-send-reply"),
1438 (CALDAV, "schedule-send-freebusy"),
1439];
1440
1441fn privilege_set<'a>(names: impl IntoIterator<Item = (&'a str, &'a str)>) -> Element {
1442 with_children(
1443 el(DAV, "current-user-privilege-set"),
1444 names
1445 .into_iter()
1446 .map(|(ns, n)| with_children(el(DAV, "privilege"), [el(ns, n)])),
1447 )
1448}
1449
1450/// Carries the collection id, so a token handed out for a deleted
1451/// collection never matches the one that later takes its URL. A cut initial
1452/// sync also carries `issued`, the collection seq it began at.
1453fn sync_token(id: i64, seq: i64, issued: Option<i64>) -> String {
1454 match issued {
1455 Some(i) => format!("urn:dovenest:sync:{id}-{seq}.{i}"),
1456 None => format!("urn:dovenest:sync:{id}-{seq}"),
1457 }
1458}
1459
1460fn content_type(kind: PimKind, component: &str) -> String {
1461 match kind {
1462 PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
1463 PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
1464 }
1465}
1466
1467// ---------------------------------------------------------------------------
1468// PROPPATCH, MKCALENDAR, MKCOL
1469// ---------------------------------------------------------------------------
1470
1471impl Cx<'_> {
1472 async fn proppatch(&self, target: &Target, body: Body) -> Reply {
1473 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1474 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1475 };
1476 let Ok(mut update) = xml::update(&body) else {
1477 return Ok(status(StatusCode::BAD_REQUEST));
1478 };
1479 // Read before the lock, so a slow client cannot hold it.
1480 let _lock = pim_schedule::LOCK.lock().await;
1481 let (href, place, res, mut col) = match target {
1482 Target::Collection(kind, _, slug) => {
1483 let Some(col) = self.collection(*kind, slug).await? else {
1484 return Ok(status(StatusCode::NOT_FOUND));
1485 };
1486 let href = self.space().collection(*kind, slug);
1487 // Per property, so a client that colors every calendar it sees
1488 // goes on.
1489 if col.access != Access::Own {
1490 let mut r = xml::Response::new(href.clone());
1491 r.error = Some(need_privilege(&href, DAV, "write-properties"));
1492 let names = update
1493 .set
1494 .iter()
1495 .map(Name::of)
1496 .chain(update.remove.iter().cloned());
1497 for n in names {
1498 r.push(403, n.element());
1499 }
1500 return Ok(multistatus(&[r], None));
1501 }
1502 let place = PropPlace::Collection(col.c.id);
1503 let stored = (*kind, col.c.clone());
1504 (href, place, self.res(*kind, col).await?, Some(stored))
1505 }
1506 Target::Home(kind, _) => {
1507 let s = self.space();
1508 if !self.may_edit(s) {
1509 return Ok(denied(&s.home(*kind), "write-properties"));
1510 }
1511 let place = PropPlace::Home(s.id, *kind);
1512 let res = Res::Home(s.principal(), Access::Own, place);
1513 (s.home(*kind), place, res, None)
1514 }
1515 Target::Principal(_) => {
1516 let s = self.space();
1517 if !self.may_edit(s) {
1518 return Ok(denied(&s.principal(), "write-properties"));
1519 }
1520 let view = PrincipalView {
1521 id: s.id,
1522 path: s.path.clone(),
1523 display: s.display.clone(),
1524 kind: s.kind,
1525 me: s.mine,
1526 };
1527 let place = PropPlace::Principal(s.id);
1528 (s.principal(), place, Res::Principal(view), None)
1529 }
1530 _ => return Ok(status(StatusCode::FORBIDDEN)),
1531 };
1532 let before = col.as_ref().map(|(_, c)| c.clone());
1533 // The inbox names the calendar that receives invitations (RFC 6638,
1534 // 9.2). `Some(Err(()))`: it names none of the owner's calendars.
1535 let default_url = Name::new(CALDAV, "schedule-default-calendar-URL");
1536 let mut default = None;
1537 if matches!(res, Res::Inbox(..)) {
1538 if let Some(i) = update.set.iter().position(|p| Name::of(p) == default_url) {
1539 let p = update.set.remove(i);
1540 let href = xml::child(&p, DAV, "href").map(xml::text);
1541 default = Some(match href {
1542 Some(h) => self.receiving_calendar(&h).await?.map(Some).ok_or(()),
1543 None => Err(()),
1544 });
1545 } else if let Some(i) = update.remove.iter().position(|n| *n == default_url) {
1546 update.remove.remove(i);
1547 default = Some(Ok(None));
1548 }
1549 }
1550 let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
1551 let stored = self.state.db.pim_props(place).await?;
1552 let mut patch = apply(
1553 col.as_mut().map(|(k, c)| (*k, c)),
1554 &update,
1555 false,
1556 &live,
1557 &stored,
1558 );
1559 let default_ok = !matches!(default, Some(Err(())));
1560 if !default_ok {
1561 for (code, _) in &mut patch.results {
1562 if *code == 200 {
1563 *code = 424;
1564 }
1565 }
1566 }
1567 let all_ok = patch.ok() && default_ok;
1568 if all_ok {
1569 let db = &self.state.db;
1570 db.pim_patch(
1571 place,
1572 before.as_ref().zip(col.as_ref().map(|(_, c)| c)),
1573 &patch.set,
1574 &patch.remove,
1575 )
1576 .await?;
1577 if let Some(Ok(id)) = default {
1578 db.pim_set_default_calendar(self.space().id, id).await?;
1579 }
1580 }
1581 let mut r = xml::Response::new(href);
1582 r.error = match (default_ok, patch.protected) {
1583 (false, _) => Some(el(CALDAV, "valid-schedule-default-calendar-URL")),
1584 (true, true) => Some(el(DAV, "cannot-modify-protected-property")),
1585 (true, false) => None,
1586 };
1587 for (code, prop) in patch.results {
1588 r.push(code, prop);
1589 }
1590 if let Some(d) = default {
1591 let code = match (d, all_ok) {
1592 (Err(()), _) => 403,
1593 (Ok(_), true) => 200,
1594 (Ok(_), false) => 424,
1595 };
1596 r.push(code, default_url.element());
1597 }
1598 Ok(multistatus(&[r], None))
1599 }
1600
1601 /// The id of the own calendar at `href` that can receive invitations:
1602 /// stored, not the inbox, taking events.
1603 async fn receiving_calendar(&self, href: &str) -> Result<Option<i64>, ApiError> {
1604 let path = match href.starts_with('/') {
1605 true => href.to_string(),
1606 false => match href.parse::<axum::http::Uri>() {
1607 Ok(u) => u.path().to_string(),
1608 Err(_) => return Ok(None),
1609 },
1610 };
1611 let space = self.space();
1612 let slug = match path.strip_prefix(PIM).and_then(parse_target) {
1613 Some(Target::Collection(PimKind::Calendar, owner, slug))
1614 if owner.eq_ignore_ascii_case(&space.path) =>
1615 {
1616 slug
1617 }
1618 _ => return Ok(None),
1619 };
1620 Ok(self
1621 .collection(PimKind::Calendar, &slug)
1622 .await?
1623 .filter(|c| {
1624 c.access == Access::Own
1625 && !generated(c.c.id)
1626 && c.c.slug != INBOX
1627 && c.c.components.split(',').any(|x| x == "VEVENT")
1628 })
1629 .map(|c| c.c.id))
1630 }
1631
1632 /// The owner changes the properties of its principal and homes, admins
1633 /// those of rooms and resources.
1634 fn may_edit(&self, s: &Space) -> bool {
1635 s.mine || (self.me.admin && s.kind != UserType::Individual)
1636 }
1637
1638 async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply {
1639 let Target::Collection(kind, _, slug) = target else {
1640 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1641 };
1642 let space = self.space();
1643 if !space.mine {
1644 return Ok(denied(&space.home(*kind), "bind"));
1645 }
1646 let calendar = method == "MKCALENDAR";
1647 if calendar && *kind != PimKind::Calendar {
1648 return Ok(status(StatusCode::FORBIDDEN));
1649 }
1650 if self.collection(*kind, slug).await?.is_some() {
1651 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1652 }
1653 // Names the home shows for lent and generated collections.
1654 if slug.starts_with(SHARED_PREFIX)
1655 || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
1656 || slug.len() > MAX_SLUG
1657 {
1658 return Ok(status(StatusCode::FORBIDDEN));
1659 }
1660 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
1661 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
1662 };
1663 let Ok(update) = xml::update(&body) else {
1664 return Ok(status(StatusCode::BAD_REQUEST));
1665 };
1666 // A plain MKCOL makes a plain collection, which a calendar home cannot
1667 // hold. An address book home takes it as an address book.
1668 let typed = update
1669 .set
1670 .iter()
1671 .any(|p| Name::of(p).is(DAV, "resourcetype"));
1672 if !calendar && *kind == PimKind::Calendar && !typed {
1673 return Ok(status(StatusCode::FORBIDDEN));
1674 }
1675 let mut col = PimCollection {
1676 slug: slug.clone(),
1677 components: match kind {
1678 PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(),
1679 PimKind::AddressBook => String::new(),
1680 },
1681 ..Default::default()
1682 };
1683 let res = Res::Collection(
1684 *kind,
1685 Col {
1686 c: col.clone(),
1687 access: Access::Own,
1688 owner: space.principal(),
1689 },
1690 );
1691 let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
1692 let patch = apply(Some((*kind, &mut col)), &update, true, &live, &[]);
1693 if !patch.ok() {
1694 let root = match calendar {
1695 true => Name::new(CALDAV, "mkcalendar-response"),
1696 false => Name::new(DAV, "mkcol-response"),
1697 };
1698 let propstats = group(patch.results);
1699 return Ok(xml_response(
1700 StatusCode::FORBIDDEN,
1701 xml::propstat_document(&root, &propstats),
1702 ));
1703 }
1704 let _lock = pim_schedule::LOCK.lock().await;
1705 let count = self.state.db.pim_collections(self.me.pid, *kind).await?;
1706 if count.iter().filter(|c| c.slug != INBOX).count() >= MAX_COLLECTIONS {
1707 return Ok(status(StatusCode::FORBIDDEN));
1708 }
1709 if !self
1710 .state
1711 .db
1712 .pim_create_collection(self.me.pid, *kind, &col, &patch.set)
1713 .await?
1714 {
1715 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1716 }
1717 Ok(status(StatusCode::CREATED))
1718 }
1719}
1720
1721fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
1722 let mut r = xml::Response::default();
1723 for (code, prop) in results {
1724 r.push(code, prop);
1725 }
1726 r.propstats
1727}
1728
1729/// A property update: each property with its status, and the client
1730/// properties to store and remove.
1731struct Patch {
1732 results: Vec<(u16, Element)>,
1733 set: Vec<DeadProp>,
1734 remove: Vec<(String, String)>,
1735 /// A property the server computes was named.
1736 protected: bool,
1737}
1738
1739impl Patch {
1740 fn ok(&self) -> bool {
1741 self.results.iter().all(|(code, _)| *code == 200)
1742 }
1743}
1744
1745/// DAV properties the server computes on some resource, beyond the ones
1746/// `live` names for the resource at hand.
1747const PROTECTED: [&str; 20] = [
1748 "acl",
1749 "alternate-URI-set",
1750 "creationdate",
1751 "current-user-principal",
1752 "current-user-privilege-set",
1753 "getcontentlength",
1754 "getcontenttype",
1755 "getetag",
1756 "getlastmodified",
1757 "group",
1758 "group-member-set",
1759 "group-membership",
1760 "lockdiscovery",
1761 "owner",
1762 "principal-URL",
1763 "principal-collection-set",
1764 "resourcetype",
1765 "supported-report-set",
1766 "supportedlock",
1767 "sync-token",
1768];
1769
1770/// Applies a PROPPATCH, MKCALENDAR or extended MKCOL body. A collection's
1771/// own properties go into `col`. What the server computes (`live`, or a
1772/// [`PROTECTED`] DAV property) is refused; anything else is stored as the
1773/// client sent it, as clients expect of properties such as Apple's
1774/// `default-alarm-vevent-date`. Nothing may be stored unless all of it is
1775/// allowed: RFC 4918 makes PROPPATCH atomic.
1776fn apply(
1777 mut col: Option<(PimKind, &mut PimCollection)>,
1778 update: &Update,
1779 creating: bool,
1780 live: &[Name],
1781 stored: &[DeadProp],
1782) -> Patch {
1783 let mut patch = Patch {
1784 results: Vec::new(),
1785 set: Vec::new(),
1786 remove: Vec::new(),
1787 protected: false,
1788 };
1789 let is_protected =
1790 |n: &Name| live.contains(n) || (n.ns == DAV && PROTECTED.contains(&n.local.as_str()));
1791 for p in &update.set {
1792 let name = Name::of(p);
1793 let xml = xml::document(p);
1794 let own = col
1795 .as_mut()
1796 .and_then(|(kind, c)| set_own(*kind, c, p, &name, creating));
1797 let code = match own {
1798 Some(false) => 403,
1799 None if is_protected(&name) => {
1800 patch.protected = true;
1801 403
1802 }
1803 _ if xml.len() > MAX_DEAD_SIZE => 507,
1804 Some(true) => 200,
1805 None => {
1806 patch.set.push(DeadProp {
1807 ns: name.ns.clone(),
1808 name: name.local.clone(),
1809 xml,
1810 });
1811 200
1812 }
1813 };
1814 patch.results.push((code, name.element()));
1815 }
1816 for name in &update.remove {
1817 let own = col
1818 .as_mut()
1819 .and_then(|(kind, c)| remove_own(*kind, c, name));
1820 let code = match own {
1821 Some(()) => 200,
1822 None if is_protected(name) => {
1823 patch.protected = true;
1824 403
1825 }
1826 None => {
1827 patch.remove.push((name.ns.clone(), name.local.clone()));
1828 200
1829 }
1830 };
1831 patch.results.push((code, name.element()));
1832 }
1833 let mut names: Vec<(&str, &str)> = stored
1834 .iter()
1835 .map(|p| (p.ns.as_str(), p.name.as_str()))
1836 .chain(patch.set.iter().map(|p| (p.ns.as_str(), p.name.as_str())))
1837 .filter(|n| {
1838 !patch
1839 .remove
1840 .iter()
1841 .any(|(ns, l)| (ns.as_str(), l.as_str()) == *n)
1842 })
1843 .collect();
1844 names.sort_unstable();
1845 names.dedup();
1846 let replaced = |p: &DeadProp| {
1847 patch
1848 .set
1849 .iter()
1850 .any(|s| (&s.ns, &s.name) == (&p.ns, &p.name))
1851 || patch
1852 .remove
1853 .iter()
1854 .any(|(ns, l)| (ns, l) == (&p.ns, &p.name))
1855 };
1856 let size = stored
1857 .iter()
1858 .filter(|p| !replaced(p))
1859 .chain(&patch.set)
1860 .map(|p| p.xml.len())
1861 .sum::<usize>()
1862 + col
1863 .as_ref()
1864 .and_then(|(_, c)| c.timezone.as_ref())
1865 .map_or(0, String::len);
1866 if names.len() > MAX_DEAD_PROPS || size > MAX_DEAD_TOTAL {
1867 // Only what adds to the total is refused.
1868 for (code, prop) in patch.results.iter_mut().take(update.set.len()) {
1869 let n = Name::of(prop);
1870 if n.is(CALDAV, "calendar-timezone")
1871 || patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local)
1872 {
1873 *code = 507;
1874 }
1875 }
1876 }
1877 if !patch.ok() {
1878 for (code, _) in &mut patch.results {
1879 if *code == 200 {
1880 *code = 424;
1881 }
1882 }
1883 }
1884 patch
1885}
1886
1887/// `#RRGGBB` or `#RRGGBBAA`, with `#RGB` widened to `#RRGGBB`.
1888pub(super) fn color(v: &str) -> Option<String> {
1889 let hex = v
1890 .strip_prefix('#')
1891 .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))?;
1892 match hex.len() {
1893 3 => Some(format!(
1894 "#{}",
1895 hex.chars().flat_map(|c| [c, c]).collect::<String>()
1896 )),
1897 6 | 8 => Some(v.to_string()),
1898 _ => None,
1899 }
1900}
1901
1902/// An integer order. Some clients write a fraction.
1903fn order(v: &str) -> Option<String> {
1904 let n = v.parse::<f64>().ok().filter(|n| n.is_finite())?;
1905 Some((n.round() as i64).to_string())
1906}
1907
1908/// Sets one of a collection's own properties. `None` if it is none of them,
1909/// `Some(valid)` otherwise.
1910fn set_own(
1911 kind: PimKind,
1912 col: &mut PimCollection,
1913 p: &Element,
1914 name: &Name,
1915 creating: bool,
1916) -> Option<bool> {
1917 let cal = kind == PimKind::Calendar;
1918 let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
1919 let short = |v: &Option<String>, max: usize, lines: bool| {
1920 v.as_ref().is_none_or(|v| valid_text(v, max, lines))
1921 };
1922 Some(match (name.ns.as_str(), name.local.as_str()) {
1923 (DAV, "displayname") => {
1924 let v = value();
1925 let valid = short(&v, MAX_DISPLAYNAME, false);
1926 if valid {
1927 col.displayname = v;
1928 }
1929 valid
1930 }
1931 (CALDAV, "calendar-description") if cal => {
1932 let v = value();
1933 let valid = short(&v, MAX_DESCRIPTION, true);
1934 if valid {
1935 col.description = v;
1936 }
1937 valid
1938 }
1939 (CARDDAV, "addressbook-description") if !cal => {
1940 let v = value();
1941 let valid = short(&v, MAX_DESCRIPTION, true);
1942 if valid {
1943 col.description = v;
1944 }
1945 valid
1946 }
1947 (APPLE, "calendar-color") if cal => match value() {
1948 None => {
1949 col.color = None;
1950 true
1951 }
1952 Some(v) => color(&v).map(|c| col.color = Some(c)).is_some(),
1953 },
1954 (APPLE, "calendar-order") if cal => match value() {
1955 None => {
1956 col.sort_order = None;
1957 true
1958 }
1959 Some(v) => order(&v).map(|o| col.sort_order = Some(o)).is_some(),
1960 },
1961 (CALDAV, "calendar-timezone") if cal => {
1962 let tz = value();
1963 let valid = tz.as_deref().is_none_or(is_timezone);
1964 if valid {
1965 col.timezone = tz;
1966 }
1967 valid
1968 }
1969 (CALDAV, "schedule-calendar-transp") if cal => {
1970 let transparent = xml::child(p, CALDAV, "transparent").is_some();
1971 let valid = transparent || xml::child(p, CALDAV, "opaque").is_some();
1972 if valid {
1973 col.transparent = transparent;
1974 }
1975 valid
1976 }
1977 (DAV, "resourcetype") if creating => {
1978 let wanted = match kind {
1979 PimKind::Calendar => (CALDAV, "calendar"),
1980 PimKind::AddressBook => (CARDDAV, "addressbook"),
1981 };
1982 xml::child(p, wanted.0, wanted.1).is_some()
1983 }
1984 (CALDAV, "supported-calendar-component-set") if creating && cal => {
1985 let comps: Vec<_> = xml::elements(p)
1986 .filter(|c| Name::of(c).is(CALDAV, "comp"))
1987 .filter_map(|c| c.attributes.get("name"))
1988 .map(|n| n.to_ascii_uppercase())
1989 .collect();
1990 let valid = !comps.is_empty()
1991 && comps
1992 .iter()
1993 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
1994 if valid {
1995 col.components = comps.join(",");
1996 }
1997 valid
1998 }
1999 _ => return None,
2000 })
2001}
2002
2003/// Removes one of a collection's own properties. `None` if it is none of
2004/// them.
2005fn remove_own(kind: PimKind, col: &mut PimCollection, name: &Name) -> Option<()> {
2006 let cal = kind == PimKind::Calendar;
2007 if cal && name.is(CALDAV, "schedule-calendar-transp") {
2008 col.transparent = false;
2009 return Some(());
2010 }
2011 let field = match (name.ns.as_str(), name.local.as_str()) {
2012 (DAV, "displayname") => &mut col.displayname,
2013 (CALDAV, "calendar-description") if cal => &mut col.description,
2014 (CARDDAV, "addressbook-description") if !cal => &mut col.description,
2015 (APPLE, "calendar-color") if cal => &mut col.color,
2016 (APPLE, "calendar-order") if cal => &mut col.sort_order,
2017 (CALDAV, "calendar-timezone") if cal => &mut col.timezone,
2018 _ => return None,
2019 };
2020 *field = None;
2021 Some(())
2022}
2023
2024/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
2025fn is_timezone(v: &str) -> bool {
2026 use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
2027 ICalendar::parse(v).is_ok_and(|c| {
2028 c.components
2029 .iter()
2030 .any(|c| c.component_type == ICalendarComponentType::VTimezone)
2031 })
2032}
2033
2034// ---------------------------------------------------------------------------
2035// Objects
2036// ---------------------------------------------------------------------------
2037
2038impl Cx<'_> {
2039 async fn get(&self, target: &Target, headers: &HeaderMap, head: bool) -> Reply {
2040 let Target::Object(kind, _, slug, name) = target else {
2041 return self.get_collection(target, head).await;
2042 };
2043 let found = match self.collection(*kind, slug).await? {
2044 Some(col) => self.member(&col.c, name).await?,
2045 None => None,
2046 };
2047 let Some((o, mut data)) = found else {
2048 return Ok(status(StatusCode::NOT_FOUND));
2049 };
2050 if *kind == PimKind::AddressBook {
2051 let accept = headers.get("accept").and_then(|v| v.to_str().ok());
2052 let req = render::AddressData {
2053 props: None,
2054 version: Some(render::accepted_version(accept)),
2055 };
2056 data = blocking(move || -> Result<_, ApiError> {
2057 Ok(render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes())
2058 })
2059 .await?;
2060 }
2061 let length = data.len().to_string();
2062 let body = if head {
2063 Body::empty()
2064 } else {
2065 Body::from(data)
2066 };
2067 let mut r = (
2068 StatusCode::OK,
2069 [
2070 (CONTENT_TYPE, content_type(*kind, &o.component)),
2071 (ETAG, o.etag),
2072 (CONTENT_LENGTH, length),
2073 ],
2074 body,
2075 )
2076 .into_response();
2077 with_schedule_tag(&mut r, o.schedule_tag.as_deref());
2078 Ok(r)
2079 }
2080
2081 /// Clients that discover with GET, as RFC 6764 allows, expect a 2xx on
2082 /// every collection on the way.
2083 async fn get_collection(&self, target: &Target, head: bool) -> Reply {
2084 if let Target::Collection(kind, _, slug) = target
2085 && !(*kind == PimKind::Calendar && slug == OUTBOX && self.space().mine)
2086 && self.collection(*kind, slug).await?.is_none()
2087 {
2088 return Ok(status(StatusCode::NOT_FOUND));
2089 }
2090 let text = "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n";
2091 Ok((
2092 StatusCode::OK,
2093 [
2094 (CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
2095 (CONTENT_LENGTH, text.len().to_string()),
2096 ],
2097 if head { "" } else { text },
2098 )
2099 .into_response())
2100 }
2101
2102 async fn put(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
2103 let Target::Object(kind, _, slug, name) = target else {
2104 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
2105 };
2106 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
2107 return Ok(status(StatusCode::CONFLICT));
2108 };
2109 let space = self.space();
2110 // The server alone delivers into the inbox.
2111 if access < Access::Write || col.slug == INBOX {
2112 return Ok(denied(&space.collection(*kind, slug), "bind"));
2113 }
2114 let ns = kind_ns(*kind);
2115 let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
2116 return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
2117 };
2118 let (kind_c, components, name_c) = (*kind, col.components.clone(), name.clone());
2119 let (parsed, stamped, data) = blocking(move || -> Result<_, ApiError> {
2120 let parsed = match kind_c {
2121 PimKind::Calendar => {
2122 let supported: Vec<&str> = components.split(',').collect();
2123 object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
2124 }
2125 PimKind::AddressBook => {
2126 object::vcard(&data).map(|uid| (uid.unwrap_or(name_c), "VCARD".into()))
2127 }
2128 };
2129 let stamped = match (&parsed, kind_c) {
2130 (Ok(_), PimKind::Calendar) => object::with_dtstamp(&data, chrono::Utc::now()),
2131 _ => None,
2132 };
2133 Ok((parsed, stamped, data))
2134 })
2135 .await?;
2136 let (uid, component) = match parsed {
2137 Ok(v) => v,
2138 Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
2139 };
2140 let data = stamped.as_deref().unwrap_or(&data);
2141
2142 let _lock = pim_schedule::LOCK.lock().await;
2143 // A DELETE of the collection or of the share may have run meanwhile.
2144 let access = match self.collection(*kind, slug).await? {
2145 Some(now) if now.c.id == col.id => now.access,
2146 _ => return Ok(status(StatusCode::CONFLICT)),
2147 };
2148 if access < Access::Write {
2149 return Ok(denied(&space.collection(*kind, slug), "bind"));
2150 }
2151 let db = &self.state.db;
2152 let current = self.member(&col, name).await?;
2153 if current.is_none() && name.len() > MAX_SLUG {
2154 return Ok(status(StatusCode::FORBIDDEN));
2155 }
2156 if refuses(headers, current.as_ref().map(|(o, _)| o)) {
2157 return Ok(status(StatusCode::PRECONDITION_FAILED));
2158 }
2159 if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? {
2160 return Ok(error(
2161 StatusCode::FORBIDDEN,
2162 with_children(
2163 el(ns, "no-uid-conflict"),
2164 hrefs([space.object(*kind, slug, &holder).as_str()]),
2165 ),
2166 ));
2167 }
2168 let stored = match kind {
2169 PimKind::Calendar => {
2170 let dir = Directory::load(self.state).await?;
2171 let owner = self.owner(&col, &dir).await?;
2172 let w = self.writer(&owner, access);
2173 let old = current.as_ref().map(|(_, d)| d.as_slice());
2174 match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, data).await? {
2175 Ok(s) => s,
2176 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
2177 }
2178 }
2179 PimKind::AddressBook => Stored {
2180 data: data.to_vec(),
2181 changed: false,
2182 schedule_tag: None,
2183 ops: Vec::new(),
2184 },
2185 };
2186 let etag = etag_of(&stored.data);
2187 let mut ops = vec![PimOp::Put {
2188 collection_id: col.id,
2189 obj: PimObject {
2190 name: name.clone(),
2191 uid,
2192 component,
2193 etag: etag.clone(),
2194 schedule_tag: stored.schedule_tag.clone(),
2195 ..Default::default()
2196 },
2197 data: stored.data,
2198 }];
2199 ops.extend(stored.ops);
2200 db.pim_apply(&ops).await?;
2201 let code = match current {
2202 Some(_) => StatusCode::NO_CONTENT,
2203 None => StatusCode::CREATED,
2204 };
2205 let mut r = status(code);
2206 // Only when the stored bytes are the request bytes (RFC 4791, 5.3.4).
2207 if !stored.changed && stamped.is_none() {
2208 r.headers_mut()
2209 .insert(ETAG, etag.parse().expect("hex is a valid header"));
2210 }
2211 with_schedule_tag(&mut r, stored.schedule_tag.as_deref());
2212 Ok(r)
2213 }
2214
2215 /// The signed-in account writing into a calendar of `owner`.
2216 fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> {
2217 Writer {
2218 owner,
2219 may_schedule: access >= Access::Schedule,
2220 sent_by: (access != Access::Own).then(|| self.me.address.clone()),
2221 quiet: false,
2222 }
2223 }
2224
2225 /// The principal owning a collection, whose addresses decide how it takes
2226 /// part in the objects there.
2227 async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result<PimPrincipal, ApiError> {
2228 let owner = match self.state.db.pim_collection_by_id(col.id).await? {
2229 Some((id, _, _)) => dir.get(id).cloned(),
2230 None => None,
2231 };
2232 owner.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))
2233 }
2234
2235 async fn delete(&self, target: &Target, headers: &HeaderMap) -> Reply {
2236 let (kind, slug, name) = match target {
2237 Target::Collection(k, _, s) => (k, s, None),
2238 Target::Object(k, _, s, n) => (k, s, Some(n)),
2239 _ => return Ok(status(StatusCode::FORBIDDEN)),
2240 };
2241 // Under the lock, so a revoked share applies at once. `delete_own`
2242 // takes it for a collection.
2243 let _lock = match name {
2244 Some(_) => Some(pim_schedule::LOCK.lock().await),
2245 None => None,
2246 };
2247 let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
2248 return Ok(status(StatusCode::NOT_FOUND));
2249 };
2250 let space = self.space();
2251 let href = space.collection(*kind, slug);
2252 let scheduling = *kind == PimKind::Calendar && col.slug != INBOX;
2253 let db = &self.state.db;
2254 let Some(name) = name else {
2255 return Ok(match access {
2256 Access::Own if *kind == PimKind::Calendar && col.slug == INBOX => {
2257 denied(&space.home(*kind), "unbind")
2258 }
2259 Access::Own => match delete_own(self.state, space.id, *kind, &col).await? {
2260 Ok(()) => status(StatusCode::NO_CONTENT),
2261 Err(condition) => error(StatusCode::FORBIDDEN, condition),
2262 },
2263 // Deleting a lent collection only takes it out of this home.
2264 _ if slug.starts_with(SHARED_PREFIX) && space.mine => {
2265 let _lock = pim_schedule::LOCK.lock().await;
2266 db.pim_remove_share(col.id, self.me.id).await?;
2267 status(StatusCode::NO_CONTENT)
2268 }
2269 _ => denied(&space.home(*kind), "unbind"),
2270 });
2271 };
2272 if access < Access::Write {
2273 return Ok(denied(&href, "unbind"));
2274 }
2275 let Some((obj, data)) = self.member(&col, name).await? else {
2276 return Ok(status(StatusCode::NOT_FOUND));
2277 };
2278 if refuses(headers, Some(&obj)) {
2279 return Ok(status(StatusCode::PRECONDITION_FAILED));
2280 }
2281 let mut ops = vec![PimOp::Delete {
2282 collection_id: col.id,
2283 name: name.clone(),
2284 }];
2285 if scheduling {
2286 let dir = Directory::load(self.state).await?;
2287 let owner = self.owner(&col, &dir).await?;
2288 let w = self.writer(&owner, access);
2289 let reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F");
2290 match pim_schedule::delete(self.state, &dir, &w, &data, reply).await? {
2291 Ok(more) => ops.extend(more),
2292 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
2293 }
2294 }
2295 db.pim_apply(&ops).await?;
2296 Ok(status(StatusCode::NO_CONTENT))
2297 }
2298}
2299
2300/// Whether If-Match, If-None-Match or If-Schedule-Tag-Match fails against
2301/// the current object.
2302fn refuses(headers: &HeaderMap, current: Option<&PimObject>) -> bool {
2303 if !precondition(headers).allows(current.map(|o| o.etag.as_str())) {
2304 return true;
2305 }
2306 headers
2307 .get("if-schedule-tag-match")
2308 .and_then(|v| v.to_str().ok())
2309 .is_some_and(|tag| current.and_then(|o| o.schedule_tag.as_deref()) != Some(tag.trim()))
2310}
2311
2312fn with_schedule_tag(r: &mut Response<Body>, tag: Option<&str>) {
2313 if let Some(v) = tag.and_then(|t| t.parse().ok()) {
2314 r.headers_mut().insert("schedule-tag", v);
2315 }
2316}
2317
2318fn precondition(headers: &HeaderMap) -> Precondition {
2319 let header = |name: &str| {
2320 headers
2321 .get(name)
2322 .and_then(|v| v.to_str().ok())
2323 .map(str::to_string)
2324 };
2325 Precondition {
2326 if_match: header("if-match"),
2327 if_none_match: header("if-none-match"),
2328 }
2329}
2330
2331// ---------------------------------------------------------------------------
2332// REPORT
2333// ---------------------------------------------------------------------------
2334
2335impl Cx<'_> {
2336 async fn report(&self, target: &Target, body: Body) -> Reply {
2337 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2338 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2339 };
2340 let report = match report::parse(&body) {
2341 Ok(r) => r,
2342 Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
2343 Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
2344 };
2345 let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
2346 let on_principals = matches!(
2347 target,
2348 Target::Root | Target::Principals | Target::Principal(_)
2349 );
2350 match report {
2351 Report::PrincipalSearch(search) if on_principals => {
2352 return self.principal_search(&search).await;
2353 }
2354 Report::PrincipalSearchPropertySet if on_principals => {
2355 return Ok(search_property_set());
2356 }
2357 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
2358 return unsupported();
2359 }
2360 _ => {}
2361 }
2362 let Target::Collection(kind, _, slug) = target else {
2363 return unsupported();
2364 };
2365 let calendar_report = matches!(
2366 report,
2367 Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
2368 );
2369 let card_report = matches!(
2370 report,
2371 Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
2372 );
2373 if (calendar_report && *kind != PimKind::Calendar)
2374 || (card_report && *kind != PimKind::AddressBook)
2375 {
2376 return unsupported();
2377 }
2378 let Some(Col { c: col, .. }) = self.collection(*kind, slug).await? else {
2379 return Ok(status(StatusCode::NOT_FOUND));
2380 };
2381 // Busy time comes from calendars, never from messages (RFC 6638, 2.3).
2382 if col.slug == INBOX && matches!(report, Report::FreeBusy(_)) {
2383 return unsupported();
2384 }
2385 let floating = col
2386 .timezone
2387 .as_deref()
2388 .and_then(zone::from_vtimezone)
2389 .unwrap_or(Zone::Utc);
2390 let mut out = Out {
2391 me: self.me.clone(),
2392 space: self.space().clone(),
2393 kind: *kind,
2394 col: col.clone(),
2395 expanded: 0,
2396 };
2397
2398 match report {
2399 Report::CalendarMultiget { props, hrefs }
2400 | Report::AddressbookMultiget { props, hrefs } => {
2401 let members = self.generated_members(&col).await?;
2402 let mut seen = HashSet::new();
2403 let mut found = Vec::new();
2404 let mut loaded = 0;
2405 let mut cut = false;
2406 for href in hrefs {
2407 if !seen.insert(href.clone()) {
2408 continue;
2409 }
2410 if found.len() >= MAX_MULTIGET_HREFS || loaded > MAX_MULTIGET_BYTES {
2411 cut = true;
2412 break;
2413 }
2414 let hit = match self.own_object(*kind, &href) {
2415 Some((slug, name)) if slug == col.slug => match &members {
2416 Some(m) => m.get(&name).cloned(),
2417 None => self.state.db.pim_object(col.id, &name).await?,
2418 },
2419 _ => None,
2420 };
2421 loaded += hit.as_ref().map_or(0, |(_, data)| data.len());
2422 found.push((href, hit));
2423 }
2424 blocking(move || -> Reply {
2425 let mut responses = Vec::new();
2426 for (href, hit) in found {
2427 if out.full() {
2428 cut = true;
2429 break;
2430 }
2431 responses.push(match hit {
2432 // The href as the client wrote it, so it can match it.
2433 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
2434 Ok(r) => xml::Response { href, ..r },
2435 Err(TooManyInstances) => return Ok(too_many()),
2436 },
2437 None => xml::Response::status(href, 404),
2438 });
2439 }
2440 if cut {
2441 responses.push(out.over_limit());
2442 }
2443 Ok(multistatus(&responses, None))
2444 })
2445 .await
2446 }
2447 Report::CalendarQuery {
2448 props,
2449 filter,
2450 timezone,
2451 } => {
2452 let floating = timezone.unwrap_or(floating);
2453 let members = self.members(&col).await?;
2454 blocking(move || -> Reply {
2455 let mut responses = Vec::new();
2456 for (o, data) in members {
2457 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref())
2458 else {
2459 continue;
2460 };
2461 if !filter::matches_calendar(&cal, &filter, &floating) {
2462 continue;
2463 }
2464 if out.full() {
2465 responses.push(out.over_limit());
2466 break;
2467 }
2468 match out.object(&o, &data, &props, &floating) {
2469 Ok(r) => responses.push(r),
2470 Err(TooManyInstances) => return Ok(too_many()),
2471 }
2472 }
2473 Ok(multistatus(&responses, None))
2474 })
2475 .await
2476 }
2477 Report::AddressbookQuery {
2478 props,
2479 filter,
2480 limit,
2481 } => {
2482 let members = self.members(&col).await?;
2483 blocking(move || -> Reply {
2484 let mut responses = Vec::new();
2485 let mut truncated = false;
2486 for (o, data) in members {
2487 let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
2488 continue;
2489 };
2490 if !filter::matches_card(&card, &filter) {
2491 continue;
2492 }
2493 if limit.is_some_and(|n| responses.len() >= n) {
2494 truncated = true;
2495 break;
2496 }
2497 if let Ok(r) = out.object(&o, &data, &props, &floating) {
2498 responses.push(r);
2499 }
2500 }
2501 if truncated {
2502 responses.push(out.over_limit());
2503 }
2504 Ok(multistatus(&responses, None))
2505 })
2506 .await
2507 }
2508 Report::SyncCollection {
2509 token,
2510 props,
2511 limit,
2512 } => {
2513 let (since, issued) = match token.is_empty() {
2514 true => (None, None),
2515 false => match parse_sync_token(&token) {
2516 // A generated collection has no change log: only its
2517 // current token is valid.
2518 Some((id, seq, None))
2519 if id == col.id && generated(id) && seq == col.seq =>
2520 {
2521 (Some(seq), None)
2522 }
2523 Some((id, seq, issued))
2524 if id == col.id
2525 && !generated(id)
2526 && seq <= col.seq
2527 && issued.is_none_or(|i| seq <= i && i <= col.seq) =>
2528 {
2529 (Some(seq), issued)
2530 }
2531 _ => return Ok(invalid_sync_token()),
2532 },
2533 };
2534 // A generated collection has no change log to resume a cut
2535 // answer from. It is small, so it always answers in full.
2536 let limit = limit.filter(|_| !generated(col.id));
2537 // The changes come first: a write between the two reads then
2538 // only makes the next sync refetch a member.
2539 let mut changes = match generated(col.id) {
2540 true => Vec::new(),
2541 false => match self.state.db.pim_changes(col.id, since, issued).await? {
2542 Some(c) => c,
2543 None => return Ok(invalid_sync_token()),
2544 },
2545 };
2546 // An initial sync reads every member at once, not one per change.
2547 let mut members = match since {
2548 None => Some(self.member_map(&col).await?),
2549 Some(_) => None,
2550 };
2551 if let (Some(m), true) = (&members, generated(col.id)) {
2552 let mut names: Vec<_> = m.keys().cloned().collect();
2553 names.sort();
2554 changes = names.into_iter().map(|n| (n, col.seq, false)).collect();
2555 }
2556 // The client of a cut initial sync saw nothing deleted before it
2557 // began, so pruning up to there leaves its resume token valid.
2558 let issued = issued.or(since.is_none().then_some(col.seq));
2559 let truncated = limit.is_some_and(|n| changes.len() > n);
2560 if let Some(n) = limit {
2561 changes.truncate(n);
2562 }
2563 // A truncated answer hands out the token of its last change, so
2564 // the next sync resumes after it.
2565 let seq = match (truncated, changes.last()) {
2566 _ if generated(col.id) => col.seq,
2567 (true, Some((_, s, _))) => *s,
2568 (_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
2569 };
2570 let mut found = Vec::with_capacity(changes.len());
2571 for (name, change, deleted) in changes {
2572 let hit = match (deleted, members.as_mut().and_then(|m| m.remove(&name))) {
2573 (true, _) => None,
2574 (false, Some(hit)) => Some(hit),
2575 // Written after the member map was read.
2576 (false, None) if !generated(col.id) => {
2577 self.state.db.pim_object(col.id, &name).await?
2578 }
2579 (false, None) => None,
2580 };
2581 found.push((name, change, hit));
2582 }
2583 let slug = col.slug.clone();
2584 let cuttable = !generated(col.id);
2585 blocking(move || -> Reply {
2586 let (mut responses, mut seq, mut truncated) = (Vec::new(), seq, truncated);
2587 let mut last = seq;
2588 for (name, change, hit) in found {
2589 // Cut like a client limit: the token of the last change answered.
2590 if cuttable && out.full() {
2591 (seq, truncated) = (last, true);
2592 break;
2593 }
2594 last = change;
2595 responses.push(match hit {
2596 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
2597 Ok(r) => r,
2598 Err(TooManyInstances) => return Ok(too_many()),
2599 },
2600 None => {
2601 xml::Response::status(out.space.object(out.kind, &slug, &name), 404)
2602 }
2603 });
2604 }
2605 if truncated {
2606 responses.push(out.over_limit());
2607 }
2608 // Past `issued`, the answer holds every change up to `seq`.
2609 let token = sync_token(col.id, seq, issued.filter(|&i| truncated && seq <= i));
2610 Ok(multistatus(
2611 &responses,
2612 Some(with_text(el(DAV, "sync-token"), token)),
2613 ))
2614 })
2615 .await
2616 }
2617 Report::FreeBusy(range) => {
2618 let members = self.members(&col).await?;
2619 blocking(move || -> Reply {
2620 let mut busy = Vec::new();
2621 for (_, data) in members {
2622 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
2623 // ponytail: one period per instance, so a long range over
2624 // a frequent series makes a long answer.
2625 busy.extend(freebusy::busy(&cal, &range, &floating, None));
2626 }
2627 }
2628 let body =
2629 freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
2630 Ok((
2631 StatusCode::OK,
2632 [(CONTENT_TYPE, "text/calendar; charset=utf-8")],
2633 body,
2634 )
2635 .into_response())
2636 })
2637 .await
2638 }
2639 Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
2640 unreachable!("answered above")
2641 }
2642 }
2643 }
2644
2645 /// principal-property-search and calendarserver-principal-search.
2646 async fn principal_search(&self, search: &Search) -> Reply {
2647 let mut responses = Vec::new();
2648 let mut truncated = false;
2649 for p in self.state.db.pim_principals(true).await? {
2650 let view = PrincipalView::of(&p, self.me);
2651 let addresses = view.addresses();
2652 let candidate = Principal {
2653 name: &p.name,
2654 display: p.display(),
2655 addresses: &addresses,
2656 kind: p.kind,
2657 };
2658 if !search.matches(&candidate) {
2659 continue;
2660 }
2661 if search.limit.is_some_and(|n| responses.len() >= n) {
2662 truncated = true;
2663 break;
2664 }
2665 let href = principal_href(&p.name);
2666 responses.push(select(
2667 href,
2668 &search.find,
2669 self.props(&Res::Principal(view)),
2670 ));
2671 }
2672 if truncated {
2673 let mut r = xml::Response::status(format!("{PIM}/principals/"), 507);
2674 r.error = Some(el(DAV, "number-of-matches-within-limits"));
2675 responses.push(r);
2676 }
2677 Ok(multistatus(&responses, None))
2678 }
2679
2680 /// `(collection slug, object name)` of an href to an object of `kind` in
2681 /// the space of this request. Takes a path or a full URL.
2682 fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> {
2683 let path = match href.starts_with('/') {
2684 true => href.to_string(),
2685 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
2686 };
2687 let space = self.space?;
2688 match parse_target(path.strip_prefix(PIM)?)? {
2689 Target::Object(k, owner, slug, name)
2690 if k == kind && owner.eq_ignore_ascii_case(&space.path) =>
2691 {
2692 Some((slug, name))
2693 }
2694 _ => None,
2695 }
2696 }
2697}
2698
2699fn search_property_set() -> Response<Body> {
2700 let body = xml::document(&with_children(
2701 el(DAV, "principal-search-property-set"),
2702 principal::SEARCHABLE.map(|(ns, local, description)| {
2703 with_children(
2704 el(DAV, "principal-search-property"),
2705 [
2706 with_children(el(DAV, "prop"), [el(ns, local)]),
2707 with_attr(
2708 with_text(el(DAV, "description"), description),
2709 "xml:lang",
2710 "en",
2711 ),
2712 ],
2713 )
2714 }),
2715 ));
2716 xml_response(StatusCode::OK, body)
2717}
2718
2719/// Instances `expand` may produce for one REPORT answer, across its objects.
2720/// Beyond it the answer is cut short with a 507, as for a client limit.
2721const MAX_EXPANDED_PER_ANSWER: usize = 20_000;
2722
2723/// Hrefs and object bytes one multiget loads. Beyond them it answers 507.
2724const MAX_MULTIGET_HREFS: usize = 1000;
2725const MAX_MULTIGET_BYTES: usize = 32 * 1024 * 1024;
2726
2727/// What a REPORT answer about one collection needs. Owned, so the answer
2728/// can be built on the blocking pool.
2729struct Out {
2730 me: Me,
2731 space: Space,
2732 kind: PimKind,
2733 col: PimCollection,
2734 /// Instances `expand` produced for this answer so far.
2735 expanded: usize,
2736}
2737
2738impl Out {
2739 fn object(
2740 &mut self,
2741 o: &PimObject,
2742 data: &[u8],
2743 props: &Props,
2744 floating: &Zone,
2745 ) -> Result<xml::Response, TooManyInstances> {
2746 let mut all = live_props(
2747 &self.me,
2748 Some(&self.space),
2749 &Res::Object(self.kind, o.clone()),
2750 );
2751 let raw = String::from_utf8_lossy(data);
2752 if let Some(req) = &props.calendar {
2753 let (text, instances) = render::calendar_data(&raw, req, floating)?;
2754 self.expanded += instances;
2755 all.push(with_text(el(CALDAV, "calendar-data"), text));
2756 }
2757 if let Some(req) = &props.address {
2758 all.push(with_text(
2759 el(CARDDAV, "address-data"),
2760 render::address_data(&raw, req),
2761 ));
2762 }
2763 let href = self.space.object(self.kind, &self.col.slug, &o.name);
2764 Ok(select(href, &props.find, all))
2765 }
2766
2767 fn full(&self) -> bool {
2768 self.expanded > MAX_EXPANDED_PER_ANSWER
2769 }
2770
2771 /// The response a query or sync adds when a limit cut it short.
2772 fn over_limit(&self) -> xml::Response {
2773 let href = self.space.collection(self.kind, &self.col.slug);
2774 let mut r = xml::Response::status(href, 507);
2775 r.error = Some(el(DAV, "number-of-matches-within-limits"));
2776 r
2777 }
2778}
2779
2780fn invalid_sync_token() -> Response<Body> {
2781 error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))
2782}
2783
2784fn too_many() -> Response<Body> {
2785 error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
2786}
2787
2788/// `(collection id, seq, issued)` of a token [`sync_token`] made.
2789fn parse_sync_token(token: &str) -> Option<(i64, i64, Option<i64>)> {
2790 let rest = token.strip_prefix("urn:dovenest:sync:")?;
2791 let (rest, issued) = match rest.split_once('.') {
2792 Some((r, i)) => (r, Some(i.parse().ok()?)),
2793 None => (rest, None),
2794 };
2795 // The birthday calendar's id is negative.
2796 let (id, seq) = rest.rsplit_once('-')?;
2797 Some((id.parse().ok()?, seq.parse().ok()?, issued))
2798}
2799
2800// ---------------------------------------------------------------------------
2801// POST
2802// ---------------------------------------------------------------------------
2803
2804impl Cx<'_> {
2805 /// A free-busy request to the own scheduling outbox (RFC 6638, 5).
2806 async fn post(&self, target: &Target, body: Body) -> Reply {
2807 let space = match target {
2808 Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX => self.space(),
2809 _ => return Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
2810 };
2811 if !space.mine {
2812 let href = space.collection(PimKind::Calendar, OUTBOX);
2813 return Ok(error(
2814 StatusCode::FORBIDDEN,
2815 need_privilege(&href, CALDAV, "schedule-send-freebusy"),
2816 ));
2817 }
2818 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
2819 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
2820 };
2821 let request = match freebusy::request(&body) {
2822 Ok(r) => r,
2823 Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition.element())),
2824 };
2825 let dir = Directory::load(self.state).await?;
2826 if !dir.is(self.me.pid)(&request.organizer) {
2827 return Ok(error(
2828 StatusCode::FORBIDDEN,
2829 el(CALDAV, "organizer-allowed"),
2830 ));
2831 }
2832 let answers = pim_schedule::free_busy(self.state, &dir, &request).await?;
2833 Ok(xml_response(
2834 StatusCode::OK,
2835 freebusy::schedule_response(&answers),
2836 ))
2837 }
2838}
2839
2840// ---------------------------------------------------------------------------
2841// MOVE
2842// ---------------------------------------------------------------------------
2843
2844impl Cx<'_> {
2845 async fn move_object(&self, target: &Target, headers: &HeaderMap) -> Reply {
2846 let Target::Object(kind, _, slug, name) = target else {
2847 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
2848 };
2849 let destination = headers.get("destination").and_then(|v| v.to_str().ok());
2850 let Some((to_slug, to_name)) = destination.and_then(|d| self.own_object(*kind, d)) else {
2851 return Ok(status(StatusCode::FORBIDDEN));
2852 };
2853 if (&to_slug, &to_name) == (slug, name) {
2854 return Ok(status(StatusCode::FORBIDDEN));
2855 }
2856 let space = self.space();
2857 let _lock = pim_schedule::LOCK.lock().await;
2858 let Some(from) = self.collection(*kind, slug).await? else {
2859 return Ok(status(StatusCode::NOT_FOUND));
2860 };
2861 let Some(to) = self.collection(*kind, &to_slug).await? else {
2862 return Ok(status(StatusCode::CONFLICT));
2863 };
2864 if from.access < Access::Write || from.c.slug == INBOX {
2865 return Ok(denied(&space.collection(*kind, slug), "unbind"));
2866 }
2867 if to.access < Access::Write || to.c.slug == INBOX {
2868 return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
2869 }
2870 // A meeting stays in its organizer's calendars (`elsewhere` allows one
2871 // scheduling object per UID and principal), so an object never changes owner. Clients fall back to
2872 // PUT and DELETE, which schedule as usual.
2873 if !from.owner.eq_ignore_ascii_case(&to.owner) {
2874 return Ok(status(StatusCode::FORBIDDEN));
2875 }
2876 let Some((obj, _)) = self.member(&from.c, name).await? else {
2877 return Ok(status(StatusCode::NOT_FOUND));
2878 };
2879 // Moving between calendars schedules nothing (RFC 6638, 3.2.3.4).
2880 if refuses(headers, Some(&obj)) {
2881 return Ok(status(StatusCode::PRECONDITION_FAILED));
2882 }
2883 if *kind == PimKind::Calendar && !to.c.components.split(',').any(|c| c == obj.component) {
2884 return Ok(error(
2885 StatusCode::FORBIDDEN,
2886 el(CALDAV, "supported-calendar-component"),
2887 ));
2888 }
2889 let overwrite = !headers
2890 .get("overwrite")
2891 .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"F"));
2892 let target = self.member(&to.c, &to_name).await?;
2893 if target.is_none() && to_name.len() > MAX_SLUG {
2894 return Ok(status(StatusCode::FORBIDDEN));
2895 }
2896 // Overwriting a meeting would drop it without telling its attendees.
2897 if overwrite && target.is_some_and(|(o, _)| o.schedule_tag.is_some()) {
2898 return Ok(status(StatusCode::FORBIDDEN));
2899 }
2900 let written = self
2901 .state
2902 .db
2903 .pim_move_object(
2904 from.c.id,
2905 name,
2906 to.c.id,
2907 &to_name,
2908 overwrite,
2909 &precondition(headers),
2910 )
2911 .await?;
2912 Ok(match written {
2913 PimWrite::Created | PimWrite::Updated => {
2914 let code = match written {
2915 PimWrite::Created => StatusCode::CREATED,
2916 _ => StatusCode::NO_CONTENT,
2917 };
2918 let mut r = status(code);
2919 with_schedule_tag(&mut r, obj.schedule_tag.as_deref());
2920 r
2921 }
2922 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
2923 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
2924 PimWrite::UidConflict(holder) => error(
2925 StatusCode::FORBIDDEN,
2926 with_children(
2927 el(kind_ns(*kind), "no-uid-conflict"),
2928 hrefs([space.object(*kind, &to_slug, &holder).as_str()]),
2929 ),
2930 ),
2931 PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
2932 })
2933 }
2934}
2935