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