//! CalDAV and CardDAV. //! //! URL layout under [`PIM`]: //! //! * `/principals/` and `/principals/{name}/`: accounts, rooms and resources //! * `/calendars/{name}/` and `/addressbooks/{name}/`, the homes //! * `/calendars/{name}/{collection}/` and `.../{collection}/{object}`, the //! same for address books //! //! A home also shows the collections lent to its account, as //! `shared-{collection id}`, and the address book home shows the generated //! system address book as `system`. The calendar home holds the scheduling //! `inbox` and `outbox`, and the generated `birthdays` calendar. A room's home //! holds its bookings. //! //! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto //! the store and assembles the responses. use std::sync::Arc; use api_types::PIM; use axum::body::Body; use axum::extract::State; use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION}; use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; use axum::response::IntoResponse; use percent_encoding::{ AsciiSet, CONTROLS, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode, }; use pimdav::calcard::icalendar::ICalendar; use pimdav::calcard::vcard::VCard; use pimdav::principal::{self, Principal, Search, UserType}; use pimdav::render::{self, TooManyInstances}; use pimdav::report::{self, Props, Refused, Report}; use pimdav::xml::{ self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr, with_children, with_text, }; use pimdav::zone::{self, Zone}; use pimdav::{contact, filter, freebusy, object}; use super::pim_schedule::{self, Directory, Stored, Writer}; use sha2::{Digest, Sha256}; use xmltree::Element; use crate::db::{ DeadProp, PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimShareMode, PimWrite, Precondition, PropPlace, User, }; use crate::error::{ApiError, AppState}; /// Largest object a PUT may store. Contacts carry photos inline. const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024; /// Largest XML request body. const MAX_XML_SIZE: usize = 1024 * 1024; /// Largest client property the server stores without interpreting it, and /// the most one resource may hold. const MAX_DEAD_SIZE: usize = 64 * 1024; const MAX_DEAD_PROPS: usize = 100; /// The domain of the addresses users schedule with. `.invalid` is reserved /// (RFC 2606), so nothing sent there can reach anyone. pub(super) const MAIL_DOMAIN: &str = "dovenest.invalid"; /// The ids of the generated collections, which no stored one has. pub(super) const DIRECTORY: i64 = 0; pub(super) const BIRTHDAYS: i64 = -1; pub(super) const DIRECTORY_SLUG: &str = "system"; pub(super) const BIRTHDAYS_SLUG: &str = "birthdays"; /// The slug prefix of a collection lent to the account. pub(super) const SHARED_PREFIX: &str = "shared-"; /// The scheduling inbox is a stored calendar collection under this slug. pub(crate) const INBOX: &str = "inbox"; /// The scheduling outbox holds nothing and is not stored. pub(crate) const OUTBOX: &str = "outbox"; /// Characters escaped in an href segment. const SEGMENT: &AsciiSet = &CONTROLS .add(b' ') .add(b'"') .add(b'#') .add(b'%') .add(b'/') .add(b'<') .add(b'>') .add(b'?') .add(b'[') .add(b']') .add(b'`') .add(b'{') .add(b'}'); /// Characters a principal name keeps in the local part of its address. The /// rest is percent-encoded: `%` is valid there, `@` and spaces are not /// (RFC 5322, 3.2.3). const LOCAL: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.'); /// The same without the dot, for names where a dot would lead, trail or /// repeat. const LOCAL_NO_DOT: &AsciiSet = &LOCAL.add(b'.'); type Reply = Result, ApiError>; /// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`. /// /// 307, not 301: HTTP clients drop the body on a 301, and python-caldav sends /// its principal search to the URL it was configured with. pub async fn well_known() -> Response { ( StatusCode::TEMPORARY_REDIRECT, [(LOCATION, format!("{PIM}/"))], ) .into_response() } /// The `DAV` header of every response here. Apple Calendar looks for it on /// PROPFIND responses too, not only on OPTIONS. pub(super) const COMPLIANCE: &str = "1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, extended-mkcol"; /// `{PIM}` and everything under it. pub async fn handle(State(state): State>, req: Request) -> Response { let mut r = match super::dav::authenticate(&state, req.headers()).await { Some((user_id, _)) => serve(&state, user_id, req) .await .unwrap_or_else(IntoResponse::into_response), None => super::dav::challenge(), }; r.headers_mut() .insert("dav", axum::http::HeaderValue::from_static(COMPLIANCE)); r } /// The signed-in account. struct Me { id: i64, /// The account's principal, which owns its collections. pid: i64, admin: bool, /// The scheduling address, for SENT-BY when acting for someone else. address: String, /// The own principal href. Spelled as the request spelled the name when /// it named this account: a client that asked for `/ALICE/` must get /// hrefs it recognises. principal: String, } /// The principal whose URLs a request addresses: the signed-in account, or /// a room or resource. Another account's principal is readable too. struct Space { id: i64, /// The URL segment, as the request spelled it. path: String, display: String, kind: UserType, mine: bool, } impl Space { fn principal(&self) -> String { principal_href(&self.path) } fn home(&self, kind: PimKind) -> String { format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.path)) } fn collection(&self, kind: PimKind, slug: &str) -> String { format!("{}{}/", self.home(kind), seg(slug)) } fn object(&self, kind: PimKind, slug: &str, name: &str) -> String { format!("{}{}", self.collection(kind, slug), seg(name)) } } /// The URL of a principal. pub(crate) fn principal_href(name: &str) -> String { format!("{PIM}/principals/{}/", seg(name)) } /// The principal name of a principal URL, given as a path or a full URL. pub(super) fn principal_name(href: &str) -> Option { let path = match href.starts_with('/') { true => href.to_string(), false => href.parse::().ok()?.path().to_string(), }; match parse_target(path.strip_prefix(PIM)?)? { Target::Principal(name) => Some(name), _ => None, } } /// The URL of a collection in the home of `user`, whether it owns it or /// has it lent (`lent_id`). pub(crate) fn collection_href( user: &str, kind: PimKind, slug: &str, lent_id: Option, ) -> String { let slug = match lent_id { Some(id) => format!("{SHARED_PREFIX}{id}"), None => slug.to_string(), }; format!("{PIM}/{}/{}/{}/", kind_segment(kind), seg(user), seg(&slug)) } /// What the signed-in account may do with a collection. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Access { Read, /// Change members, not the collection's own properties. Write, /// Also send scheduling messages as the owner. Schedule, Own, } /// A collection as the signed-in account sees it. struct Col { /// `slug` and `displayname` as this account sees them. c: PimCollection, access: Access, /// The principal href of the owner. owner: String, } async fn serve(state: &AppState, user_id: i64, req: Request) -> Reply { let Some(user) = state.db.find_user_by_id(user_id).await? else { return Ok(status(StatusCode::UNAUTHORIZED)); }; let path = req.uri().path().strip_prefix(PIM).unwrap_or_default(); let Some(target) = parse_target(path) else { return Ok(status(StatusCode::NOT_FOUND)); }; let (me, space) = match resolve_space(state, &user, &target).await? { Ok(v) => v, Err(code) => return Ok(status(code)), }; state.db.pim_ensure_defaults(me.pid).await?; let method = req.method().clone(); let (parts, body) = req.into_parts(); let cx = Cx { state, me: &me, space: space.as_ref(), }; match method.as_str() { "OPTIONS" => Ok(options(&target)), "POST" => cx.post(&target, body).await, "PROPFIND" => cx.propfind(&target, &parts.headers, body).await, "PROPPATCH" => cx.proppatch(&target, body).await, "MKCALENDAR" | "MKCOL" => cx.mkcol(&target, method.as_str(), body).await, "GET" | "HEAD" => { cx.get(&target, &parts.headers, method == Method::HEAD) .await } "PUT" => cx.put(&target, &parts.headers, body).await, "DELETE" => cx.delete(&target, &parts.headers).await, "REPORT" => cx.report(&target, body).await, "MOVE" => cx.move_object(&target, &parts.headers).await, _ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)), } } /// Who asks, and in whose URL space. Another account's space is off limits /// except for its principal. async fn resolve_space( state: &AppState, user: &User, target: &Target, ) -> Result), StatusCode>, ApiError> { let mut me = Me { id: user.id, pid: state.db.principal_of(user.id).await?, admin: user.is_admin, address: format!("mailto:{}", mailto(&user.name, UserType::Individual)), principal: principal_href(&user.name), }; let Some(segment) = target.owner() else { return Ok(Ok((me, None))); }; if segment.eq_ignore_ascii_case(&user.name) { me.principal = principal_href(segment); let space = Space { id: me.pid, path: segment.to_string(), display: user.name.clone(), kind: UserType::Individual, mine: true, }; return Ok(Ok((me, Some(space)))); } let Some(p) = state.db.pim_principal(segment).await? else { return Ok(Err(StatusCode::NOT_FOUND)); }; if p.kind == UserType::Individual && !matches!(target, Target::Principal(_)) { return Ok(Err(StatusCode::FORBIDDEN)); } let space = Space { id: p.id, path: segment.to_string(), display: p.display().to_string(), kind: p.kind, mine: false, }; Ok(Ok((me, Some(space)))) } #[derive(Debug)] enum Target { Root, Principals, Principal(String), Home(PimKind, String), Collection(PimKind, String, String), Object(PimKind, String, String, String), } impl Target { fn owner(&self) -> Option<&str> { match self { Target::Root | Target::Principals => None, Target::Principal(u) | Target::Home(_, u) | Target::Collection(_, u, _) | Target::Object(_, u, _, _) => Some(u), } } } fn parse_target(path: &str) -> Option { let segs = path .split('/') .filter(|s| !s.is_empty()) .map(|s| { let s = percent_decode_str(s).decode_utf8().ok()?; (s != "." && s != "..").then(|| s.into_owned()) }) .collect::>>()?; let kind = |s: &str| match s { "calendars" => Some(PimKind::Calendar), "addressbooks" => Some(PimKind::AddressBook), _ => None, }; let mut it = segs.into_iter(); let Some(first) = it.next() else { return Some(Target::Root); }; let rest: Vec = it.collect(); if first == "principals" { let mut rest = rest.into_iter(); return match (rest.next(), rest.next()) { (None, _) => Some(Target::Principals), (Some(user), None) => Some(Target::Principal(user)), _ => None, }; } let kind = kind(&first)?; let mut rest = rest.into_iter(); Some(match (rest.next(), rest.next(), rest.next(), rest.next()) { (Some(u), None, None, None) => Target::Home(kind, u), (Some(u), Some(c), None, None) => Target::Collection(kind, u, c), (Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o), _ => return None, }) } fn kind_segment(kind: PimKind) -> &'static str { match kind { PimKind::Calendar => "calendars", PimKind::AddressBook => "addressbooks", } } fn kind_ns(kind: PimKind) -> &'static str { match kind { PimKind::Calendar => CALDAV, PimKind::AddressBook => CARDDAV, } } pub(super) fn seg(s: &str) -> String { utf8_percent_encode(s, SEGMENT).to_string() } fn status(code: StatusCode) -> Response { code.into_response() } fn xml_response(code: StatusCode, body: String) -> Response { ( code, [(CONTENT_TYPE, "application/xml; charset=utf-8")], body, ) .into_response() } /// A failed precondition, named in a `` body. fn error(code: StatusCode, condition: Element) -> Response { xml_response(code, xml::error(condition)) } /// The condition for a lacking privilege on `href` (RFC 3744, 7.1.1). pub(super) fn need_privilege(href: &str, ns: &str, privilege: &str) -> Element { with_children( el(DAV, "need-privileges"), [with_children( el(DAV, "resource"), [ with_text(el(DAV, "href"), href), with_children(el(DAV, "privilege"), [el(ns, privilege)]), ], )], ) } fn denied(href: &str, privilege: &str) -> Response { error(StatusCode::FORBIDDEN, need_privilege(href, DAV, privilege)) } fn options(target: &Target) -> Response { let outbox = matches!(target, Target::Collection(PimKind::Calendar, _, s) if s == OUTBOX); let allow = match outbox { true => "OPTIONS, PROPFIND, POST", false => { "OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT" } }; (StatusCode::OK, [(ALLOW.as_str(), allow)]).into_response() } async fn read_body(body: Body, limit: usize) -> Option { axum::body::to_bytes(body, limit).await.ok() } pub(super) fn etag_of(data: &[u8]) -> String { format!("\"{}\"", crate::hex(&Sha256::digest(data)[..16])) } /// A stable UUID per principal, for the `urn:uuid:` calendar user address. pub(super) fn principal_uuid(id: i64) -> String { let h = crate::hex(&Sha256::digest(format!("dovenest principal {id}"))[..16]); format!( "{}-{}-{}-{}-{}", &h[..8], &h[8..12], &h[12..16], &h[16..20], &h[20..] ) } /// The scheduling address of a principal. Rooms and resources use their own /// subdomains, so no account name can take their address. pub(super) fn mailto(name: &str, kind: UserType) -> String { let domain = match kind { UserType::Individual => MAIL_DOMAIN.to_string(), UserType::Room => format!("rooms.{MAIL_DOMAIN}"), UserType::Resource => format!("resources.{MAIL_DOMAIN}"), }; format!("{}@{domain}", local_part(name)) } /// A principal name as the local part of an address. Decoding the percent /// escapes gives the name back. pub(super) fn local_part(name: &str) -> String { let set = match name.starts_with('.') || name.ends_with('.') || name.contains("..") { true => LOCAL_NO_DOT, false => LOCAL, }; utf8_percent_encode(name, set).to_string() } /// A principal as PROPFIND and the searches describe it. struct PrincipalView { id: i64, /// The URL segment. path: String, display: String, kind: UserType, /// The signed-in account itself. me: bool, } impl PrincipalView { fn of(p: &PimPrincipal, me: &Me) -> Self { PrincipalView { id: p.id, path: p.name.clone(), display: p.display().to_string(), kind: p.kind, me: p.id == me.pid, } } /// Only the mailto address: Apple takes the first href in order unless /// one is `preferred`, and an attendee matched by its principal URL gets /// no reply buttons. Scheduling still accepts the principal URL and the /// `urn:uuid:` form. fn addresses(&self) -> Vec { vec![format!("mailto:{}", mailto(&self.path, self.kind))] } } // --------------------------------------------------------------------------- // Collections and members // --------------------------------------------------------------------------- /// Whether a collection is generated rather than stored. pub(super) fn generated(id: i64) -> bool { id <= DIRECTORY } /// A generated collection. Its members' ETags stand in for a change counter: /// any change to them changes the CTag and the sync token. Only the current /// token is valid, so a client resyncs after each change. fn generated_collection( id: i64, slug: &str, name: &str, components: &str, members: &[(PimObject, Vec)], ) -> PimCollection { let digest = Sha256::digest( members .iter() .map(|(o, _)| o.etag.as_str()) .collect::(), ); PimCollection { id, slug: slug.to_string(), displayname: Some(name.to_string()), components: components.to_string(), seq: i64::from_be_bytes(digest[..8].try_into().expect("8 bytes")) & i64::MAX, ..Default::default() } } pub(super) type Members = Vec<(PimObject, Vec)>; /// The generated system address book: one card per visible principal. pub(super) async fn directory(state: &AppState) -> Result<(PimCollection, Members), ApiError> { let mut members = Vec::new(); for p in state.db.pim_principals().await? { let uuid = principal_uuid(p.id); let uid = format!("urn:uuid:{uuid}"); let addresses: [String; 0] = []; let view = Principal { name: &p.name, display: p.display(), addresses: &addresses, kind: p.kind, }; let data = principal::card(&uid, &view, &mailto(&p.name, p.kind)).into_bytes(); members.push(( generated_object(format!("{uuid}.vcf"), uid, "VCARD", &data), data, )); } let col = generated_collection(DIRECTORY, DIRECTORY_SLUG, "Directory", "", &members); Ok((col, members)) } /// The generated birthday calendar of a principal: the birthdays and /// anniversaries in its own address books, not lent ones. // ponytail: rebuilt from every contact on each request. Store the events if // large address books make it slow. pub(super) async fn birthdays( state: &AppState, principal: i64, ) -> Result<(PimCollection, Members), ApiError> { let mut members = Vec::new(); for book in state .db .pim_collections(principal, PimKind::AddressBook) .await? { for (o, data) in state.db.pim_objects_with_data(book.id).await? { let key = format!("{}/{}", book.id, o.name); for (uid, ics) in contact::dates(&String::from_utf8_lossy(&data), &key) { let data = ics.into_bytes(); members.push(( generated_object(format!("{uid}.ics"), uid, "VEVENT", &data), data, )); } } } let mut col = generated_collection(BIRTHDAYS, BIRTHDAYS_SLUG, "Birthdays", "VEVENT", &members); col.transparent = true; Ok((col, members)) } /// The members of collection `id`, stored or generated. `principal` owns /// a generated birthday calendar. pub(super) async fn members_of( state: &AppState, principal: i64, id: i64, ) -> Result { match id { DIRECTORY => Ok(directory(state).await?.1), BIRTHDAYS => Ok(birthdays(state, principal).await?.1), id => Ok(state.db.pim_objects_with_data(id).await?), } } fn generated_object(name: String, uid: String, component: &str, data: &[u8]) -> PimObject { PimObject { name, uid, component: component.to_string(), etag: etag_of(data), size: data.len() as i64, ..Default::default() } } /// The request context: who asks, and in whose URL space. struct Cx<'a> { state: &'a AppState, me: &'a Me, space: Option<&'a Space>, } impl Cx<'_> { fn space(&self) -> &Space { self.space.expect("targets with an owner resolve a space") } /// A collection of the space by slug, with the access of the signed-in /// account. async fn collection(&self, kind: PimKind, slug: &str) -> Result, ApiError> { let space = self.space(); let db = &self.state.db; if !space.mine { if slug == INBOX { return Ok(None); } // A room: everyone reads its bookings, admins may change and // answer them. let access = if self.me.admin { Access::Schedule } else { Access::Read }; return Ok(db.pim_collection(space.id, kind, slug).await?.map(|c| Col { c, access, owner: space.principal(), })); } if let Some(c) = db.pim_collection(space.id, kind, slug).await? { return Ok(Some(Col { c, access: Access::Own, owner: space.principal(), })); } let generated = match (kind, slug) { (PimKind::AddressBook, DIRECTORY_SLUG) => Some(directory(self.state).await?.0), (PimKind::Calendar, BIRTHDAYS_SLUG) => Some(birthdays(self.state, space.id).await?.0), _ => None, }; if let Some(c) = generated { return Ok(Some(Col { c, access: Access::Read, owner: space.principal(), })); } let Some(id) = slug .strip_prefix(SHARED_PREFIX) .and_then(|id| id.parse().ok()) else { return Ok(None); }; Ok(db .pim_shared_collection(self.me.id, kind, id) .await? .map(|(c, owner, mode)| lent(c, &owner, mode))) } /// Every collection of `kind` in the space's home. async fn collections(&self, kind: PimKind) -> Result, ApiError> { let space = self.space(); let db = &self.state.db; let own = if space.mine { Access::Own } else if self.me.admin { Access::Schedule } else { Access::Read }; let mut out: Vec = db .pim_collections(space.id, kind) .await? .into_iter() .filter(|c| space.mine || c.slug != INBOX) .map(|c| Col { c, access: own, owner: space.principal(), }) .collect(); if space.mine { let generated = match kind { PimKind::AddressBook => directory(self.state).await?.0, PimKind::Calendar => birthdays(self.state, space.id).await?.0, }; out.push(Col { c: generated, access: Access::Read, owner: space.principal(), }); for (c, owner, mode) in db.pim_shared_collections(self.me.id, kind).await? { out.push(lent(c, &owner, mode)); } } Ok(out) } async fn members(&self, c: &PimCollection) -> Result { members_of(self.state, self.space().id, c.id).await } async fn member( &self, c: &PimCollection, name: &str, ) -> Result)>, ApiError> { if generated(c.id) { let all = self.members(c).await?; return Ok(all.into_iter().find(|(o, _)| o.name == name)); } Ok(self.state.db.pim_object(c.id, name).await?) } } /// Deletes a collection of principal `owner`. A calendar's scheduling /// objects are cancelled for their attendees first. `Err` names the /// precondition that refuses it: the calendar that receives invitations /// stays. pub(super) async fn delete_own( state: &AppState, owner: i64, kind: PimKind, col: &PimCollection, ) -> Result, ApiError> { let db = &state.db; if kind == PimKind::Calendar && col.slug != INBOX { if db .pim_calendar_for(owner, "VEVENT") .await? .is_some_and(|d| d.id == col.id) { return Ok(Err(el(CALDAV, "default-calendar-needed"))); } let _lock = pim_schedule::LOCK.lock().await; let dir = Directory::load(state).await?; let owner = dir .get(owner) .cloned() .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; let w = Writer::owner(&owner); let mut ops = Vec::new(); for (_, data) in db.pim_objects_with_data(col.id).await? { if let Ok(more) = pim_schedule::delete(state, &dir, &w, &data, true).await? { ops.extend(more); } } db.pim_apply(&ops).await?; } db.pim_delete_collection(col.id).await?; Ok(Ok(())) } /// A collection lent to the signed-in account, as it appears in their home. fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col { let name = c.displayname.take().unwrap_or_else(|| c.slug.clone()); c.displayname = Some(format!("{name} ({owner})")); c.slug = format!("{SHARED_PREFIX}{}", c.id); Col { c, access: match mode { PimShareMode::Ro => Access::Read, PimShareMode::Rw => Access::Write, PimShareMode::RwSchedule => Access::Schedule, }, owner: principal_href(owner), } } // --------------------------------------------------------------------------- // PROPFIND // --------------------------------------------------------------------------- /// A resource PROPFIND can describe. enum Res { Root, Principals, Principal(PrincipalView), /// With its owner's principal href, whether the account may add to it, /// and where its client properties live. Home(String, Access, PropPlace), Collection(PimKind, Col), /// With the href of the calendar that receives new invitations. Inbox(Col, Option), /// With its owner's principal href. Outbox(String), Object(PimKind, PimObject), } impl Cx<'_> { async fn propfind(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply { // Missing means infinity to RFC 4918, but clients that omit it mean 0. let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) { None | Some("0") => false, Some("1") => true, Some(_) => { return Ok(error( StatusCode::FORBIDDEN, el(DAV, "propfind-finite-depth"), )); } }; let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let Ok(request) = xml::propfind(&body) else { return Ok(status(StatusCode::BAD_REQUEST)); }; let mut list: Vec<(String, Res)> = Vec::new(); match target { Target::Root => list.push((format!("{PIM}/"), Res::Root)), Target::Principals => { list.push((format!("{PIM}/principals/"), Res::Principals)); if deep { for p in self.state.db.pim_principals().await? { list.push(( principal_href(&p.name), Res::Principal(PrincipalView::of(&p, self.me)), )); } } } Target::Principal(_) => { let s = self.space(); list.push(( s.principal(), Res::Principal(PrincipalView { id: s.id, path: s.path.clone(), display: s.display.clone(), kind: s.kind, me: s.mine, }), )); } Target::Home(kind, _) => { let s = self.space(); let access = if s.mine { Access::Own } else { Access::Read }; let place = PropPlace::Home(s.id, *kind); list.push((s.home(*kind), Res::Home(s.principal(), access, place))); if deep { for col in self.collections(*kind).await? { let href = s.collection(*kind, &col.c.slug); list.push((href, self.res(*kind, col).await?)); } if *kind == PimKind::Calendar && s.mine { list.push((s.collection(*kind, OUTBOX), Res::Outbox(s.principal()))); } } } Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX && self.space().mine => { let s = self.space(); list.push(( s.collection(PimKind::Calendar, OUTBOX), Res::Outbox(s.principal()), )); } Target::Collection(kind, _, slug) => { let Some(col) = self.collection(*kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; let objects = match (deep, col.c.id) { (false, _) => Vec::new(), (true, id) if generated(id) => self .members(&col.c) .await? .into_iter() .map(|(o, _)| o) .collect(), (true, id) => self.state.db.pim_objects(id).await?, }; let s = self.space(); let slug = col.c.slug.clone(); list.push((s.collection(*kind, &slug), self.res(*kind, col).await?)); for o in objects { list.push((s.object(*kind, &slug, &o.name), Res::Object(*kind, o))); } } Target::Object(kind, _, slug, name) => { let found = match self.collection(*kind, slug).await? { Some(col) => self.member(&col.c, name).await?, None => None, }; let Some((o, _)) = found else { return Ok(status(StatusCode::NOT_FOUND)); }; list.push(( self.space().object(*kind, slug, name), Res::Object(*kind, o), )); } } let mut responses = Vec::with_capacity(list.len()); for (href, res) in list { let mut all = self.props(&res); all.extend(self.dead_props(&res).await?); responses.push(select(href, &request, all)); } Ok(multistatus(&responses, None)) } /// The client properties stored for a resource. async fn dead_props(&self, res: &Res) -> Result, ApiError> { let place = match res { Res::Principal(p) => PropPlace::Principal(p.id), Res::Home(_, _, place) => *place, Res::Collection(_, col) | Res::Inbox(col, _) if !generated(col.c.id) => { PropPlace::Collection(col.c.id) } _ => return Ok(Vec::new()), }; Ok(self .state .db .pim_props(place) .await? .iter() .filter_map(|p| Element::parse(p.xml.as_bytes()).ok()) .collect()) } /// Every live property of a resource, with its value. fn props(&self, res: &Res) -> Vec { let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v); let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h])); let resourcetype = |types: &[(&str, &str)]| { with_children( el(DAV, "resourcetype"), types.iter().map(|(ns, l)| el(ns, l)), ) }; let principals = format!("{PIM}/principals/"); let mut out = vec![ href_prop(DAV, "current-user-principal", &self.me.principal), href_prop(DAV, "principal-collection-set", &principals), ]; match res { Res::Root => out.push(resourcetype(&[(DAV, "collection")])), Res::Principals => out.extend([ resourcetype(&[(DAV, "collection")]), privileges(Access::Read), principal_reports(), ]), Res::Principal(p) => { // The own principal in the spelling of the request. let href = match p.me { true => self.me.principal.clone(), false => principal_href(&p.path), }; let addresses = p.addresses(); out.extend([ resourcetype(&[(DAV, "collection"), (DAV, "principal")]), text(DAV, "displayname", &p.display), href_prop(DAV, "principal-URL", &href), with_children( el(CALDAV, "calendar-user-address-set"), hrefs(addresses.iter().map(String::as_str)) .into_iter() .map(|h| with_attr(h, "preferred", "1")), ), with_children( el(CALSERVER, "email-address-set"), [with_text( el(CALSERVER, "email-address"), mailto(&p.path, p.kind), )], ), text(CALDAV, "calendar-user-type", p.kind.as_str()), privileges(if p.me { Access::Own } else { Access::Read }), principal_reports(), ]); let home = |kind: PimKind| { let name = match p.me { true => self.space.map_or(p.path.clone(), |s| s.path.clone()), false => p.path.clone(), }; format!("{PIM}/{}/{}/", kind_segment(kind), seg(&name)) }; // Also for other accounts: python-caldav drops a search hit // without one. Their homes still answer 403. out.push(href_prop( CALDAV, "calendar-home-set", &home(PimKind::Calendar), )); if p.me { let cal = home(PimKind::Calendar); out.push(href_prop( CALDAV, "schedule-inbox-URL", &format!("{cal}{INBOX}/"), )); out.push(href_prop( CALDAV, "schedule-outbox-URL", &format!("{cal}{OUTBOX}/"), )); let book = home(PimKind::AddressBook); out.push(href_prop(CARDDAV, "addressbook-home-set", &book)); out.push(href_prop( CARDDAV, "directory-gateway", &format!("{book}{DIRECTORY_SLUG}/"), )); } } Res::Home(owner, access, _) => out.extend([ resourcetype(&[(DAV, "collection")]), href_prop(DAV, "owner", owner), privileges(*access), ]), Res::Collection(kind, col) => { let c = &col.c; let (types, desc) = match kind { PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")), PimKind::AddressBook => ( (CARDDAV, "addressbook"), (CARDDAV, "addressbook-description"), ), }; out.extend([ resourcetype(&[(DAV, "collection"), types]), href_prop(DAV, "owner", &col.owner), privileges(col.access), supported_reports(*kind), text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)), text(DAV, "sync-token", &sync_token(c.id, c.seq)), text( kind_ns(*kind), "max-resource-size", &MAX_RESOURCE_SIZE.to_string(), ), ]); if let Some(v) = &c.displayname { out.push(text(DAV, "displayname", v)); } if let Some(v) = &c.description { out.push(text(desc.0, desc.1, v)); } match kind { PimKind::Calendar => { out.push(with_children( el(CALDAV, "supported-calendar-component-set"), c.components .split(',') .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)), )); out.push(with_children( el(CALDAV, "supported-calendar-data"), [with_attr( with_attr( el(CALDAV, "calendar-data"), "content-type", "text/calendar", ), "version", "2.0", )], )); if let Some(v) = &c.color { out.push(text(APPLE, "calendar-color", v)); } if let Some(v) = &c.sort_order { out.push(text(APPLE, "calendar-order", v)); } if let Some(v) = &c.timezone { out.push(text(CALDAV, "calendar-timezone", v)); } out.push(with_children( el(CALDAV, "schedule-calendar-transp"), [el( CALDAV, if c.transparent { "transparent" } else { "opaque" }, )], )); } // 3.0 only: a client told of 4.0 writes 4.0 groups, which // Apple Contacts on the same account cannot read. A 4.0 // PUT is still stored, and served as 4.0 on request. PimKind::AddressBook => out.push(with_children( el(CARDDAV, "supported-address-data"), [with_attr( with_attr( el(CARDDAV, "address-data-type"), "content-type", "text/vcard", ), "version", "3.0", )], )), } } Res::Inbox(col, default) => { let c = &col.c; out.extend([ resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-inbox")]), href_prop(DAV, "owner", &col.owner), privilege_set(INBOX_PRIVILEGES), report_set(&[ (CALDAV, "calendar-multiget"), (CALDAV, "calendar-query"), (DAV, "sync-collection"), ]), text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)), text(DAV, "sync-token", &sync_token(c.id, c.seq)), ]); if let Some(v) = &c.displayname { out.push(text(DAV, "displayname", v)); } if let Some(h) = default { out.push(href_prop(CALDAV, "schedule-default-calendar-URL", h)); } } Res::Outbox(owner) => out.extend([ resourcetype(&[(DAV, "collection"), (CALDAV, "schedule-outbox")]), href_prop(DAV, "owner", owner), privilege_set(OUTBOX_PRIVILEGES), ]), Res::Object(kind, o) => { if let Some(tag) = &o.schedule_tag { out.push(text(CALDAV, "schedule-tag", tag)); } out.extend([ resourcetype(&[]), text(DAV, "getetag", &o.etag), text(DAV, "getcontenttype", &content_type(*kind, &o.component)), text(DAV, "getcontentlength", &o.size.to_string()), ]); if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) { let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); out.push(text(DAV, "getlastmodified", &http_date)); } } } out } } impl Cx<'_> { /// How PROPFIND describes a collection. The inbox names the calendar /// that receives new invitations. async fn res(&self, kind: PimKind, col: Col) -> Result { if kind != PimKind::Calendar || col.c.slug != INBOX { return Ok(Res::Collection(kind, col)); } let space = self.space(); let default = self .state .db .pim_calendar_for(space.id, "VEVENT") .await? .map(|c| space.collection(PimKind::Calendar, &c.slug)); Ok(Res::Inbox(col, default)) } } /// The response for one resource: the requested ones of `all`, and 404 for /// those it lacks. fn select(href: String, request: &Propfind, all: Vec) -> xml::Response { let mut r = xml::Response::new(href); match request { Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)), Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())), Propfind::Prop(names) => { for n in names { match all.iter().find(|p| Name::of(p) == *n) { Some(p) => r.push(200, p.clone()), None => r.push(404, n.element()), } } } } if r.propstats.is_empty() { r.status = Some(200); } r } fn multistatus(responses: &[xml::Response], tail: Option) -> Response { xml_response( StatusCode::MULTI_STATUS, xml::multistatus_with(&Name::new(DAV, "multistatus"), responses, tail), ) } fn report_set(reports: &[(&str, &str)]) -> Element { with_children( el(DAV, "supported-report-set"), reports.iter().map(|(ns, local)| { with_children( el(DAV, "supported-report"), [with_children(el(DAV, "report"), [el(ns, local)])], ) }), ) } fn supported_reports(kind: PimKind) -> Element { report_set(match kind { PimKind::Calendar => &[ (CALDAV, "calendar-multiget"), (CALDAV, "calendar-query"), (CALDAV, "free-busy-query"), (DAV, "sync-collection"), ], PimKind::AddressBook => &[ (CARDDAV, "addressbook-multiget"), (CARDDAV, "addressbook-query"), (DAV, "sync-collection"), ], }) } fn principal_reports() -> Element { report_set(&[ (DAV, "principal-property-search"), (DAV, "principal-search-property-set"), (CALSERVER, "calendarserver-principal-search"), ]) } fn privileges(access: Access) -> Element { const WRITE: [(&str, &str); 5] = [ (DAV, "read"), (DAV, "write-content"), (DAV, "bind"), (DAV, "unbind"), (DAV, "read-current-user-privilege-set"), ]; let names: Vec<(&str, &str)> = match access { Access::Own => [ "all", "read", "write", "write-properties", "write-content", "bind", "unbind", "read-current-user-privilege-set", ] .map(|n| (DAV, n)) .to_vec(), // RFC 6638 grants these on the outbox, which a sharee cannot see. Access::Schedule => [ (CALDAV, "schedule-send"), (CALDAV, "schedule-send-invite"), (CALDAV, "schedule-send-reply"), ] .into_iter() .chain(WRITE) .collect(), Access::Write => WRITE.to_vec(), Access::Read => vec![(DAV, "read"), (DAV, "read-current-user-privilege-set")], }; privilege_set(names) } /// The owner reads and empties the inbox; only the server delivers into it. const INBOX_PRIVILEGES: [(&str, &str); 7] = [ (DAV, "read"), (DAV, "unbind"), (DAV, "read-current-user-privilege-set"), (CALDAV, "schedule-deliver"), (CALDAV, "schedule-deliver-invite"), (CALDAV, "schedule-deliver-reply"), (CALDAV, "schedule-query-freebusy"), ]; const OUTBOX_PRIVILEGES: [(&str, &str); 6] = [ (DAV, "read"), (DAV, "read-current-user-privilege-set"), (CALDAV, "schedule-send"), (CALDAV, "schedule-send-invite"), (CALDAV, "schedule-send-reply"), (CALDAV, "schedule-send-freebusy"), ]; fn privilege_set<'a>(names: impl IntoIterator) -> Element { with_children( el(DAV, "current-user-privilege-set"), names .into_iter() .map(|(ns, n)| with_children(el(DAV, "privilege"), [el(ns, n)])), ) } /// Carries the collection id, so a token handed out for a deleted /// collection never matches the one that later takes its URL. fn sync_token(id: i64, seq: i64) -> String { format!("urn:dovenest:sync:{id}-{seq}") } fn content_type(kind: PimKind, component: &str) -> String { match kind { PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"), PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(), } } // --------------------------------------------------------------------------- // PROPPATCH, MKCALENDAR, MKCOL // --------------------------------------------------------------------------- impl Cx<'_> { async fn proppatch(&self, target: &Target, body: Body) -> Reply { let (href, place, res, mut col) = match target { Target::Collection(kind, _, slug) => { let Some(col) = self.collection(*kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; let href = self.space().collection(*kind, slug); if col.access != Access::Own { return Ok(denied(&href, "write-properties")); } let place = PropPlace::Collection(col.c.id); let stored = (*kind, col.c.clone()); (href, place, self.res(*kind, col).await?, Some(stored)) } Target::Home(kind, _) => { let s = self.space(); if !self.may_edit(s) { return Ok(denied(&s.home(*kind), "write-properties")); } let place = PropPlace::Home(s.id, *kind); let res = Res::Home(s.principal(), Access::Own, place); (s.home(*kind), place, res, None) } Target::Principal(_) => { let s = self.space(); if !self.may_edit(s) { return Ok(denied(&s.principal(), "write-properties")); } let view = PrincipalView { id: s.id, path: s.path.clone(), display: s.display.clone(), kind: s.kind, me: s.mine, }; let place = PropPlace::Principal(s.id); (s.principal(), place, Res::Principal(view), None) } _ => return Ok(status(StatusCode::FORBIDDEN)), }; let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let Ok(update) = xml::update(&body) else { return Ok(status(StatusCode::BAD_REQUEST)); }; let live: Vec = self.props(&res).iter().map(Name::of).collect(); let stored = self.state.db.pim_props(place).await?; let patch = apply( col.as_mut().map(|(k, c)| (*k, c)), &update, false, &live, &stored, ); if patch.ok() { let db = &self.state.db; db.pim_patch( place, col.as_ref().map(|(_, c)| c), &patch.set, &patch.remove, ) .await?; } let mut r = xml::Response::new(href); r.error = patch .protected .then(|| el(DAV, "cannot-modify-protected-property")); for (code, prop) in patch.results { r.push(code, prop); } Ok(multistatus(&[r], None)) } /// The owner changes the properties of its principal and homes, admins /// those of rooms and resources. fn may_edit(&self, s: &Space) -> bool { s.mine || (self.me.admin && s.kind != UserType::Individual) } async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply { let Target::Collection(kind, _, slug) = target else { return Ok(status(StatusCode::FORBIDDEN)); }; let space = self.space(); if !space.mine { return Ok(denied(&space.home(*kind), "bind")); } let calendar = method == "MKCALENDAR"; if calendar && *kind != PimKind::Calendar { return Ok(status(StatusCode::FORBIDDEN)); } if self.collection(*kind, slug).await?.is_some() { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); } // Names the home shows for lent and generated collections. if slug.starts_with(SHARED_PREFIX) || [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&slug.as_str()) { return Ok(status(StatusCode::FORBIDDEN)); } let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let Ok(update) = xml::update(&body) else { return Ok(status(StatusCode::BAD_REQUEST)); }; // A plain MKCOL makes a plain collection, which a calendar home cannot // hold. An address book home takes it as an address book. let typed = update .set .iter() .any(|p| Name::of(p).is(DAV, "resourcetype")); if !calendar && *kind == PimKind::Calendar && !typed { return Ok(status(StatusCode::FORBIDDEN)); } let mut col = PimCollection { slug: slug.clone(), components: match kind { PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(), PimKind::AddressBook => String::new(), }, ..Default::default() }; let res = Res::Collection( *kind, Col { c: col.clone(), access: Access::Own, owner: space.principal(), }, ); let live: Vec = self.props(&res).iter().map(Name::of).collect(); let patch = apply(Some((*kind, &mut col)), &update, true, &live, &[]); if !patch.ok() { let root = match calendar { true => Name::new(CALDAV, "mkcalendar-response"), false => Name::new(DAV, "mkcol-response"), }; let propstats = group(patch.results); return Ok(xml_response( StatusCode::FORBIDDEN, xml::propstat_document(&root, &propstats), )); } if !self .state .db .pim_create_collection(self.me.pid, *kind, &col, &patch.set) .await? { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); } Ok(status(StatusCode::CREATED)) } } fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec)> { let mut r = xml::Response::default(); for (code, prop) in results { r.push(code, prop); } r.propstats } /// A property update: each property with its status, and the client /// properties to store and remove. struct Patch { results: Vec<(u16, Element)>, set: Vec, remove: Vec<(String, String)>, /// A property the server computes was named. protected: bool, } impl Patch { fn ok(&self) -> bool { self.results.iter().all(|(code, _)| *code == 200) } } /// DAV properties the server computes on some resource, beyond the ones /// `live` names for the resource at hand. const PROTECTED: [&str; 20] = [ "acl", "alternate-URI-set", "creationdate", "current-user-principal", "current-user-privilege-set", "getcontentlength", "getcontenttype", "getetag", "getlastmodified", "group", "group-member-set", "group-membership", "lockdiscovery", "owner", "principal-URL", "principal-collection-set", "resourcetype", "supported-report-set", "supportedlock", "sync-token", ]; /// Applies a PROPPATCH, MKCALENDAR or extended MKCOL body. A collection's /// own properties go into `col`. What the server computes (`live`, or a /// [`PROTECTED`] DAV property) is refused; anything else is stored as the /// client sent it, as clients expect of properties such as Apple's /// `default-alarm-vevent-date`. Nothing may be stored unless all of it is /// allowed: RFC 4918 makes PROPPATCH atomic. fn apply( mut col: Option<(PimKind, &mut PimCollection)>, update: &Update, creating: bool, live: &[Name], stored: &[DeadProp], ) -> Patch { let mut patch = Patch { results: Vec::new(), set: Vec::new(), remove: Vec::new(), protected: false, }; let is_protected = |n: &Name| live.contains(n) || (n.ns == DAV && PROTECTED.contains(&n.local.as_str())); for p in &update.set { let name = Name::of(p); let own = col .as_mut() .and_then(|(kind, c)| set_own(*kind, c, p, &name, creating)); let code = match own { Some(true) => 200, Some(false) => 403, None if is_protected(&name) => { patch.protected = true; 403 } None => { let xml = xml::document(p); if xml.len() > MAX_DEAD_SIZE { 507 } else { patch.set.push(DeadProp { ns: name.ns.clone(), name: name.local.clone(), xml, }); 200 } } }; patch.results.push((code, name.element())); } for name in &update.remove { let own = col .as_mut() .and_then(|(kind, c)| remove_own(*kind, c, name)); let code = match own { Some(()) => 200, None if is_protected(name) => { patch.protected = true; 403 } None => { patch.remove.push((name.ns.clone(), name.local.clone())); 200 } }; patch.results.push((code, name.element())); } let mut names: Vec<(&str, &str)> = stored .iter() .map(|p| (p.ns.as_str(), p.name.as_str())) .chain(patch.set.iter().map(|p| (p.ns.as_str(), p.name.as_str()))) .filter(|n| { !patch .remove .iter() .any(|(ns, l)| (ns.as_str(), l.as_str()) == *n) }) .collect(); names.sort_unstable(); names.dedup(); if names.len() > MAX_DEAD_PROPS { for (code, prop) in &mut patch.results { let n = Name::of(prop); if patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local) { *code = 507; } } } if !patch.ok() { for (code, _) in &mut patch.results { if *code == 200 { *code = 424; } } } patch } /// Sets one of a collection's own properties. `None` if it is none of them, /// `Some(valid)` otherwise. fn set_own( kind: PimKind, col: &mut PimCollection, p: &Element, name: &Name, creating: bool, ) -> Option { let cal = kind == PimKind::Calendar; let value = || Some(xml::text(p)).filter(|v| !v.is_empty()); Some(match (name.ns.as_str(), name.local.as_str()) { (DAV, "displayname") => { col.displayname = value(); true } (CALDAV, "calendar-description") if cal => { col.description = value(); true } (CARDDAV, "addressbook-description") if !cal => { col.description = value(); true } (APPLE, "calendar-color") if cal => { col.color = value(); true } (APPLE, "calendar-order") if cal => { col.sort_order = value(); true } (CALDAV, "calendar-timezone") if cal => { let tz = value(); let valid = tz.as_deref().is_none_or(is_timezone); if valid { col.timezone = tz; } valid } (CALDAV, "schedule-calendar-transp") if cal => { let transparent = xml::child(p, CALDAV, "transparent").is_some(); let valid = transparent || xml::child(p, CALDAV, "opaque").is_some(); if valid { col.transparent = transparent; } valid } (DAV, "resourcetype") if creating => { let wanted = match kind { PimKind::Calendar => (CALDAV, "calendar"), PimKind::AddressBook => (CARDDAV, "addressbook"), }; xml::child(p, wanted.0, wanted.1).is_some() } (CALDAV, "supported-calendar-component-set") if creating && cal => { let comps: Vec<_> = xml::elements(p) .filter(|c| Name::of(c).is(CALDAV, "comp")) .filter_map(|c| c.attributes.get("name")) .map(|n| n.to_ascii_uppercase()) .collect(); let valid = !comps.is_empty() && comps .iter() .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str())); if valid { col.components = comps.join(","); } valid } _ => return None, }) } /// Removes one of a collection's own properties. `None` if it is none of /// them. fn remove_own(kind: PimKind, col: &mut PimCollection, name: &Name) -> Option<()> { let cal = kind == PimKind::Calendar; if cal && name.is(CALDAV, "schedule-calendar-transp") { col.transparent = false; return Some(()); } let field = match (name.ns.as_str(), name.local.as_str()) { (DAV, "displayname") => &mut col.displayname, (CALDAV, "calendar-description") if cal => &mut col.description, (CARDDAV, "addressbook-description") if !cal => &mut col.description, (APPLE, "calendar-color") if cal => &mut col.color, (APPLE, "calendar-order") if cal => &mut col.sort_order, (CALDAV, "calendar-timezone") if cal => &mut col.timezone, _ => return None, }; *field = None; Some(()) } /// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be. fn is_timezone(v: &str) -> bool { use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType}; ICalendar::parse(v).is_ok_and(|c| { c.components .iter() .any(|c| c.component_type == ICalendarComponentType::VTimezone) }) } // --------------------------------------------------------------------------- // Objects // --------------------------------------------------------------------------- impl Cx<'_> { async fn get(&self, target: &Target, headers: &HeaderMap, head: bool) -> Reply { let Target::Object(kind, _, slug, name) = target else { return self.get_collection(target, head).await; }; let found = match self.collection(*kind, slug).await? { Some(col) => self.member(&col.c, name).await?, None => None, }; let Some((o, mut data)) = found else { return Ok(status(StatusCode::NOT_FOUND)); }; if *kind == PimKind::AddressBook { let accept = headers.get("accept").and_then(|v| v.to_str().ok()); let req = render::AddressData { props: None, version: Some(render::accepted_version(accept)), }; data = render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes(); } let body = if head { Body::empty() } else { Body::from(data) }; let mut r = ( StatusCode::OK, [ (CONTENT_TYPE, content_type(*kind, &o.component)), (ETAG, o.etag), ], body, ) .into_response(); with_schedule_tag(&mut r, o.schedule_tag.as_deref()); Ok(r) } /// Clients that discover with GET, as RFC 6764 allows, expect a 2xx on /// every collection on the way. async fn get_collection(&self, target: &Target, head: bool) -> Reply { if let Target::Collection(kind, _, slug) = target && !(*kind == PimKind::Calendar && slug == OUTBOX && self.space().mine) && self.collection(*kind, slug).await?.is_none() { return Ok(status(StatusCode::NOT_FOUND)); } let body = match head { true => "", false => "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n", }; Ok(( StatusCode::OK, [(CONTENT_TYPE, "text/plain; charset=utf-8")], body, ) .into_response()) } async fn put(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply { let Target::Object(kind, _, slug, name) = target else { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); }; let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else { return Ok(status(StatusCode::CONFLICT)); }; let space = self.space(); // The server alone delivers into the inbox. if access < Access::Write || col.slug == INBOX { return Ok(denied(&space.collection(*kind, slug), "bind")); } let ns = kind_ns(*kind); let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else { return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size"))); }; let parsed = match kind { PimKind::Calendar => { let supported: Vec<&str> = col.components.split(',').collect(); object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string())) } PimKind::AddressBook => object::vcard(&data) .map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into())), }; let (uid, component) = match parsed { Ok(v) => v, Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())), }; let stamped = match kind { PimKind::Calendar => object::with_dtstamp(&data, chrono::Utc::now()), PimKind::AddressBook => None, }; let data = stamped.as_deref().unwrap_or(&data); let _lock = pim_schedule::LOCK.lock().await; let db = &self.state.db; let current = self.member(&col, name).await?; if refuses(headers, current.as_ref().map(|(o, _)| o)) { return Ok(status(StatusCode::PRECONDITION_FAILED)); } if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? { return Ok(error( StatusCode::FORBIDDEN, with_children( el(ns, "no-uid-conflict"), hrefs([space.object(*kind, slug, &holder).as_str()]), ), )); } let stored = match kind { PimKind::Calendar => { let dir = Directory::load(self.state).await?; let owner = self.owner(&col, &dir).await?; let w = self.writer(&owner, access); let old = current.as_ref().map(|(_, d)| d.as_slice()); match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, data).await? { Ok(s) => s, Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)), } } PimKind::AddressBook => Stored { data: data.to_vec(), changed: false, schedule_tag: None, ops: Vec::new(), }, }; let etag = etag_of(&stored.data); let mut ops = vec![PimOp::Put { collection_id: col.id, obj: PimObject { name: name.clone(), uid, component, etag: etag.clone(), schedule_tag: stored.schedule_tag.clone(), ..Default::default() }, data: stored.data, }]; ops.extend(stored.ops); db.pim_apply(&ops).await?; let code = match current { Some(_) => StatusCode::NO_CONTENT, None => StatusCode::CREATED, }; let mut r = status(code); // Only when the stored bytes are the request bytes (RFC 4791, 5.3.4). if !stored.changed && stamped.is_none() { r.headers_mut() .insert(ETAG, etag.parse().expect("hex is a valid header")); } with_schedule_tag(&mut r, stored.schedule_tag.as_deref()); Ok(r) } /// The signed-in account writing into a calendar of `owner`. fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> { Writer { owner, may_schedule: access >= Access::Schedule, sent_by: (access != Access::Own).then(|| self.me.address.clone()), } } /// The principal owning a collection, whose addresses decide how it takes /// part in the objects there. async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result { let owner = match self.state.db.pim_collection_by_id(col.id).await? { Some((id, _, _)) => dir.get(id).cloned(), None => None, }; owner.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found")) } async fn delete(&self, target: &Target, headers: &HeaderMap) -> Reply { let (kind, slug, name) = match target { Target::Collection(k, _, s) => (k, s, None), Target::Object(k, _, s, n) => (k, s, Some(n)), _ => return Ok(status(StatusCode::FORBIDDEN)), }; let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; let space = self.space(); let href = space.collection(*kind, slug); let scheduling = *kind == PimKind::Calendar && col.slug != INBOX; let db = &self.state.db; let Some(name) = name else { return Ok(match access { Access::Own if *kind == PimKind::Calendar && col.slug == INBOX => { denied(&space.home(*kind), "unbind") } Access::Own => match delete_own(self.state, space.id, *kind, &col).await? { Ok(()) => status(StatusCode::NO_CONTENT), Err(condition) => error(StatusCode::FORBIDDEN, condition), }, // Deleting a lent collection only takes it out of this home. _ if slug.starts_with(SHARED_PREFIX) && space.mine => { db.pim_remove_share(col.id, self.me.id).await?; status(StatusCode::NO_CONTENT) } _ => denied(&space.home(*kind), "unbind"), }); }; if access < Access::Write { return Ok(denied(&href, "unbind")); } let _lock = pim_schedule::LOCK.lock().await; let Some((obj, data)) = self.member(&col, name).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; if refuses(headers, Some(&obj)) { return Ok(status(StatusCode::PRECONDITION_FAILED)); } let mut ops = vec![PimOp::Delete { collection_id: col.id, name: name.clone(), }]; if scheduling { let dir = Directory::load(self.state).await?; let owner = self.owner(&col, &dir).await?; let w = self.writer(&owner, access); let reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F"); match pim_schedule::delete(self.state, &dir, &w, &data, reply).await? { Ok(more) => ops.extend(more), Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)), } } db.pim_apply(&ops).await?; Ok(status(StatusCode::NO_CONTENT)) } } /// Whether If-Match, If-None-Match or If-Schedule-Tag-Match fails against /// the current object. fn refuses(headers: &HeaderMap, current: Option<&PimObject>) -> bool { if !precondition(headers).allows(current.map(|o| o.etag.as_str())) { return true; } headers .get("if-schedule-tag-match") .and_then(|v| v.to_str().ok()) .is_some_and(|tag| current.and_then(|o| o.schedule_tag.as_deref()) != Some(tag.trim())) } fn with_schedule_tag(r: &mut Response, tag: Option<&str>) { if let Some(v) = tag.and_then(|t| t.parse().ok()) { r.headers_mut().insert("schedule-tag", v); } } fn precondition(headers: &HeaderMap) -> Precondition { let header = |name: &str| { headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_string) }; Precondition { if_match: header("if-match"), if_none_match: header("if-none-match"), } } // --------------------------------------------------------------------------- // REPORT // --------------------------------------------------------------------------- impl Cx<'_> { async fn report(&self, target: &Target, body: Body) -> Reply { let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let report = match report::parse(&body) { Ok(r) => r, Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)), Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())), }; let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report"))); let on_principals = matches!( target, Target::Root | Target::Principals | Target::Principal(_) ); match report { Report::PrincipalSearch(search) if on_principals => { return self.principal_search(&search).await; } Report::PrincipalSearchPropertySet if on_principals => { return Ok(search_property_set()); } Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => { return unsupported(); } _ => {} } let Target::Collection(kind, _, slug) = target else { return unsupported(); }; let calendar_report = matches!( report, Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_) ); let card_report = matches!( report, Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. } ); if (calendar_report && *kind != PimKind::Calendar) || (card_report && *kind != PimKind::AddressBook) { return unsupported(); } let Some(Col { c: col, .. }) = self.collection(*kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; // Busy time comes from calendars, never from messages (RFC 6638, 2.3). if col.slug == INBOX && matches!(report, Report::FreeBusy(_)) { return unsupported(); } let floating = col .timezone .as_deref() .and_then(zone::from_vtimezone) .unwrap_or(Zone::Utc); let out = Out { cx: self, kind: *kind, col: &col, }; match report { Report::CalendarMultiget { props, hrefs } | Report::AddressbookMultiget { props, hrefs } => { let mut responses = Vec::new(); for href in hrefs { let found = match self.own_object(*kind, &href) { Some((slug, name)) if slug == col.slug => self.member(&col, &name).await?, _ => None, }; responses.push(match found { // The href as the client wrote it, so it can match it. Some((o, data)) => match out.object(&o, &data, &props, &floating) { Ok(r) => xml::Response { href, ..r }, Err(TooManyInstances) => return Ok(too_many()), }, None => xml::Response::status(href, 404), }); } Ok(multistatus(&responses, None)) } Report::CalendarQuery { props, filter, timezone, } => { let floating = timezone.unwrap_or(floating); let mut responses = Vec::new(); for (o, data) in self.members(&col).await? { let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else { continue; }; if filter::matches_calendar(&cal, &filter, &floating) { match out.object(&o, &data, &props, &floating) { Ok(r) => responses.push(r), Err(TooManyInstances) => return Ok(too_many()), } } } Ok(multistatus(&responses, None)) } Report::AddressbookQuery { props, filter, limit, } => { let mut responses = Vec::new(); let mut truncated = false; for (o, data) in self.members(&col).await? { let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else { continue; }; if !filter::matches_card(&card, &filter) { continue; } if limit.is_some_and(|n| responses.len() >= n) { truncated = true; break; } if let Ok(r) = out.object(&o, &data, &props, &floating) { responses.push(r); } } if truncated { responses.push(out.over_limit()); } Ok(multistatus(&responses, None)) } Report::SyncCollection { token, props, limit, } => { let since = match token.is_empty() { true => None, false => match parse_sync_token(&token) { // A generated collection has no change log: only its // current token is valid. Some((id, seq)) if id == col.id && generated(id) && seq == col.seq => { Some(seq) } Some((id, seq)) if id == col.id && !generated(id) && seq <= col.seq => { Some(seq) } _ => { return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))); } }, }; let mut changes = if generated(col.id) { match since { Some(_) => Vec::new(), None => self .members(&col) .await? .into_iter() .map(|(o, _)| (o.name, col.seq, false)) .collect(), } } else { self.state.db.pim_changes(col.id, since).await? }; let truncated = limit.is_some_and(|n| changes.len() > n); if let Some(n) = limit { changes.truncate(n); } // A truncated answer hands out the token of its last change, so // the next sync resumes after it. let seq = match (truncated, changes.last()) { (true, Some((_, s, _))) if !generated(col.id) => *s, _ if generated(col.id) => col.seq, (_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)), }; let mut responses = Vec::new(); for (name, _, deleted) in changes { let href = self.space().object(*kind, &col.slug, &name); let found = match deleted { true => None, false => self.member(&col, &name).await?, }; responses.push(match found { Some((o, data)) => match out.object(&o, &data, &props, &floating) { Ok(r) => r, Err(TooManyInstances) => return Ok(too_many()), }, None => xml::Response::status(href, 404), }); } if truncated { responses.push(out.over_limit()); } Ok(multistatus( &responses, Some(with_text(el(DAV, "sync-token"), sync_token(col.id, seq))), )) } Report::FreeBusy(range) => { let mut busy = Vec::new(); for (_, data) in self.members(&col).await? { if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) { // ponytail: one period per instance, so a long range over // a frequent series makes a long answer. busy.extend(freebusy::busy(&cal, &range, &floating, None)); } } let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now()); Ok(( StatusCode::OK, [(CONTENT_TYPE, "text/calendar; charset=utf-8")], body, ) .into_response()) } Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => { unreachable!("answered above") } } } /// principal-property-search and calendarserver-principal-search. async fn principal_search(&self, search: &Search) -> Reply { let mut responses = Vec::new(); let mut truncated = false; for p in self.state.db.pim_principals().await? { let view = PrincipalView::of(&p, self.me); let addresses = view.addresses(); let candidate = Principal { name: &p.name, display: p.display(), addresses: &addresses, kind: p.kind, }; if !search.matches(&candidate) { continue; } if search.limit.is_some_and(|n| responses.len() >= n) { truncated = true; break; } let href = principal_href(&p.name); responses.push(select( href, &search.find, self.props(&Res::Principal(view)), )); } if truncated { let mut r = xml::Response::status(format!("{PIM}/principals/"), 507); r.error = Some(el(DAV, "number-of-matches-within-limits")); responses.push(r); } Ok(multistatus(&responses, None)) } /// `(collection slug, object name)` of an href to an object of `kind` in /// the space of this request. Takes a path or a full URL. fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> { let path = match href.starts_with('/') { true => href.to_string(), false => href.parse::().ok()?.path().to_string(), }; let space = self.space?; match parse_target(path.strip_prefix(PIM)?)? { Target::Object(k, owner, slug, name) if k == kind && owner.eq_ignore_ascii_case(&space.path) => { Some((slug, name)) } _ => None, } } } fn search_property_set() -> Response { let body = xml::document(&with_children( el(DAV, "principal-search-property-set"), principal::SEARCHABLE.map(|(ns, local, description)| { with_children( el(DAV, "principal-search-property"), [ with_children(el(DAV, "prop"), [el(ns, local)]), with_attr( with_text(el(DAV, "description"), description), "xml:lang", "en", ), ], ) }), )); xml_response(StatusCode::OK, body) } /// What a REPORT answer about one collection needs. struct Out<'a> { cx: &'a Cx<'a>, kind: PimKind, col: &'a PimCollection, } impl Out<'_> { fn object( &self, o: &PimObject, data: &[u8], props: &Props, floating: &Zone, ) -> Result { let mut all = self.cx.props(&Res::Object(self.kind, o.clone())); let raw = String::from_utf8_lossy(data); if let Some(req) = &props.calendar { let text = render::calendar_data(&raw, req, floating)?; all.push(with_text(el(CALDAV, "calendar-data"), text)); } if let Some(req) = &props.address { all.push(with_text( el(CARDDAV, "address-data"), render::address_data(&raw, req), )); } let href = self.cx.space().object(self.kind, &self.col.slug, &o.name); Ok(select(href, &props.find, all)) } /// The response a query or sync adds when a client limit cut it short. fn over_limit(&self) -> xml::Response { let href = self.cx.space().collection(self.kind, &self.col.slug); let mut r = xml::Response::status(href, 507); r.error = Some(el(DAV, "number-of-matches-within-limits")); r } } fn too_many() -> Response { error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances")) } /// `(collection id, seq)` of a token [`sync_token`] made. fn parse_sync_token(token: &str) -> Option<(i64, i64)> { // The birthday calendar's id is negative. let (id, seq) = token.strip_prefix("urn:dovenest:sync:")?.rsplit_once('-')?; Some((id.parse().ok()?, seq.parse().ok()?)) } // --------------------------------------------------------------------------- // POST // --------------------------------------------------------------------------- impl Cx<'_> { /// A free-busy request to the own scheduling outbox (RFC 6638, 5). async fn post(&self, target: &Target, body: Body) -> Reply { let space = match target { Target::Collection(PimKind::Calendar, _, slug) if slug == OUTBOX => self.space(), _ => return Ok(status(StatusCode::METHOD_NOT_ALLOWED)), }; if !space.mine { let href = space.collection(PimKind::Calendar, OUTBOX); return Ok(error( StatusCode::FORBIDDEN, need_privilege(&href, CALDAV, "schedule-send-freebusy"), )); } let Some(body) = read_body(body, MAX_XML_SIZE).await else { return Ok(status(StatusCode::PAYLOAD_TOO_LARGE)); }; let request = match freebusy::request(&body) { Ok(r) => r, Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition.element())), }; let dir = Directory::load(self.state).await?; if !dir.is(self.me.pid)(&request.organizer) { return Ok(error( StatusCode::FORBIDDEN, el(CALDAV, "organizer-allowed"), )); } let answers = pim_schedule::free_busy(self.state, &dir, &request).await?; Ok(xml_response( StatusCode::OK, freebusy::schedule_response(&answers), )) } } // --------------------------------------------------------------------------- // MOVE // --------------------------------------------------------------------------- impl Cx<'_> { async fn move_object(&self, target: &Target, headers: &HeaderMap) -> Reply { let Target::Object(kind, _, slug, name) = target else { return Ok(status(StatusCode::METHOD_NOT_ALLOWED)); }; let destination = headers.get("destination").and_then(|v| v.to_str().ok()); let Some((to_slug, to_name)) = destination.and_then(|d| self.own_object(*kind, d)) else { return Ok(status(StatusCode::FORBIDDEN)); }; if (&to_slug, &to_name) == (slug, name) { return Ok(status(StatusCode::FORBIDDEN)); } let space = self.space(); let Some(from) = self.collection(*kind, slug).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; let Some(to) = self.collection(*kind, &to_slug).await? else { return Ok(status(StatusCode::CONFLICT)); }; if from.access < Access::Write || from.c.slug == INBOX { return Ok(denied(&space.collection(*kind, slug), "unbind")); } if to.access < Access::Write || to.c.slug == INBOX { return Ok(denied(&space.collection(*kind, &to_slug), "bind")); } let _lock = pim_schedule::LOCK.lock().await; let Some((obj, _)) = self.member(&from.c, name).await? else { return Ok(status(StatusCode::NOT_FOUND)); }; // Moving between calendars schedules nothing (RFC 6638, 3.2.3.4). if refuses(headers, Some(&obj)) { return Ok(status(StatusCode::PRECONDITION_FAILED)); } if *kind == PimKind::Calendar && !to.c.components.split(',').any(|c| c == obj.component) { return Ok(error( StatusCode::FORBIDDEN, el(CALDAV, "supported-calendar-component"), )); } let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F"); let written = self .state .db .pim_move_object( from.c.id, name, to.c.id, &to_name, overwrite, &precondition(headers), ) .await?; Ok(match written { PimWrite::Created | PimWrite::Updated => { let code = match written { PimWrite::Created => StatusCode::CREATED, _ => StatusCode::NO_CONTENT, }; let mut r = status(code); with_schedule_tag(&mut r, obj.schedule_tag.as_deref()); r } PimWrite::NotFound => status(StatusCode::NOT_FOUND), PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED), PimWrite::UidConflict(holder) => error( StatusCode::FORBIDDEN, with_children( el(kind_ns(*kind), "no-uid-conflict"), hrefs([space.object(*kind, &to_slug, &holder).as_str()]), ), ), PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR), }) } }