//! Implicit scheduling (RFC 6638) between the principals of this server. //! //! `pimdav::itip` decides what a change sends to whom. This module finds the //! recipients and their objects, and turns every message into writes that //! commit together with the change itself. Nothing leaves the server: an //! address outside it gets a delivery failure in its SCHEDULE-STATUS. use chrono::Utc; use percent_encoding::percent_decode_str; use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType}; use pimdav::itip::{self, Message, Method, Role}; use pimdav::principal::UserType; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use xmltree::Element; use super::pim::{MAIL_DOMAIN, etag_of, principal_name, principal_uuid}; use crate::db::{PimObject, PimOp, PimPrincipal}; use crate::error::{ApiError, AppState}; /// Held from reading a calendar object to committing the change, so that a /// change and the writes it causes see a consistent store. // ponytail: one lock for every object write. Per-UID locks if write // throughput ever matters. pub(crate) static LOCK: Mutex<()> = Mutex::const_new(()); /// The delivery status codes of RFC 6638, 3.2.9. const DELIVERED: &str = "1.2"; /// An address in this server's domains that names no one. const INVALID_USER: &str = "3.7"; /// An address outside this server: there is no iMIP to reach it. const NO_ROUTE: &str = "5.2"; /// The recipient has no calendar for the component. const REFUSED: &str = "5.3"; /// Every principal, for mapping calendar user addresses. pub(crate) struct Directory(Vec); enum Recipient<'a> { Local(&'a PimPrincipal), Unknown, External, } fn found(p: Option<&PimPrincipal>) -> Recipient<'_> { match p { Some(p) => Recipient::Local(p), None => Recipient::Unknown, } } impl Directory { pub(crate) async fn load(state: &AppState) -> Result { Ok(Directory(state.db.pim_principals().await?)) } pub(crate) fn get(&self, id: i64) -> Option<&PimPrincipal> { self.0.iter().find(|p| p.id == id) } /// The forms `calendar-user-address-set` lists: the mailto address, the /// principal URL and the `urn:uuid:`. Compared without case. fn resolve(&self, addr: &str) -> Recipient<'_> { let addr = addr.trim(); let lower = addr.to_ascii_lowercase(); if let Some(rest) = lower.strip_prefix("mailto:") { let Some((local, domain)) = rest.rsplit_once('@') else { return Recipient::External; }; let kind = match domain.strip_suffix(MAIL_DOMAIN) { Some("") => UserType::Individual, Some("rooms.") => UserType::Room, Some("resources.") => UserType::Resource, _ => return Recipient::External, }; let name = percent_decode_str(local).decode_utf8_lossy(); return found( self.0 .iter() .find(|p| p.kind == kind && p.name.eq_ignore_ascii_case(&name)), ); } if let Some(uuid) = lower.strip_prefix("urn:uuid:") { return found(self.0.iter().find(|p| principal_uuid(p.id) == uuid)); } match principal_name(addr) { Some(name) => found(self.0.iter().find(|p| p.name.eq_ignore_ascii_case(&name))), None if lower.starts_with('/') || lower.starts_with("http") => Recipient::Unknown, None => Recipient::External, } } /// Whether an address names principal `id`. pub(crate) fn is(&self, id: i64) -> impl Fn(&str) -> bool + '_ { move |a: &str| matches!(self.resolve(a), Recipient::Local(p) if p.id == id) } } /// What a PUT of a calendar object stores, and what else it writes. pub(crate) struct Stored { pub data: Vec, /// Whether `data` differs from the request body. pub changed: bool, pub schedule_tag: Option, pub ops: Vec, } /// A PUT of `body` over `old` in a calendar of `owner`. `Err` names a failed /// scheduling precondition. pub(crate) async fn put( state: &AppState, dir: &Directory, owner: &PimPrincipal, old: Option<&[u8]>, body: &[u8], ) -> Result, ApiError> { let parse = |b: &[u8]| ICalendar::parse(String::from_utf8_lossy(b).as_ref()).ok(); let Some(sent) = parse(body) else { return Ok(Ok(unchanged(body, None))); }; let owns = dir.is(owner.id); let role = match itip::role(&sent, &owns) { Ok(r) => r, Err(refused) => return Ok(Err(refused.condition())), }; let old = old.and_then(parse); let old_role = old.as_ref().and_then(|o| itip::role(o, &owns).ok()); let now = Utc::now(); let mut ops = Vec::new(); let stored = match (role, old_role) { (Role::Organizer, _) => { let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer)); let (store, messages) = itip::organize(old, Some(sent.clone()), &owns, now); let mut store = store.expect("a PUT stores"); for m in &messages { if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? { itip::set_attendee_status(&mut store, &m.to, status); } } store } (Role::Attendee, Some(Role::Attendee)) => { let old = old.as_ref().expect("an attendee role needs the old object"); let (mut store, reply) = match itip::attend(old, sent.clone(), &owns, now) { Ok(v) => v, Err(refused) => return Ok(Err(refused.condition())), }; if let Some(reply) = reply { let status = reply_to(state, dir, owner, &reply, &mut ops).await?; itip::set_organizer_status(&mut store, status); } store } // No longer a scheduling object, or a copy the attendee brings in // itself (RFC 6638, 3.2.2.2): stored as sent. (_, previous) => { if let Some(old) = &old { ops.extend(removed(state, dir, owner, old, previous, true).await?); } let tag = (role != Role::None).then(|| etag_of(body)); return Ok(Ok(Stored { ops, ..unchanged(body, tag) })); } }; let changed = stored != sent; let data = match changed { true => stored.to_string().into_bytes(), false => body.to_vec(), }; Ok(Ok(Stored { schedule_tag: Some(etag_of(&data)), data, changed, ops, })) } fn unchanged(body: &[u8], schedule_tag: Option) -> Stored { Stored { data: body.to_vec(), changed: false, schedule_tag, ops: Vec::new(), } } /// The writes a DELETE of `old` from a calendar of `owner` causes. `reply` /// is false for `Schedule-Reply: F` (RFC 6638, 8.1). pub(crate) async fn delete( state: &AppState, dir: &Directory, owner: &PimPrincipal, old: &[u8], reply: bool, ) -> Result, ApiError> { let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else { return Ok(Vec::new()); }; let role = itip::role(&old, &dir.is(owner.id)).ok(); removed(state, dir, owner, &old, role, reply).await } /// An organizer object going away cancels; an attendee copy declines. async fn removed( state: &AppState, dir: &Directory, owner: &PimPrincipal, old: &ICalendar, role: Option, reply: bool, ) -> Result, ApiError> { let owns = dir.is(owner.id); let now = Utc::now(); let mut ops = Vec::new(); match role { Some(Role::Organizer) => { let (_, messages) = itip::organize(Some(old), None, &owns, now); for m in &messages { deliver(state, dir, owner, m, &mut ops).await?; } } Some(Role::Attendee) if reply => { if let Some(m) = itip::decline(old, &owns, now) { reply_to(state, dir, owner, &m, &mut ops).await?; } } _ => {} } Ok(ops) } /// A REQUEST or CANCEL from `sender` into the recipient's calendar and /// inbox. Returns the delivery status, `None` for the sender itself. async fn deliver( state: &AppState, dir: &Directory, sender: &PimPrincipal, m: &Message, ops: &mut Vec, ) -> Result, ApiError> { let p = match dir.resolve(&m.to) { Recipient::Local(p) if p.id == sender.id => return Ok(None), Recipient::Local(p) => p, Recipient::Unknown => return Ok(Some(INVALID_USER)), Recipient::External => return Ok(Some(NO_ROUTE)), }; ensure(state, p).await?; let Some((uid, component)) = identity(&m.cal) else { return Ok(Some(REFUSED)); }; let copy = state.db.pim_find_uid(p.id, &uid).await?; let current = copy .as_ref() .and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(d).as_ref()).ok()); if let Some(next) = itip::receive(current.as_ref(), m) { let data = next.to_string().into_bytes(); let etag = etag_of(&data); let (collection_id, name, schedule_tag) = match copy { // Only the others' answers changed: the attendee's pending edit // may still go through (RFC 6638, 3.2.10). Some((id, obj, _)) => ( id, obj.name, if m.quiet { obj.schedule_tag } else { Some(etag.clone()) }, ), None => match state.db.pim_calendar_for(p.id, &component).await? { Some(c) => ( c.id, format!("{}.ics", &crate::hex(&Sha256::digest(&uid))[..32]), Some(etag.clone()), ), None => return Ok(Some(REFUSED)), }, }; ops.push(PimOp::Put { collection_id, obj: PimObject { name, uid, component: component.clone(), etag, schedule_tag, ..Default::default() }, data, }); } if !m.quiet { ops.push(inbox(p, m, &component)); } Ok(Some(DELIVERED)) } /// An attendee's REPLY: applied to the organizer's object, passed on to the /// other attendees, and left in the organizer's inbox. Returns the delivery /// status for the attendee's copy. async fn reply_to( state: &AppState, dir: &Directory, attendee: &PimPrincipal, m: &Message, ops: &mut Vec, ) -> Result<&'static str, ApiError> { let organizer = match dir.resolve(&m.to) { Recipient::Local(p) => p, Recipient::Unknown => return Ok(INVALID_USER), Recipient::External => return Ok(NO_ROUTE), }; let Some((uid, component)) = identity(&m.cal) else { return Ok(REFUSED); }; // RFC 6638, 4.2: a reply to an object the organizer no longer has is // ignored. let Some((collection_id, obj, data)) = state.db.pim_find_uid(organizer.id, &uid).await? else { return Ok(NO_ROUTE); }; let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else { return Ok(NO_ROUTE); }; let mut after = before.clone(); let replier = dir.is(attendee.id); if itip::apply_reply(&mut after, &m.cal, &replier) { let data = after.to_string().into_bytes(); ops.push(PimOp::Put { collection_id, obj: PimObject { etag: etag_of(&data), ..obj }, data, }); // The others learn the new answer without a new Schedule-Tag. let organizes = dir.is(organizer.id); for mut other in itip::messages(Some(&before), Some(&after), &organizes, &[], Utc::now()) { if other.method == Method::Request && !replier(&other.to) { other.quiet = true; deliver(state, dir, organizer, &other, ops).await?; } } } ops.push(inbox(organizer, m, &component)); Ok(DELIVERED) } async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> { match p.kind { UserType::Individual => state.db.pim_ensure_defaults(p.id).await?, _ => state.db.pim_ensure_inbox(p.id).await?, } Ok(()) } fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp { let data = m.cal.to_string().into_bytes(); let stamp = Utc::now().timestamp_nanos_opt().unwrap_or_default(); let seed = format!( "{}\n{}\n{stamp}\n{:?}", m.to, String::from_utf8_lossy(&data), m.method ); let name = format!("{}.ics", &crate::hex(&Sha256::digest(seed))[..32]); PimOp::Inbox { principal_id: p.id, obj: PimObject { // Inbox messages share UIDs, and the store keeps UIDs unique. uid: name.clone(), name, component: component.to_string(), etag: etag_of(&data), ..Default::default() }, data, } } /// UID and component type of a scheduling message or object. fn identity(cal: &ICalendar) -> Option<(String, String)> { let c = cal.components.iter().find(|c| { matches!( c.component_type, ICalendarComponentType::VEvent | ICalendarComponentType::VTodo | ICalendarComponentType::VJournal ) })?; Some((c.uid()?.to_string(), c.component_type.as_str().to_string())) }