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