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