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