CalDAV scheduling: local implicit scheduling (RFC 6638)
- pimdav::itip: roles, organizer diffs into REQUEST/CANCEL per attendee and instance, attendee REPLY, copy updates, REPLY applied to the organizer copy; server-owned PARTSTAT and SCHEDULE-STATUS - Inbox (stored) and outbox (virtual) collections, principal URLs, schedule-default-calendar-URL, calendar-auto-schedule in OPTIONS - Schedule-Tag header and property, If-Schedule-Tag-Match, quiet PARTSTAT-only updates that keep the tag, Schedule-Reply: F - Deliveries commit with the change in one transaction under one lock; SCHEDULE-STATUS 1.2 / 3.7 / 5.2 / 5.3; rooms receive bookings - The default calendar cannot be deleted; schema v14 adds schedule_tag Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apimdav/src/itip.rs
@@ -0,0 +1,1141 @@
//! Implicit scheduling (RFC 6638) as iTIP messages (RFC 5546): what a change
//! to a scheduling object sends to whom, and how each copy changes.
//!
//! Calendar user addresses are compared through closures, so the caller maps
//! them onto its principals.
use std::collections::HashSet;
use calcard::common::PartialDateTime;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry,
ICalendarMethod, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue,
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue,
};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::xml::{CALDAV, el};
use crate::zone::{Zone, Zones};
/// Whether an address belongs to someone in particular.
pub type Is<'a> = &'a dyn Fn(&str) -> bool;
/// How the owner of a calendar takes part in one of its objects (RFC 6638,
/// 3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Organizer,
Attendee,
/// Not a scheduling object for this owner.
None,
}
/// A scheduling precondition a PUT fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
SameOrganizer,
AttendeeChange,
}
impl Refused {
pub fn condition(self) -> Element {
match self {
Refused::SameOrganizer => el(CALDAV, "same-organizer-in-all-components"),
Refused::AttendeeChange => el(CALDAV, "allowed-attendee-scheduling-object-change"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Request,
Cancel,
Reply,
}
#[derive(Debug, Clone)]
pub struct Message {
/// The recipient's address, as the object writes it.
pub to: String,
pub method: Method,
/// Only the participation of other attendees changed. It updates an
/// existing copy, keeps its Schedule-Tag and leaves no inbox entry.
pub quiet: bool,
/// With METHOD.
pub cal: ICalendar,
}
pub fn role(cal: &ICalendar, owner: Is) -> Result<Role, Refused> {
let obj = Obj::new(cal);
let mut organizers = obj
.comps()
.filter_map(|c| address(c.c.property(&ICalendarProperty::Organizer)?));
let Some(organizer) = organizers.next() else {
return Ok(Role::None);
};
if organizers.any(|o| !o.eq_ignore_ascii_case(organizer)) {
return Err(Refused::SameOrganizer);
}
if owner(organizer) {
return Ok(Role::Organizer);
}
let attends = obj
.comps()
.flat_map(|c| attendees(&c.c))
.any(|e| address(e).is_some_and(owner));
Ok(if attends { Role::Attendee } else { Role::None })
}
/// An organizer's PUT (`new`) or DELETE (`None`) of a scheduling object.
/// `old` is the stored organizer object, if any. Returns what to store and
/// what to deliver.
pub fn organize(
old: Option<&ICalendar>,
new: Option<ICalendar>,
organizer: Is,
now: DateTime<Utc>,
) -> (Option<ICalendar>, Vec<Message>) {
let mut force = Vec::new();
let new = new.map(|n| guard(old, &n, organizer, &mut force));
let messages = messages(old, new.as_ref(), organizer, &force, now);
(new, messages)
}
/// The messages a change of the organizer object sends, without touching
/// the attendee state in it. `force` lists attendees who get a REQUEST even
/// if nothing changed for them.
pub fn messages(
old: Option<&ICalendar>,
new: Option<&ICalendar>,
organizer: Is,
force: &[String],
now: DateTime<Utc>,
) -> Vec<Message> {
let old = old.map(Obj::new);
let new = new.map(Obj::new);
let mut who: Vec<&str> = Vec::new();
for obj in old.iter().chain(new.iter()) {
for e in obj.comps().flat_map(|c| attendees(&c.c)) {
if let Some(a) = address(e)
&& server_agent(e)
&& !organizer(a)
&& !who.iter().any(|w| w.eq_ignore_ascii_case(a))
{
who.push(a);
}
}
}
let mut out = Vec::new();
for a in who {
let before = old.as_ref().and_then(|o| Some((o, o.view(a)?)));
let after = new.as_ref().and_then(|n| Some((n, n.view(a)?)));
let (method, quiet, comps, src) = match (before, after) {
(Some((src, b)), None) => (Method::Cancel, false, cancelled(b), src),
(None, Some((src, comps))) => (Method::Request, false, comps, src),
(Some((_, b)), Some((src, comps))) => {
let quiet = if normalized(&b, true) != normalized(&comps, true) {
false
} else if normalized(&b, false) != normalized(&comps, false) {
true
} else if force.iter().any(|f| f.eq_ignore_ascii_case(a)) {
false
} else {
continue;
};
(Method::Request, quiet, comps, src)
}
(None, None) => continue,
};
out.push(Message {
to: a.to_string(),
method,
quiet,
cal: src.envelope(comps, method, now),
});
}
out
}
/// Records the delivery status for `to` on its ATTENDEE properties.
pub fn set_attendee_status(cal: &mut ICalendar, to: &str, status: &str) {
for c in &mut cal.components {
for e in c
.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Attendee)
{
if address(e).is_some_and(|a| a.eq_ignore_ascii_case(to)) {
set_param(e, ICalendarParameterName::ScheduleStatus, text(status));
}
}
}
}
/// Records the delivery status of a REPLY on the ORGANIZER properties.
pub fn set_organizer_status(cal: &mut ICalendar, status: &str) {
for c in &mut cal.components {
for e in c
.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Organizer)
{
set_param(e, ICalendarParameterName::ScheduleStatus, text(status));
}
}
}
/// An attendee's PUT over the stored copy. Returns what to store and the
/// REPLY, if the attendee's participation changed.
pub fn attend(
old: &ICalendar,
new: ICalendar,
me: Is,
now: DateTime<Utc>,
) -> Result<(ICalendar, Option<Message>), Refused> {
let old = Obj::new(old);
if !old.organizer_schedules() {
return Ok((new, None));
}
let mut next = Obj::new(&new);
let master = old.master();
for c in next.comps() {
let key = next.key(&c.c);
match old.find(key) {
Some(oc) => {
let same_times = [
ICalendarProperty::Dtstart,
ICalendarProperty::Dtend,
ICalendarProperty::Duration,
ICalendarProperty::Due,
ICalendarProperty::Rrule,
ICalendarProperty::Rdate,
ICalendarProperty::Exrule,
]
.iter()
.all(|p| old.times(&oc.c, p) == next.times(&c.c, p));
let kept_exdates = old
.times(&oc.c, &ICalendarProperty::Exdate)
.is_subset(&next.times(&c.c, &ICalendarProperty::Exdate));
let organizer = |c: &ICalendarComponent| {
c.property(&ICalendarProperty::Organizer)
.and_then(address)
.map(str::to_ascii_lowercase)
};
if !same_times
|| !kept_exdates
|| organizer(&oc.c) != organizer(&c.c)
|| addresses(&oc.c) != addresses(&c.c)
{
return Err(Refused::AttendeeChange);
}
}
// An instance the attendee overrides, to set its own status.
None => {
let start =
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
if key.is_none() || master.is_none() || start != key {
return Err(Refused::AttendeeChange);
}
}
}
}
// The state of the others and of the organizer is the server's.
let mut force = false;
let keys: Vec<Option<i64>> = next.comps().map(|c| next.key(&c.c)).collect();
for (c, key) in next.comps_mut().zip(&keys) {
let Some(base) = old.find(*key).or(master) else {
continue;
};
for e in &mut c.c.entries {
match e.name {
ICalendarProperty::Attendee if !address(e).is_some_and(me) => {
if let Some(b) = same_attendee(&base.c, e) {
e.params = b.params.clone();
}
}
ICalendarProperty::Organizer => {
force |= param(e, &ICalendarParameterName::ScheduleForceSend)
.is_some_and(|v| v.eq_ignore_ascii_case("REPLY"));
if let Some(b) = base.c.property(&ICalendarProperty::Organizer) {
e.params = b.params.clone();
}
}
_ => {}
}
}
}
let mut replied: Vec<Node> = Vec::new();
for (c, key) in next.comps().zip(&keys) {
let now_stat = own_partstat(&c.c, me);
let before = old
.find(*key)
.or(master)
.and_then(|b| own_partstat(&b.c, me));
if now_stat.is_some() && (force || now_stat != before) {
replied.push(reply_part(c, me));
}
}
if let Some(m) = next.master() {
let before = master.map_or_else(HashSet::new, |om| {
old.times(&om.c, &ICalendarProperty::Exdate)
});
for e in m.c.properties(&ICalendarProperty::Exdate) {
for value in &e.values {
if before.contains(&next.value_key(e, value)) {
continue;
}
let rid = ICalendarEntry {
name: ICalendarProperty::RecurrenceId,
params: e.params.clone(),
values: vec![value.clone()],
};
replied.push(declined_instance(m, rid, me));
}
}
}
let reply = match replied.is_empty() {
true => None,
false => next.organizer().map(|to| Message {
to,
method: Method::Reply,
quiet: false,
cal: next.envelope(replied, Method::Reply, now),
}),
};
Ok((next.done(), reply))
}
/// An attendee's DELETE: the REPLY declining every instance, unless the
/// organizer handles scheduling itself or already cancelled.
pub fn decline(old: &ICalendar, me: Is, now: DateTime<Utc>) -> Option<Message> {
let old = Obj::new(old);
if !old.organizer_schedules() {
return None;
}
let cancelled = old.comps().all(|c| {
c.c.property(&ICalendarProperty::Status)
.and_then(|e| e.values.first()?.as_text())
.is_some_and(|s| s.eq_ignore_ascii_case("CANCELLED"))
});
if cancelled {
return None;
}
let comps: Vec<Node> =
old.comps()
.filter(|c| own_partstat(&c.c, me).is_some())
.map(|c| {
let mut part = reply_part(c, me);
for e in
part.c.entries.iter_mut().filter(|e| {
e.name == ICalendarProperty::Attendee && address(e).is_some_and(me)
})
{
set_param(
e,
ICalendarParameterName::Partstat,
partstat(ICalendarParticipationStatus::Declined),
);
}
part
})
.collect();
if comps.is_empty() {
return None;
}
Some(Message {
to: old.organizer()?,
method: Method::Reply,
quiet: false,
cal: old.envelope(comps, Method::Reply, now),
})
}
/// A REQUEST or CANCEL applied to the attendee's copy. `None`: nothing to
/// store.
pub fn receive(copy: Option<&ICalendar>, msg: &Message) -> Option<ICalendar> {
match msg.method {
Method::Request => {
if msg.quiet && copy.is_none() {
return None;
}
let copy = copy.map(Obj::new);
let mut next = Obj::new(&msg.cal);
next.root
.c
.entries
.retain(|e| e.name != ICalendarProperty::Method);
let keys: Vec<Option<i64>> = next.comps().map(|c| next.key(&c.c)).collect();
let Some(copy) = copy else {
return Some(next.done());
};
// What the attendee may keep for itself (RFC 6638, 3.2.2.1).
for (c, key) in next.comps_mut().zip(&keys) {
let Some(base) = copy.find(*key).or(copy.master()) else {
continue;
};
c.children
.retain(|n| n.c.component_type != ICalendarComponentType::VAlarm);
c.children.extend(
base.children
.iter()
.filter(|n| n.c.component_type == ICalendarComponentType::VAlarm)
.cloned(),
);
for p in [
ICalendarProperty::Transp,
ICalendarProperty::PercentComplete,
ICalendarProperty::Completed,
] {
c.c.entries.retain(|e| e.name != p);
c.c.entries.extend(base.c.properties(&p).cloned());
}
let status = base
.c
.property(&ICalendarProperty::Organizer)
.and_then(|e| e.parameter(&ICalendarParameterName::ScheduleStatus))
.cloned();
if let Some(s) = status {
for e in
c.c.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Organizer)
{
set_param(e, ICalendarParameterName::ScheduleStatus, s.clone());
}
}
}
Some(next.done())
}
Method::Cancel => {
let msg_obj = Obj::new(&msg.cal);
let mut next = Obj::new(copy?);
let whole = msg_obj.master().is_some();
let gone: Vec<(i64, ICalendarEntry)> = msg_obj
.comps()
.filter_map(|c| {
let rid = c.c.property(&ICalendarProperty::RecurrenceId)?;
Some((msg_obj.instant(rid)?, rid.clone()))
})
.collect();
let keys: Vec<Option<i64>> = next.comps().map(|c| next.key(&c.c)).collect();
for (c, key) in next.comps_mut().zip(&keys) {
if whole || key.is_some_and(|k| gone.iter().any(|(g, _)| *g == k)) {
set_prop(
&mut c.c,
ICalendarProperty::Status,
ICalendarValue::Status(ICalendarStatus::Cancelled),
);
}
}
if !whole {
let missing: Vec<ICalendarEntry> = gone
.into_iter()
.filter(|(k, _)| !keys.contains(&Some(*k)))
.map(|(_, rid)| ICalendarEntry {
name: ICalendarProperty::Exdate,
params: without(rid.params, &ICalendarParameterName::Range),
values: rid.values,
})
.collect();
if let Some(m) = next
.comps_mut()
.find(|c| !c.c.has_property(&ICalendarProperty::RecurrenceId))
{
m.c.entries.extend(missing);
}
}
Some(next.done())
}
Method::Reply => None,
}
}
/// A REPLY applied to the organizer object. `false` if it changed nothing.
pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool {
let rep = Obj::new(reply);
let mut next = Obj::new(org);
let mut changed = false;
for rc in rep.comps() {
let key = rep.key(&rc.c);
let at = match next.position(key) {
Some(at) => at,
// A reply for one instance of the series gets its own override.
None => {
let (Some(_), Some(rid), Some(master)) = (
key,
rc.c.property(&ICalendarProperty::RecurrenceId),
next.master(),
) else {
continue;
};
let inst = next.instance(master, rid);
next.root.children.push(inst);
next.root.children.len() - 1
}
};
let target = &mut next.root.children[at];
if sequence(&rc.c) < sequence(&target.c) {
continue;
}
let Some(stat) =
rc.c.properties(&ICalendarProperty::Attendee)
.find(|e| address(e).is_some_and(replier))
.map(|e| {
e.parameter(&ICalendarParameterName::Partstat)
.cloned()
.unwrap_or(partstat(ICalendarParticipationStatus::NeedsAction))
})
else {
continue;
};
let codes: Vec<String> =
rc.c.properties(&ICalendarProperty::RequestStatus)
.filter_map(|e| e.values.first()?.as_text())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
.collect();
let status = match codes.is_empty() {
true => "2.0".to_string(),
false => codes.join(","),
};
for e in
target.c.entries.iter_mut().filter(|e| {
e.name == ICalendarProperty::Attendee && address(e).is_some_and(replier)
})
{
changed |= e.parameter(&ICalendarParameterName::Partstat) != Some(&stat);
set_param(e, ICalendarParameterName::Partstat, stat.clone());
set_param(e, ICalendarParameterName::ScheduleStatus, text(&status));
}
}
if changed {
*org = next.done();
}
changed
}
// ---------------------------------------------------------------------------
// The organizer object
// ---------------------------------------------------------------------------
/// The client's organizer object with the attendee state the server owns:
/// their PARTSTAT (reset on a reschedule, RFC 6638 3.2.8) and
/// SCHEDULE-STATUS. Collects SCHEDULE-FORCE-SEND=REQUEST into `force`.
fn guard(
old: Option<&ICalendar>,
new: &ICalendar,
organizer: Is,
force: &mut Vec<String>,
) -> ICalendar {
let old = old.map(Obj::new);
let mut next = Obj::new(new);
let master = old.as_ref().and_then(Obj::master);
let keys: Vec<Option<i64>> = next.comps().map(|c| next.key(&c.c)).collect();
let moved: Vec<bool> = next
.comps()
.map(|c| match &old {
Some(o) => match o.find(next.key(&c.c)) {
Some(oc) => rescheduled(o, &oc.c, &next, &c.c),
// A new override: rescheduled if it moves its instance.
None => {
let start =
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
next.key(&c.c).is_none() || start != next.key(&c.c)
}
},
None => true,
})
.collect();
let bumps: Vec<Option<i64>> = next
.comps()
.zip(&moved)
.map(|(c, moved)| {
let oc = old.as_ref()?.find(next.key(&c.c))?;
(*moved && sequence(&c.c) <= sequence(&oc.c)).then(|| sequence(&oc.c) + 1)
})
.collect();
for ((c, key), (moved, bump)) in next.comps_mut().zip(&keys).zip(moved.iter().zip(&bumps)) {
let base = old.as_ref().and_then(|o| o.find(*key)).or(master);
for e in &mut c.c.entries {
match e.name {
ICalendarProperty::Organizer => {
remove_param(e, &ICalendarParameterName::ScheduleForceSend);
}
ICalendarProperty::Attendee => {
let forced = param(e, &ICalendarParameterName::ScheduleForceSend)
.is_some_and(|v| v.eq_ignore_ascii_case("REQUEST"));
remove_param(e, &ICalendarParameterName::ScheduleForceSend);
let Some(a) = address(e).map(str::to_string) else {
continue;
};
if organizer(&a) || !server_agent(e) {
continue;
}
if forced {
force.push(a.clone());
}
let prev = base.and_then(|b| same_attendee(&b.c, e));
let stat = match (moved, prev) {
(false, Some(p)) => p
.parameter(&ICalendarParameterName::Partstat)
.cloned()
.unwrap_or(partstat(ICalendarParticipationStatus::NeedsAction)),
_ => partstat(ICalendarParticipationStatus::NeedsAction),
};
set_param(e, ICalendarParameterName::Partstat, stat);
match prev.and_then(|p| p.parameter(&ICalendarParameterName::ScheduleStatus)) {
Some(s) => set_param(e, ICalendarParameterName::ScheduleStatus, s.clone()),
None => remove_param(e, &ICalendarParameterName::ScheduleStatus),
}
}
_ => {}
}
}
if let Some(n) = bump {
set_prop(
&mut c.c,
ICalendarProperty::Sequence,
ICalendarValue::Integer(*n),
);
}
}
next.done()
}
/// Whether a change moves instances in time (RFC 6638, 3.2.8). Shortening a
/// series or excluding instances does not.
fn rescheduled(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bool {
let moved = [
ICalendarProperty::Dtstart,
ICalendarProperty::Dtend,
ICalendarProperty::Duration,
ICalendarProperty::Due,
ICalendarProperty::Rdate,
]
.iter()
.any(|p| old.times(oc, p) != new.times(nc, p));
let reinstated = !old
.times(oc, &ICalendarProperty::Exdate)
.is_subset(&new.times(nc, &ICalendarProperty::Exdate));
moved || reinstated || rules_grew(oc, nc)
}
fn rules_grew(oc: &ICalendarComponent, nc: &ICalendarComponent) -> bool {
let rules = |c: &ICalendarComponent| -> Vec<_> {
c.properties(&ICalendarProperty::Rrule)
.filter_map(|e| match e.values.first()? {
ICalendarValue::RecurrenceRule(r) => Some((**r).clone()),
_ => None,
})
.collect()
};
let (o, n) = (rules(oc), rules(nc));
if o == n {
return false;
}
let (o, n) = match (&o[..], &n[..]) {
// Dropping the rule leaves the first instance only.
([_, ..], []) => return false,
([o], [n]) => (o, n),
_ => return true,
};
let unbounded = |r: &calcard::icalendar::ICalendarRecurrenceRule| {
let mut r = r.clone();
r.until = None;
r.count = None;
r
};
if unbounded(o) != unbounded(n) {
return true;
}
let shorter = match (&o.until, &n.until, o.count, n.count) {
(Some(ou), Some(nu), _, _) => nu <= ou,
(_, _, Some(oc), Some(nc)) => nc <= oc,
(None, _, None, _) => true,
_ => false,
};
!shorter
}
fn cancelled(comps: Vec<Node>) -> Vec<Node> {
comps
.into_iter()
.map(|mut n| {
set_prop(
&mut n.c,
ICalendarProperty::Status,
ICalendarValue::Status(ICalendarStatus::Cancelled),
);
let seq = sequence(&n.c) + 1;
set_prop(
&mut n.c,
ICalendarProperty::Sequence,
ICalendarValue::Integer(seq),
);
n
})
.collect()
}
/// For comparing what an attendee would receive: without the stamps a
/// client rewrites on every save, and optionally without participation.
fn normalized(comps: &[Node], without_partstat: bool) -> Vec<Node> {
comps
.iter()
.map(|n| {
let mut n = n.clone();
n.c.entries.retain(|e| {
!matches!(
e.name,
ICalendarProperty::Dtstamp
| ICalendarProperty::LastModified
| ICalendarProperty::Created
| ICalendarProperty::Sequence
) && !matches!(&e.name, ICalendarProperty::Other(x) if x.to_ascii_uppercase().starts_with("X-"))
});
if without_partstat {
for e in n.c.entries.iter_mut().filter(|e| e.name == ICalendarProperty::Attendee) {
remove_param(e, &ICalendarParameterName::Partstat);
}
}
n
})
.collect()
}
/// A component of the attendee's REPLY: only its own ATTENDEE, no alarms.
fn reply_part(c: &Node, me: Is) -> Node {
let mut n = Node {
c: c.c.clone(),
children: Vec::new(),
};
n.c.entries
.retain(|e| e.name != ICalendarProperty::Attendee || address(e).is_some_and(me));
strip_scheduling_params(&mut n.c);
n
}
/// The REPLY component for an instance the attendee excluded with EXDATE.
fn declined_instance(master: &Node, rid: ICalendarEntry, me: Is) -> Node {
let mut c = ICalendarComponent {
component_type: master.c.component_type.clone(),
entries: Vec::new(),
component_ids: Vec::new(),
};
for p in [
ICalendarProperty::Uid,
ICalendarProperty::Sequence,
ICalendarProperty::Organizer,
ICalendarProperty::Summary,
] {
c.entries.extend(master.c.properties(&p).cloned());
}
c.entries.push(ICalendarEntry {
name: ICalendarProperty::Dtstart,
..rid.clone()
});
c.entries.push(rid);
for e in master
.c
.properties(&ICalendarProperty::Attendee)
.filter(|e| address(e).is_some_and(me))
{
let mut e = e.clone();
set_param(
&mut e,
ICalendarParameterName::Partstat,
partstat(ICalendarParticipationStatus::Declined),
);
c.entries.push(e);
}
strip_scheduling_params(&mut c);
Node {
c,
children: Vec::new(),
}
}
// ---------------------------------------------------------------------------
// The object as a tree
// ---------------------------------------------------------------------------
/// A component with its sub-components, detached from the flat list calcard
/// keeps.
#[derive(Debug, Clone, PartialEq)]
struct Node {
/// `component_ids` is empty; `children` replaces it.
c: ICalendarComponent,
children: Vec<Node>,
}
fn node(cal: &ICalendar, i: usize) -> Node {
let mut c = cal.components[i].clone();
let ids = std::mem::take(&mut c.component_ids);
let children = ids
.iter()
.map(|&id| id as usize)
.filter(|&id| id > i && id < cal.components.len())
.map(|id| node(cal, id))
.collect();
Node { c, children }
}
fn flatten(n: &Node, out: &mut Vec<ICalendarComponent>) -> u32 {
let at = out.len();
out.push(n.c.clone());
for ch in &n.children {
let id = flatten(ch, out);
out[at].component_ids.push(id);
}
at as u32
}
/// A calendar object: the VCALENDAR with its time zones and components.
struct Obj {
root: Node,
zones: Zones,
}
impl Obj {
fn new(cal: &ICalendar) -> Self {
let root = match cal.components.is_empty() {
true => Node {
c: ICalendarComponent {
component_type: ICalendarComponentType::VCalendar,
entries: Vec::new(),
component_ids: Vec::new(),
},
children: Vec::new(),
},
false => node(cal, 0),
};
Obj {
root,
zones: Zones::new(cal, Zone::Utc),
}
}
fn done(self) -> ICalendar {
let mut components = Vec::new();
flatten(&self.root, &mut components);
ICalendar { components }
}
fn comps(&self) -> impl Iterator<Item = &Node> {
self.root.children.iter().filter(|n| is_scheduled(&n.c))
}
fn comps_mut(&mut self) -> impl Iterator<Item = &mut Node> {
self.root.children.iter_mut().filter(|n| is_scheduled(&n.c))
}
fn master(&self) -> Option<&Node> {
self.find(None)
}
fn find(&self, key: Option<i64>) -> Option<&Node> {
self.comps().find(|c| self.key(&c.c) == key)
}
fn position(&self, key: Option<i64>) -> Option<usize> {
self.root
.children
.iter()
.position(|n| is_scheduled(&n.c) && self.key(&n.c) == key)
}
/// The RECURRENCE-ID as an instant; `None` for the master.
fn key(&self, c: &ICalendarComponent) -> Option<i64> {
self.instant(c.property(&ICalendarProperty::RecurrenceId)?)
}
/// The first value of a date or date-time property, in UTC seconds.
/// Dates count from midnight, whatever the zone.
fn instant(&self, e: &ICalendarEntry) -> Option<i64> {
let v = e.values.first()?.as_partial_date_time()?;
self.at(v, e.tz_id())
}
fn at(&self, v: &PartialDateTime, tzid: Option<&str>) -> Option<i64> {
let dt = v.to_date_time()?;
Some(match dt.offset {
_ if v.hour.is_none() => dt.date_time.and_utc().timestamp(),
Some(o) => dt.date_time.and_utc().timestamp() - i64::from(o.local_minus_utc()),
None => self.zones.get(tzid).to_utc(dt.date_time).timestamp(),
})
}
/// Every value of a property, comparable across encodings: instants for
/// dates and date-times, the text otherwise.
fn times(&self, c: &ICalendarComponent, prop: &ICalendarProperty) -> HashSet<String> {
c.properties(prop)
.flat_map(|e| e.values.iter().map(move |v| self.value_key(e, v)))
.collect()
}
fn value_key(&self, e: &ICalendarEntry, v: &ICalendarValue) -> String {
match v.as_partial_date_time() {
Some(p) => match self.at(p, e.tz_id()) {
Some(t) if p.hour.is_none() => format!("{t}d"),
Some(t) => t.to_string(),
None => format!("{v:?}"),
},
None => format!("{v:?}"),
}
}
fn organizer(&self) -> Option<String> {
self.comps()
.find_map(|c| address(c.c.property(&ICalendarProperty::Organizer)?))
.map(str::to_string)
}
/// Whether the server replies for the attendee (RFC 6638, 3.2.2).
fn organizer_schedules(&self) -> bool {
self.comps()
.filter_map(|c| c.c.property(&ICalendarProperty::Organizer))
.all(server_agent)
}
/// What attendee `a` gets to see (RFC 6638, 3.2.6): the master with the
/// overrides it is in, and EXDATEs for those it is not in; or just the
/// overrides it is in.
fn view(&self, a: &str) -> Option<Vec<Node>> {
let invited = |c: &ICalendarComponent| {
attendees(c)
.any(|e| address(e).is_some_and(|x| x.eq_ignore_ascii_case(a)) && server_agent(e))
};
let mut out = Vec::new();
let master = self.master().filter(|m| invited(&m.c));
if let Some(m) = master {
let mut m = m.clone();
for o in self
.comps()
.filter(|c| c.c.has_property(&ICalendarProperty::RecurrenceId))
{
if !invited(&o.c)
&& let Some(rid) = o.c.property(&ICalendarProperty::RecurrenceId)
{
m.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Exdate,
params: without(rid.params.clone(), &ICalendarParameterName::Range),
values: rid.values.clone(),
});
}
}
out.push(m);
}
out.extend(
self.comps()
.filter(|c| c.c.has_property(&ICalendarProperty::RecurrenceId) && invited(&c.c))
.cloned(),
);
for n in &mut out {
n.children
.retain(|ch| ch.c.component_type != ICalendarComponentType::VAlarm);
strip_scheduling_params(&mut n.c);
}
(!out.is_empty()).then_some(out)
}
/// A message: this object's VCALENDAR and time zones around `comps`.
fn envelope(&self, comps: Vec<Node>, method: Method, now: DateTime<Utc>) -> ICalendar {
let mut root = Node {
c: self.root.c.clone(),
children: self
.root
.children
.iter()
.filter(|n| n.c.component_type == ICalendarComponentType::VTimezone)
.cloned()
.collect(),
};
let method = match method {
Method::Request => ICalendarMethod::Request,
Method::Cancel => ICalendarMethod::Cancel,
Method::Reply => ICalendarMethod::Reply,
};
set_prop(
&mut root.c,
ICalendarProperty::Method,
ICalendarValue::Method(method),
);
let stamp = PartialDateTime::from_utc_timestamp(now.timestamp());
for mut n in comps {
set_prop(
&mut n.c,
ICalendarProperty::Dtstamp,
ICalendarValue::PartialDateTime(Box::new(stamp.clone())),
);
root.children.push(n);
}
let mut components = Vec::new();
flatten(&root, &mut components);
ICalendar { components }
}
/// An override for one instance of `master`, so it can hold a status of
/// its own. Its length becomes a DURATION.
fn instance(&self, master: &Node, rid: &ICalendarEntry) -> Node {
let mut n = master.clone();
let start =
n.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| self.instant(e));
let end_prop = match n.c.component_type {
ICalendarComponentType::VTodo => ICalendarProperty::Due,
_ => ICalendarProperty::Dtend,
};
let end = n.c.property(&end_prop).and_then(|e| self.instant(e));
if let (Some(s), Some(e)) = (start, end) {
n.c.entries.retain(|x| x.name != end_prop);
n.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Duration,
params: Vec::new(),
values: vec![ICalendarValue::Duration(ICalendarDuration::from_seconds(
(e - s).max(0),
))],
});
}
n.c.entries.retain(|e| {
!matches!(
e.name,
ICalendarProperty::Rrule
| ICalendarProperty::Rdate
| ICalendarProperty::Exdate
| ICalendarProperty::Exrule
| ICalendarProperty::Dtstart
| ICalendarProperty::RecurrenceId
)
});
n.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Dtstart,
params: without(rid.params.clone(), &ICalendarParameterName::Range),
values: rid.values.clone(),
});
n.c.entries.push(ICalendarEntry {
name: ICalendarProperty::RecurrenceId,
params: without(rid.params.clone(), &ICalendarParameterName::Range),
values: rid.values.clone(),
});
n
}
}
// ---------------------------------------------------------------------------
// Properties and parameters
// ---------------------------------------------------------------------------
fn is_scheduled(c: &ICalendarComponent) -> bool {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
}
fn address(e: &ICalendarEntry) -> Option<&str> {
e.values.first()?.as_text().map(str::trim)
}
fn attendees(c: &ICalendarComponent) -> impl Iterator<Item = &ICalendarEntry> {
c.properties(&ICalendarProperty::Attendee)
}
fn addresses(c: &ICalendarComponent) -> Vec<String> {
let mut v: Vec<String> = attendees(c)
.filter_map(address)
.map(str::to_ascii_lowercase)
.collect();
v.sort();
v.dedup();
v
}
fn same_attendee<'a>(c: &'a ICalendarComponent, e: &ICalendarEntry) -> Option<&'a ICalendarEntry> {
let a = address(e)?;
attendees(c).find(|x| address(x).is_some_and(|b| b.eq_ignore_ascii_case(a)))
}
fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
attendees(c).find(|e| address(e).is_some_and(me)).map(|e| {
param(e, &ICalendarParameterName::Partstat)
.unwrap_or("NEEDS-ACTION")
.to_ascii_uppercase()
})
}
/// `SCHEDULE-AGENT` absent or `SERVER`. Unknown values count as `NONE`.
fn server_agent(e: &ICalendarEntry) -> bool {
param(e, &ICalendarParameterName::ScheduleAgent)
.is_none_or(|v| v.eq_ignore_ascii_case("SERVER"))
}
fn sequence(c: &ICalendarComponent) -> i64 {
c.property(&ICalendarProperty::Sequence)
.and_then(|e| e.values.first()?.as_integer())
.unwrap_or(0)
}
fn param<'a>(e: &'a ICalendarEntry, name: &ICalendarParameterName) -> Option<&'a str> {
e.parameter(name)?.as_text()
}
fn set_param(e: &mut ICalendarEntry, name: ICalendarParameterName, value: ICalendarParameterValue) {
remove_param(e, &name);
e.params.push(ICalendarParameter::new(name, value));
}
fn remove_param(e: &mut ICalendarEntry, name: &ICalendarParameterName) {
e.params.retain(|p| &p.name != name);
}
fn without(
mut params: Vec<ICalendarParameter>,
name: &ICalendarParameterName,
) -> Vec<ICalendarParameter> {
params.retain(|p| &p.name != name);
params
}
fn set_prop(c: &mut ICalendarComponent, name: ICalendarProperty, value: ICalendarValue) {
c.entries.retain(|e| e.name != name);
c.entries.push(ICalendarEntry {
name,
params: Vec::new(),
values: vec![value],
});
}
/// Messages carry none of the scheduling parameters (RFC 6638, 7).
fn strip_scheduling_params(c: &mut ICalendarComponent) {
for e in c.entries.iter_mut().filter(|e| {
matches!(
e.name,
ICalendarProperty::Attendee | ICalendarProperty::Organizer
)
}) {
for p in [
ICalendarParameterName::ScheduleAgent,
ICalendarParameterName::ScheduleStatus,
ICalendarParameterName::ScheduleForceSend,
] {
remove_param(e, &p);
}
}
}
fn text(s: &str) -> ICalendarParameterValue {
ICalendarParameterValue::Text(s.to_string())
}
fn partstat(p: ICalendarParticipationStatus) -> ICalendarParameterValue {
ICalendarParameterValue::Partstat(p)
}
Mpimdav/src/lib.rs
@@ -4,6 +4,7 @@
pub mod expand;
pub mod filter;
pub mod freebusy;
pub mod itip;
pub mod object;
pub mod principal;
pub mod render;
Apimdav/tests/itip.rs
@@ -0,0 +1,361 @@
//! Implicit scheduling: what organizer and attendee changes send, and how
//! the copies change.
use chrono::{TimeZone, Utc};
use pimdav::calcard::icalendar::ICalendar;
use pimdav::itip::{self, Message, Method, Refused, Role};
const ALICE: &str = "mailto:alice@filebrowser.invalid";
const BOB: &str = "mailto:bob@filebrowser.invalid";
const CAROL: &str = "mailto:carol@example.com";
fn is(who: &'static str) -> impl Fn(&str) -> bool {
move |a: &str| a.eq_ignore_ascii_case(who)
}
fn cal(body: &str) -> ICalendar {
let text = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{}END:VCALENDAR\r\n",
body.replace('\n', "\r\n")
);
ICalendar::parse(&text).unwrap()
}
/// A weekly event organized by alice, with the given extra lines.
fn meeting(extra: &str) -> ICalendar {
cal(&format!(
"BEGIN:VEVENT
UID:m1
DTSTAMP:20260101T000000Z
DTSTART:20260105T100000Z
DTEND:20260105T110000Z
RRULE:FREQ=WEEKLY;COUNT=4
SUMMARY:Sync
ORGANIZER:{ALICE}
ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
{extra}END:VEVENT
"
))
}
fn now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).unwrap()
}
fn text(c: &ICalendar) -> String {
c.to_string().replace("\r\n ", "")
}
fn to<'a>(msgs: &'a [Message], who: &str) -> Option<&'a Message> {
msgs.iter().find(|m| m.to.eq_ignore_ascii_case(who))
}
#[test]
fn roles() {
let m = meeting(&format!("ATTENDEE:{BOB}\n"));
assert_eq!(itip::role(&m, &is(ALICE)), Ok(Role::Organizer));
assert_eq!(itip::role(&m, &is(BOB)), Ok(Role::Attendee));
assert_eq!(itip::role(&m, &is(CAROL)), Ok(Role::None));
let split = cal(&format!(
"BEGIN:VEVENT\nUID:x\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:x\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\nORGANIZER:{BOB}\nEND:VEVENT\n"
));
assert_eq!(itip::role(&split, &is(ALICE)), Err(Refused::SameOrganizer));
}
#[test]
fn invite() {
// The client claims bob accepted; only bob can say that.
let new = cal(&format!(
"BEGIN:VEVENT
UID:m1
DTSTAMP:20260101T000000Z
DTSTART:20260105T100000Z
SUMMARY:Sync
ORGANIZER:{ALICE}
ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}
ATTENDEE;SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED:{CAROL}
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT5M
END:VALARM
END:VEVENT
"
));
let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert!(
store.contains(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}")),
"{store}"
);
assert!(
store.contains("SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED"),
"{store}"
);
// Nothing to alice herself or to the attendee the client schedules.
assert_eq!(msgs.len(), 1);
let m = to(&msgs, BOB).unwrap();
assert_eq!(m.method, Method::Request);
let body = text(&m.cal);
assert!(body.contains("METHOD:REQUEST"));
assert!(body.contains("DTSTAMP:20260102T120000Z"));
assert!(!body.contains("VALARM"));
assert!(!body.contains("SCHEDULE-"), "{body}");
}
#[test]
fn reschedule_resets_participation() {
let old = meeting(&format!(
"ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}\n"
));
// A new title keeps bob's answer, even from a client with a stale copy.
let retitled = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
.replace("SUMMARY:Sync", "SUMMARY:Weekly sync");
let (store, msgs) = itip::organize(
Some(&old),
Some(ICalendar::parse(&retitled).unwrap()),
&is(ALICE),
now(),
);
let store = text(&store.unwrap());
assert!(
store.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
"{store}"
);
assert!(!to(&msgs, BOB).unwrap().quiet);
let moved = text(&old).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z");
let (store, msgs) = itip::organize(
Some(&old),
Some(ICalendar::parse(&moved).unwrap()),
&is(ALICE),
now(),
);
let store = text(&store.unwrap());
assert!(store.contains("PARTSTAT=NEEDS-ACTION"), "{store}");
assert!(store.contains("SEQUENCE:1"), "{store}");
assert!(text(&to(&msgs, BOB).unwrap().cal).contains("DTSTART:20260105T090000Z"));
// Shortening the series is no reschedule.
let shorter = text(&old).replace("COUNT=4", "COUNT=2");
let (store, _) = itip::organize(
Some(&old),
Some(ICalendar::parse(&shorter).unwrap()),
&is(ALICE),
now(),
);
assert!(
text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}"))
);
}
#[test]
fn unchanged_or_forced() {
let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let resaved = text(&old)
.replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260101T080000Z")
.replace("SUMMARY:Sync", "SUMMARY:Sync\r\nX-MOZ-GENERATION:3");
let (_, msgs) = itip::organize(
Some(&old),
Some(ICalendar::parse(&resaved).unwrap()),
&is(ALICE),
now(),
);
assert!(msgs.is_empty(), "{msgs:?}");
let forced = text(&old).replace(
"ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob",
"ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST;PARTSTAT=ACCEPTED:mailto:bob",
);
let (store, msgs) = itip::organize(
Some(&old),
Some(ICalendar::parse(&forced).unwrap()),
&is(ALICE),
now(),
);
assert!(!text(&store.unwrap()).contains("FORCE-SEND"));
assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
}
#[test]
fn removed_attendee_and_delete_cancel() {
let old = meeting(&format!("ATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"));
let (_, msgs) = itip::organize(
Some(&old),
Some(meeting(&format!("ATTENDEE:{BOB}\n"))),
&is(ALICE),
now(),
);
let cancel = to(&msgs, CAROL).unwrap();
assert_eq!(cancel.method, Method::Cancel);
assert!(text(&cancel.cal).contains("STATUS:CANCELLED"));
// The attendee list changed for bob too.
assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
let (store, msgs) = itip::organize(Some(&old), None, &is(ALICE), now());
assert!(store.is_none());
assert!(msgs.iter().all(|m| m.method == Method::Cancel));
assert_eq!(msgs.len(), 2);
}
#[test]
fn instances_only_reach_their_attendees() {
let old = cal(&format!(
"BEGIN:VEVENT
UID:m1
DTSTART:20260105T100000Z
RRULE:FREQ=WEEKLY;COUNT=4
ORGANIZER:{ALICE}
ATTENDEE:{BOB}
ATTENDEE:{CAROL}
END:VEVENT
BEGIN:VEVENT
UID:m1
RECURRENCE-ID:20260112T100000Z
DTSTART:20260112T140000Z
ORGANIZER:{ALICE}
ATTENDEE:{BOB}
END:VEVENT
"
));
let (_, msgs) = itip::organize(None, Some(old), &is(ALICE), now());
let carol = text(&to(&msgs, CAROL).unwrap().cal);
assert!(carol.contains("EXDATE:20260112T100000Z"), "{carol}");
assert!(!carol.contains("RECURRENCE-ID"), "{carol}");
let bob = text(&to(&msgs, BOB).unwrap().cal);
assert!(
bob.contains("RECURRENCE-ID:20260112T100000Z") && !bob.contains("EXDATE"),
"{bob}"
);
}
#[test]
fn accept_and_apply_reply() {
let org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
assert!(!text(©).contains("METHOD"));
let accepted = text(©).replace(
&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}"),
&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
);
let (_, reply) =
itip::attend(©, ICalendar::parse(&accepted).unwrap(), &is(BOB), now()).unwrap();
let reply = reply.unwrap();
assert_eq!((reply.method, reply.to.as_str()), (Method::Reply, ALICE));
let body = text(&reply.cal);
assert!(body.contains("METHOD:REPLY"), "{body}");
assert!(
!body.contains(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}")),
"{body}"
);
let mut org = org;
assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
let org = text(&org);
assert!(
org.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
"{org}"
);
}
#[test]
fn declining_one_instance() {
let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let copy = org.clone();
// bob overrides the second week to decline it.
let with_override = format!(
"{}BEGIN:VEVENT\r\nUID:m1\r\nRECURRENCE-ID:20260112T100000Z\r\nDTSTART:20260112T100000Z\r\nDTEND:20260112T110000Z\r\nSUMMARY:Sync\r\nORGANIZER:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;PARTSTAT=DECLINED:{BOB}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
text(©).trim_end_matches("END:VCALENDAR\r\n")
);
let (_, reply) = itip::attend(
©,
ICalendar::parse(&with_override).unwrap(),
&is(BOB),
now(),
)
.unwrap();
let reply = reply.unwrap();
assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
let mut org = org;
assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
let org_text = text(&org);
assert!(
org_text.contains("RECURRENCE-ID:20260112T100000Z"),
"{org_text}"
);
assert!(org_text.contains("DURATION:PT1H"), "{org_text}");
assert!(
org_text.contains(&format!("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:{BOB}")),
"{org_text}"
);
// An EXDATE declines too.
let excluded = text(©).replace(
"RRULE:FREQ=WEEKLY;COUNT=4",
"RRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE:20260119T100000Z",
);
let (_, reply) =
itip::attend(©, ICalendar::parse(&excluded).unwrap(), &is(BOB), now()).unwrap();
let body = text(&reply.unwrap().cal);
assert!(
body.contains("RECURRENCE-ID:20260119T100000Z") && body.contains("PARTSTAT=DECLINED"),
"{body}"
);
}
#[test]
fn attendees_may_not_move_the_meeting() {
let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
let moved = text(©).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T120000Z");
assert_eq!(
itip::attend(©, ICalendar::parse(&moved).unwrap(), &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
// Alarms and transparency are the attendee's.
let own = text(©).replace(
"SUMMARY:Sync",
"SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
);
let (_, reply) = itip::attend(©, ICalendar::parse(&own).unwrap(), &is(BOB), now()).unwrap();
assert!(reply.is_none());
}
#[test]
fn attendee_delete_declines() {
let copy = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let reply = itip::decline(©, &is(BOB), now()).unwrap();
assert!(text(&reply.cal).contains(&format!("PARTSTAT=DECLINED:{BOB}")));
let client = text(©).replace(
&format!("ORGANIZER:{ALICE}"),
&format!("ORGANIZER;SCHEDULE-AGENT=CLIENT:{ALICE}"),
);
assert!(itip::decline(&ICalendar::parse(&client).unwrap(), &is(BOB), now()).is_none());
}
#[test]
fn updates_keep_what_the_attendee_owns() {
let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
let copy = ICalendar::parse(text(©).replace(
"SUMMARY:Sync",
"SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
))
.unwrap();
let retitled =
ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap();
let (_, msgs) = itip::organize(Some(&org), Some(retitled), &is(ALICE), now());
let updated = text(&itip::receive(Some(©), to(&msgs, BOB).unwrap()).unwrap());
assert!(
updated.contains("SUMMARY:Planning")
&& updated.contains("TRANSP:TRANSPARENT")
&& updated.contains("TRIGGER:-PT5M"),
"{updated}"
);
let (_, msgs) = itip::organize(Some(&org), None, &is(ALICE), now());
let cancelled = text(&itip::receive(Some(©), to(&msgs, BOB).unwrap()).unwrap());
assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
}
Mserver/src/api/mod.rs
@@ -95,6 +95,7 @@ mod files;
mod passkeys;
mod pim;
mod pim_api;
mod pim_schedule;
mod search;
mod shares;
mod spa;
Mserver/src/api/pim.rs
@@ -9,7 +9,8 @@
//!
//! 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`. A room's home holds its bookings.
//! system address book as `system`. The calendar home holds the scheduling
//! `inbox` and `outbox`. 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.
@@ -34,11 +35,13 @@ use pimdav::xml::{
};
use pimdav::zone::{self, Zone};
use pimdav::{filter, freebusy, object};
use super::pim_schedule::{self, Directory, Stored};
use sha2::{Digest, Sha256};
use xmltree::Element;
use crate::db::{
Mode, PimCollection, PimKind, PimObject, PimPrincipal, PimWrite, Precondition, User,
Mode, PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimWrite, Precondition, User,
};
use crate::error::{ApiError, AppState};
@@ -50,13 +53,17 @@ const MAX_XML_SIZE: usize = 1024 * 1024;
/// The domain of the addresses users schedule with. `.invalid` is reserved
/// (RFC 2606), so nothing sent there can reach anyone.
const MAIL_DOMAIN: &str = "filebrowser.invalid";
pub(super) const MAIL_DOMAIN: &str = "filebrowser.invalid";
/// The id of the system address book, which no stored collection has.
const DIRECTORY: i64 = 0;
const DIRECTORY_SLUG: &str = "system";
/// The slug prefix of a collection lent to the account.
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.
const OUTBOX: &str = "outbox";
/// Characters escaped in an href segment.
const SEGMENT: &AsciiSet = &CONTROLS
@@ -142,6 +149,18 @@ 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<String> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => href.parse::<axum::http::Uri>().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(
@@ -368,7 +387,11 @@ fn options() -> Response<Body> {
(
StatusCode::OK,
[
("dav", "1, 3, access-control, calendar-access, addressbook, extended-mkcol"),
(
"dav",
"1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, \
extended-mkcol",
),
(
ALLOW.as_str(),
"OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
@@ -382,12 +405,12 @@ async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
axum::body::to_bytes(body, limit).await.ok()
}
fn etag_of(data: &[u8]) -> String {
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.
fn principal_uuid(id: i64) -> String {
pub(super) fn principal_uuid(id: i64) -> String {
let h = crate::hex(&Sha256::digest(format!("filebrowser-ng principal {id}"))[..16]);
format!(
"{}-{}-{}-{}-{}",
@@ -467,7 +490,7 @@ async fn directory(
component: "VCARD".to_string(),
etag: etag_of(&data),
size: data.len() as i64,
modified_at: String::new(),
..Default::default()
};
members.push((obj, data));
}
@@ -508,6 +531,9 @@ impl Cx<'_> {
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 them.
let access = if self.me.admin {
Access::Write
@@ -561,6 +587,7 @@ impl Cx<'_> {
.pim_collections(space.id, kind)
.await?
.into_iter()
.filter(|c| space.mine || c.slug != INBOX)
.map(|c| Col {
c,
access: own,
@@ -630,6 +657,10 @@ enum Res {
/// With its owner's principal href and whether the account may add to it.
Home(String, Access),
Collection(PimKind, Col),
/// With the href of the calendar that receives new invitations.
Inbox(Col, Option<String>),
/// With its owner's principal href.
Outbox(String),
Object(PimKind, PimObject),
}
@@ -686,13 +717,23 @@ impl Cx<'_> {
list.push((s.home(*kind), Res::Home(s.principal(), access)));
if deep {
for col in self.collections(*kind).await? {
list.push((
s.collection(*kind, &col.c.slug),
Res::Collection(*kind, col),
));
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));
@@ -709,7 +750,7 @@ impl Cx<'_> {
};
let s = self.space();
let slug = col.c.slug.clone();
list.push((s.collection(*kind, &slug), Res::Collection(*kind, col)));
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)));
}
@@ -799,6 +840,17 @@ impl Cx<'_> {
&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(
@@ -887,7 +939,36 @@ impl Cx<'_> {
)),
}
}
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),
@@ -904,6 +985,24 @@ impl Cx<'_> {
}
}
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<Res, ApiError> {
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<Element>) -> xml::Response {
@@ -990,11 +1089,35 @@ fn privileges(access: Access) -> Element {
],
Access::Read => &["read", "read-current-user-privilege-set"],
};
privilege_set(names.iter().map(|n| (DAV, *n)))
}
/// 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<Item = (&'a str, &'a str)>) -> Element {
with_children(
el(DAV, "current-user-privilege-set"),
names
.iter()
.map(|n| with_children(el(DAV, "privilege"), [el(DAV, n)])),
.into_iter()
.map(|(ns, n)| with_children(el(DAV, "privilege"), [el(ns, n)])),
)
}
@@ -1063,7 +1186,9 @@ impl Cx<'_> {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
}
// Names the home shows for lent and generated collections.
if slug.starts_with(SHARED_PREFIX) || slug == DIRECTORY_SLUG {
if slug.starts_with(SHARED_PREFIX)
|| [DIRECTORY_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
{
return Ok(status(StatusCode::FORBIDDEN));
}
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
@@ -1245,7 +1370,7 @@ impl Cx<'_> {
} else {
Body::from(data)
};
Ok((
let mut r = (
StatusCode::OK,
[
(CONTENT_TYPE, content_type(*kind, &o.component)),
@@ -1253,7 +1378,9 @@ impl Cx<'_> {
],
body,
)
.into_response())
.into_response();
with_schedule_tag(&mut r, o.schedule_tag.as_deref());
Ok(r)
}
async fn put(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
@@ -1264,7 +1391,8 @@ impl Cx<'_> {
return Ok(status(StatusCode::CONFLICT));
};
let space = self.space();
if access < Access::Write {
// 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);
@@ -1283,33 +1411,76 @@ impl Cx<'_> {
Ok(v) => v,
Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
};
let etag = etag_of(&data);
let obj = PimObject {
name: name.clone(),
uid,
component,
etag: etag.clone(),
..Default::default()
};
// The stored bytes are the request bytes, so the ETag may be returned.
match self
.state
.db
.pim_put_object(col.id, &obj, &data, &precondition(headers))
.await?
{
PimWrite::Created => Ok((StatusCode::CREATED, [(ETAG, etag)]).into_response()),
PimWrite::Updated => Ok((StatusCode::NO_CONTENT, [(ETAG, etag)]).into_response()),
PimWrite::PreconditionFailed => Ok(status(StatusCode::PRECONDITION_FAILED)),
PimWrite::UidConflict(holder) => Ok(error(
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()]),
),
)),
PimWrite::Deleted | PimWrite::NotFound => Ok(status(StatusCode::INTERNAL_SERVER_ERROR)),
));
}
let stored = match kind {
PimKind::Calendar => {
let dir = Directory::load(self.state).await?;
let owner = self.owner(&col, &dir).await?;
let old = current.as_ref().map(|(_, d)| d.as_slice());
match pim_schedule::put(self.state, &dir, &owner, 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 {
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 principal owning a collection, whose addresses decide how it takes
/// part in the objects there.
async fn owner(&self, col: &PimCollection, dir: &Directory) -> Result<PimPrincipal, ApiError> {
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 {
@@ -1323,15 +1494,43 @@ impl Cx<'_> {
};
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 => {
self.state.db.pim_delete_collection(col.id).await?;
if scheduling
&& db
.pim_calendar_for(space.id, "VEVENT")
.await?
.is_some_and(|d| d.id == col.id)
{
return Ok(error(
StatusCode::FORBIDDEN,
el(CALDAV, "default-calendar-needed"),
));
}
if scheduling {
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(self.state).await?;
let owner = self.owner(&col, &dir).await?;
let mut ops = Vec::new();
for (_, data) in db.pim_objects_with_data(col.id).await? {
ops.extend(
pim_schedule::delete(self.state, &dir, &owner, &data, true).await?,
);
}
db.pim_apply(&ops).await?;
}
db.pim_delete_collection(col.id).await?;
status(StatusCode::NO_CONTENT)
}
// Deleting a lent collection only takes it out of this home.
_ if slug.starts_with(SHARED_PREFIX) && space.mine => {
self.state.db.pim_remove_share(col.id, self.me.id).await?;
db.pim_remove_share(col.id, self.me.id).await?;
status(StatusCode::NO_CONTENT)
}
_ => denied(&space.home(*kind), "unbind"),
@@ -1340,19 +1539,43 @@ impl Cx<'_> {
if access < Access::Write {
return Ok(denied(&href, "unbind"));
}
Ok(
match self
.state
.db
.pim_delete_object(col.id, name, &precondition(headers))
.await?
{
PimWrite::Deleted => status(StatusCode::NO_CONTENT),
PimWrite::NotFound => status(StatusCode::NOT_FOUND),
PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
_ => status(StatusCode::INTERNAL_SERVER_ERROR),
},
)
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 reply = headers.get("schedule-reply").and_then(|v| v.to_str().ok()) != Some("F");
ops.extend(pim_schedule::delete(self.state, &dir, &owner, &data, reply).await?);
}
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<Body>, tag: Option<&str>) {
if let Some(v) = tag.and_then(|t| t.parse().ok()) {
r.headers_mut().insert("schedule-tag", v);
}
}
@@ -1419,6 +1642,10 @@ impl Cx<'_> {
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()
@@ -1737,15 +1964,20 @@ impl Cx<'_> {
let Some(to) = self.collection(*kind, &to_slug).await? else {
return Ok(status(StatusCode::CONFLICT));
};
if from.access < Access::Write {
if from.access < Access::Write || from.c.slug == INBOX {
return Ok(denied(&space.collection(*kind, slug), "unbind"));
}
if to.access < Access::Write {
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,
@@ -1753,33 +1985,38 @@ impl Cx<'_> {
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
Ok(
match self
.state
.db
.pim_move_object(
from.c.id,
name,
to.c.id,
&to_name,
overwrite,
&precondition(headers),
)
.await?
{
PimWrite::Created => status(StatusCode::CREATED),
PimWrite::Updated => status(StatusCode::NO_CONTENT),
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()]),
),
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),
},
)
),
PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
})
}
}
Mserver/src/api/pim_api.rs
@@ -12,7 +12,7 @@ use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::SessionUser;
use crate::api::pim::collection_href;
use crate::api::pim::{INBOX, collection_href};
use crate::db::{PimCollection, PimKind, UserType};
use crate::error::{ApiError, AppState};
@@ -37,6 +37,9 @@ pub async fn list(
let mut out = Vec::new();
for kind in [PimKind::Calendar, PimKind::AddressBook] {
for c in state.db.pim_collections(me.id, kind).await? {
if kind == PimKind::Calendar && c.slug == INBOX {
continue;
}
out.push(PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
@@ -63,7 +66,8 @@ pub async fn list(
/// The id of a collection the signed-in user owns, or 404.
async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
match state.db.pim_collection_by_id(id).await? {
Some((owner, _, _)) if owner == auth.user.id => Ok(id),
// The inbox is not lent: it holds messages, not events.
Some((owner, _, c)) if owner == auth.user.id && c.slug != INBOX => Ok(id),
_ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
}
}
Aserver/src/api/pim_schedule.rs
@@ -0,0 +1,395 @@
//! 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<PimPrincipal>);
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<Self, ApiError> {
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<u8>,
/// Whether `data` differs from the request body.
pub changed: bool,
pub schedule_tag: Option<String>,
pub ops: Vec<PimOp>,
}
/// 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<Result<Stored, Element>, 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<String>) -> 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<Vec<PimOp>, 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<Role>,
reply: bool,
) -> Result<Vec<PimOp>, 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<PimOp>,
) -> Result<Option<&'static str>, 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<PimOp>,
) -> 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 {
user_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()))
}
Mserver/src/db.rs
@@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 13;
const SCHEMA_VERSION: i64 = 14;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -203,6 +203,29 @@ pub struct PimObject {
pub etag: String,
pub size: i64,
pub modified_at: String,
/// With the quotes. Only on scheduling objects.
pub schedule_tag: Option<String>,
}
/// One write of [`Db::pim_apply`].
#[derive(Debug)]
pub enum PimOp {
Put {
collection_id: i64,
obj: PimObject,
data: Vec<u8>,
},
Delete {
collection_id: i64,
name: String,
},
/// A scheduling message for the inbox of `user_id`. `obj.uid` must be
/// unique in the inbox: several messages share one iCalendar UID.
Inbox {
user_id: i64,
obj: PimObject,
data: Vec<u8>,
},
}
#[derive(Debug, PartialEq, Eq)]
@@ -225,7 +248,7 @@ pub struct Precondition {
impl Precondition {
/// Whether the write may go ahead given the current ETag, if any.
fn allows(&self, current: Option<&str>) -> bool {
pub fn allows(&self, current: Option<&str>) -> bool {
let listed = |header: &str| match current {
Some(etag) => header.split(',').any(|t| {
let t = t.trim();
@@ -457,6 +480,11 @@ impl Db {
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);",
)?;
}
if version < 14 {
// Implicit scheduling (RFC 6638). NULL for objects that schedule
// nothing.
conn.execute_batch("ALTER TABLE pim_objects ADD COLUMN schedule_tag TEXT;")?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -1290,15 +1318,15 @@ impl Db {
// ---------- CalDAV and CardDAV ----------
/// Gives the user a calendar and an address book when they have none.
/// Scheduling needs a calendar to deliver into, so a user who deletes the
/// last one gets a new one.
/// Gives the user a calendar, an address book and a scheduling inbox when
/// they have none.
pub async fn pim_ensure_defaults(&self, user_id: i64) -> DbResult<()> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
SELECT ?1, ?2, 'default', ?3, ?4, ?5
WHERE NOT EXISTS (SELECT 1 FROM pim_collections WHERE user_id = ?1 AND kind = ?2)",
WHERE NOT EXISTS (SELECT 1 FROM pim_collections
WHERE user_id = ?1 AND kind = ?2 AND slug != 'inbox')",
)?;
let now = now();
stmt.execute(params![
@@ -1309,6 +1337,120 @@ impl Db {
now
])?;
stmt.execute(params![user_id, "card", "Contacts", "", now])?;
ensure_inbox(&c, user_id)
}
/// The scheduling inbox alone, for rooms and resources.
pub async fn pim_ensure_inbox(&self, user_id: i64) -> DbResult<()> {
let c = self.0.lock().await;
ensure_inbox(&c, user_id)
}
/// The calendar that receives new invitations of `component`: the
/// oldest one that takes it.
pub async fn pim_calendar_for(
&self,
user_id: i64,
component: &str,
) -> DbResult<Option<PimCollection>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE user_id = ?1 AND kind = 'cal' AND slug != 'inbox'
AND ',' || components || ',' LIKE '%,' || ?2 || ',%'
ORDER BY id LIMIT 1"
))?;
stmt.query_row(params![user_id, component], map_pim_collection)
.optional()
}
/// The object with `uid` in any of the user's own calendars.
pub async fn pim_find_uid(
&self,
user_id: i64,
uid: &str,
) -> DbResult<Option<(i64, PimObject, Vec<u8>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT o.name, o.uid, o.component, o.etag, length(o.data), o.modified_at,
o.schedule_tag, o.data, o.collection_id
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.user_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox' AND o.uid = ?2
ORDER BY o.id LIMIT 1",
)?;
stmt.query_row(params![user_id, uid], |r| {
Ok((r.get(8)?, map_pim_object(r)?, r.get(7)?))
})
.optional()
}
/// The name of another object in the collection that has `uid`.
pub async fn pim_uid_holder(
&self,
collection_id: i64,
uid: &str,
name: &str,
) -> DbResult<Option<String>> {
let c = self.0.lock().await;
c.query_row(
"SELECT name FROM pim_objects WHERE collection_id = ?1 AND uid = ?2 AND name != ?3",
params![collection_id, uid, name],
|r| r.get(0),
)
.optional()
}
/// Several object writes in one transaction.
pub async fn pim_apply(&self, ops: &[PimOp]) -> DbResult<()> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
for op in ops {
match op {
PimOp::Put {
collection_id,
obj,
data,
} => {
put_object(&tx, *collection_id, obj, data)?;
}
PimOp::Delete {
collection_id,
name,
} => {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
)?;
record_pim_change(&tx, *collection_id, name, true)?;
}
PimOp::Inbox { user_id, obj, data } => {
let inbox: i64 = tx.query_row(
"SELECT id FROM pim_collections
WHERE user_id = ?1 AND kind = 'cal' AND slug = 'inbox'",
[user_id],
|r| r.get(0),
)?;
put_object(&tx, inbox, obj, data)?;
// ponytail: a fixed cap. Clients that never empty the inbox
// would fill it forever; an age limit may suit better.
let old: Vec<String> = tx
.prepare_cached(
"SELECT name FROM pim_objects WHERE collection_id = ?1
ORDER BY id DESC LIMIT -1 OFFSET ?2",
)?
.query_map(params![inbox, INBOX_KEEP], |r| r.get(0))?
.collect::<DbResult<_>>()?;
for name in old {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![inbox, name],
)?;
record_pim_change(&tx, inbox, &name, true)?;
}
}
}
}
tx.commit()?;
Ok(())
}
@@ -1413,7 +1555,7 @@ impl Db {
WHERE collection_id = ?1 AND name = ?2"
))?;
stmt.query_row(params![collection_id, name], |r| {
Ok((map_pim_object(r)?, r.get(6)?))
Ok((map_pim_object(r)?, r.get(7)?))
})
.optional()
}
@@ -1426,7 +1568,7 @@ impl Db {
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS}, data FROM pim_objects WHERE collection_id = ?1 ORDER BY name"
))?;
stmt.query_map([collection_id], |r| Ok((map_pim_object(r)?, r.get(6)?)))?
stmt.query_map([collection_id], |r| Ok((map_pim_object(r)?, r.get(7)?)))?
.collect()
}
@@ -1514,91 +1656,6 @@ impl Db {
})
}
/// Stores an object under `obj.name`. The precondition and the UID check
/// run in the same transaction as the write.
pub async fn pim_put_object(
&self,
collection_id: i64,
obj: &PimObject,
data: &[u8],
cond: &Precondition,
) -> DbResult<PimWrite> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let current: Option<String> = tx
.query_row(
"SELECT etag FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, obj.name],
|r| r.get(0),
)
.optional()?;
if !cond.allows(current.as_deref()) {
return Ok(PimWrite::PreconditionFailed);
}
let holder: Option<String> = tx
.query_row(
"SELECT name FROM pim_objects
WHERE collection_id = ?1 AND uid = ?2 AND name != ?3",
params![collection_id, obj.uid, obj.name],
|r| r.get(0),
)
.optional()?;
if let Some(holder) = holder {
return Ok(PimWrite::UidConflict(holder));
}
tx.execute(
"INSERT INTO pim_objects (collection_id, name, uid, component, data, etag, modified_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT (collection_id, name) DO UPDATE SET uid = ?3, component = ?4,
data = ?5, etag = ?6, modified_at = ?7",
params![
collection_id,
obj.name,
obj.uid,
obj.component,
data,
obj.etag,
now()
],
)?;
record_pim_change(&tx, collection_id, &obj.name, false)?;
tx.commit()?;
Ok(match current {
Some(_) => PimWrite::Updated,
None => PimWrite::Created,
})
}
pub async fn pim_delete_object(
&self,
collection_id: i64,
name: &str,
cond: &Precondition,
) -> DbResult<PimWrite> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let current: Option<String> = tx
.query_row(
"SELECT etag FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
|r| r.get(0),
)
.optional()?;
if current.is_none() {
return Ok(PimWrite::NotFound);
}
if !cond.allows(current.as_deref()) {
return Ok(PimWrite::PreconditionFailed);
}
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
)?;
record_pim_change(&tx, collection_id, name, true)?;
tx.commit()?;
Ok(PimWrite::Deleted)
}
// ---------- principals, sharing, rooms ----------
/// An account, room or resource by URL name. Disabled accounts are
@@ -1882,6 +1939,47 @@ fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordInfo> {
})
}
/// Messages an inbox keeps; older ones are dropped.
const INBOX_KEEP: i64 = 100;
fn ensure_inbox(c: &Connection, user_id: i64) -> DbResult<()> {
c.prepare_cached(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
SELECT ?1, 'cal', 'inbox', 'Inbox', 'VEVENT,VTODO,VJOURNAL', ?2
WHERE NOT EXISTS (SELECT 1 FROM pim_collections
WHERE user_id = ?1 AND kind = 'cal' AND slug = 'inbox')",
)?
.execute(params![user_id, now()])?;
Ok(())
}
/// Stores an object under `obj.name`, replacing one of that name.
fn put_object(
tx: &rusqlite::Transaction,
collection_id: i64,
obj: &PimObject,
data: &[u8],
) -> DbResult<()> {
tx.execute(
"INSERT INTO pim_objects (collection_id, name, uid, component, data, etag, modified_at,
schedule_tag)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT (collection_id, name) DO UPDATE SET uid = ?3, component = ?4,
data = ?5, etag = ?6, modified_at = ?7, schedule_tag = ?8",
params![
collection_id,
obj.name,
obj.uid,
obj.component,
data,
obj.etag,
now(),
obj.schedule_tag
],
)?;
record_pim_change(tx, collection_id, &obj.name, false)
}
/// Bumps the collection's `seq` and records it as the latest change of `name`.
fn record_pim_change(
tx: &rusqlite::Transaction,
@@ -1970,7 +2068,7 @@ fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
})
}
const PIM_OBJECT_COLS: &str = "name, uid, component, etag, length(data), modified_at";
const PIM_OBJECT_COLS: &str = "name, uid, component, etag, length(data), modified_at, schedule_tag";
fn map_pim_object(r: &rusqlite::Row) -> DbResult<PimObject> {
Ok(PimObject {
@@ -1980,6 +2078,7 @@ fn map_pim_object(r: &rusqlite::Row) -> DbResult<PimObject> {
etag: r.get(3)?,
size: r.get(4)?,
modified_at: r.get(5)?,
schedule_tag: r.get(6)?,
})
}
Mserver/tests/api_pim.rs
@@ -111,6 +111,8 @@ fn error_condition(r: &Resp) -> Name {
const HOME: &str = "/pim/calendars/alice/";
const CAL: &str = "/pim/calendars/alice/default/";
const BOOK: &str = "/pim/addressbooks/alice/default/";
const INBOX: &str = "/pim/calendars/alice/inbox/";
const OUTBOX: &str = "/pim/calendars/alice/outbox/";
fn event(uid: &str, summary: &str) -> String {
format!(
@@ -211,7 +213,16 @@ async fn homes_list_the_default_collections() {
let (env, auth) = setup().await;
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(ms.len(), 2, "{}", r.text());
// The home, the calendar, and the scheduling inbox and outbox.
assert_eq!(ms.len(), 4, "{}", r.text());
for (href, kind) in [(INBOX, "schedule-inbox"), (OUTBOX, "schedule-outbox")] {
let rt = prop(&ms, href, DAV, "resourcetype").unwrap();
assert!(xml::child(&rt, CALDAV, kind).is_some(), "{href}");
}
assert_eq!(
prop(&ms, INBOX, CALDAV, "schedule-default-calendar-URL").map(|p| hrefs_of(&p)),
Some(vec![CAL.to_string()])
);
let rt = prop(&ms, CAL, DAV, "resourcetype").unwrap();
assert!(xml::child(&rt, CALDAV, "calendar").is_some());
assert_eq!(
@@ -536,13 +547,39 @@ async fn address_objects() {
}
#[tokio::test]
async fn deleting_the_last_calendar_brings_a_new_default() {
async fn the_default_calendar_stays() {
let (env, auth) = setup().await;
// Invitations arrive there (RFC 6638, 4.3).
let r = req(&env, "DELETE", CAL, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
let ms = parse_multistatus(&r);
assert!(ms.iter().any(|(h, _)| h == CAL));
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(CALDAV, "default-calendar-needed"));
let other = format!("{HOME}work/");
assert_eq!(
req(&env, "MKCALENDAR", &other, &auth, &[], "").await.status,
StatusCode::CREATED
);
assert_eq!(
req(&env, "DELETE", &other, &auth, &[], "").await.status,
StatusCode::NO_CONTENT
);
// Nor can the inbox be made or removed by a client.
assert_eq!(
req(&env, "DELETE", INBOX, &auth, &[], "").await.status,
StatusCode::FORBIDDEN
);
assert_eq!(
req(
&env,
"MKCALENDAR",
"/pim/calendars/alice/outbox/",
&auth,
&[],
""
)
.await
.status,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
Aserver/tests/api_pim_schedule.rs
@@ -0,0 +1,385 @@
//! Implicit scheduling (RFC 6638) between the accounts and rooms of one
//! server.
mod common;
use axum::http::{Method, StatusCode};
use common::*;
use pimdav::xml::{self, CALDAV, DAV, Name};
use serde_json::json;
use xmltree::Element;
const USERS: [&str; 3] = ["alice", "bob", "carol"];
const PW: &str = "secret12345";
struct Pim {
env: Env,
admin: Client,
}
impl Pim {
async fn new() -> Self {
let env = Env::new().await;
let admin = env.admin().await;
for u in USERS {
create_user(&admin, u, PW, &[]).await;
}
Pim { env, admin }
}
async fn req(
&self,
user: &str,
verb: &str,
path: &str,
extra: &[(&str, &str)],
body: &str,
) -> Resp {
let auth = basic(user, PW);
let mut headers = vec![("authorization", auth.as_str())];
headers.extend_from_slice(extra);
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&headers,
body.as_bytes().to_vec(),
)
.await
}
/// The hrefs of the members of a collection.
async fn members(&self, user: &str, collection: &str) -> Vec<String> {
let r = self
.req(user, "PROPFIND", collection, &[("depth", "1")], "")
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| xml::text(xml::child(resp, DAV, "href").unwrap()))
.filter(|h| h != collection)
.collect()
}
/// The only object of a user's default calendar.
async fn copy(&self, user: &str) -> (String, Resp) {
let members = self.members(user, &cal(user)).await;
assert_eq!(members.len(), 1, "{members:?}");
let href = members[0].clone();
let r = self.req(user, "GET", &href, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
(href, r)
}
async fn inbox(&self, user: &str) -> Vec<String> {
let mut out = Vec::new();
for href in self
.members(user, &format!("/pim/calendars/{user}/inbox/"))
.await
{
out.push(unfold(&self.req(user, "GET", &href, &[], "").await.text()));
}
out
}
}
fn cal(user: &str) -> String {
format!("/pim/calendars/{user}/default/")
}
fn addr(user: &str) -> String {
format!("mailto:{user}@filebrowser.invalid")
}
fn unfold(s: &str) -> String {
s.replace("\r\n ", "")
}
/// A parameter of the ATTENDEE property of `who`.
fn attendee_param(ics: &str, who: &str, param: &str) -> Option<String> {
let suffix = format!(":{who}");
let unfolded = unfold(ics);
let line = unfolded
.lines()
.find(|l| l.starts_with("ATTENDEE") && l.ends_with(&suffix))?;
line.strip_suffix(&suffix)?
.split(';')
.find_map(|p| p.strip_prefix(&format!("{param}=")).map(str::to_string))
}
fn meeting(start: &str, attendees: &[&str]) -> String {
let attendees: String = attendees
.iter()
.map(|a| format!("ATTENDEE;RSVP=TRUE:{a}\r\n"))
.collect();
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:meet-1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\
ORGANIZER:{}\r\nATTENDEE;PARTSTAT=ACCEPTED:{}\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n",
addr("alice"),
addr("alice")
)
}
const ALICE_EVENT: &str = "/pim/calendars/alice/default/meet.ics";
async fn invite(pim: &Pim, attendees: &[&str]) -> Resp {
let r = pim
.req(
"alice",
"PUT",
ALICE_EVENT,
&[],
&meeting("20260301T100000Z", attendees),
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
r
}
fn error_condition(r: &Resp) -> Name {
let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
Name::of(xml::elements(&root).next().unwrap())
}
#[tokio::test]
async fn discovery_names_inbox_and_outbox() {
let pim = Pim::new().await;
let r = pim.req("alice", "OPTIONS", "/pim/", &[], "").await;
assert!(r.header("dav").unwrap().contains("calendar-auto-schedule"));
let body = "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>\
<c:schedule-inbox-URL/><c:schedule-outbox-URL/></d:prop></d:propfind>";
let r = pim
.req("alice", "PROPFIND", "/pim/principals/alice/", &[], body)
.await;
let text = r.text();
assert!(text.contains("/pim/calendars/alice/inbox/"), "{text}");
assert!(text.contains("/pim/calendars/alice/outbox/"), "{text}");
}
#[tokio::test]
async fn invite_and_answer() {
let pim = Pim::new().await;
let r = invite(
&pim,
&[
&addr("bob"),
&addr("carol"),
"mailto:dave@example.com",
&addr("nobody"),
],
)
.await;
// The server added SCHEDULE-STATUS, so the stored bytes differ.
assert!(r.header("etag").is_none());
assert!(r.header("schedule-tag").is_some());
let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
for (who, status) in [
(addr("bob"), "1.2"),
(addr("carol"), "1.2"),
("mailto:dave@example.com".into(), "5.2"),
(addr("nobody"), "3.7"),
] {
let got = attendee_param(&org, &who, "SCHEDULE-STATUS");
assert_eq!(got.as_deref(), Some(status), "{org}");
}
let (bob_href, got) = pim.copy("bob").await;
let bob_copy = unfold(&got.text());
let partstat = attendee_param(&bob_copy, &addr("bob"), "PARTSTAT");
assert_eq!(partstat.as_deref(), Some("NEEDS-ACTION"), "{bob_copy}");
assert!(
!bob_copy.contains("METHOD") && !bob_copy.contains("SCHEDULE-STATUS"),
"{bob_copy}"
);
let inbox = pim.inbox("bob").await;
assert_eq!(inbox.len(), 1);
assert!(inbox[0].contains("METHOD:REQUEST"));
// bob accepts, conditional on what he read.
let alice_tag = pim
.req("alice", "GET", ALICE_EVENT, &[], "")
.await
.header("schedule-tag");
let carol_tag = pim.copy("carol").await.1.header("schedule-tag");
let tag = got.header("schedule-tag").unwrap();
let accepted = bob_copy.replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
let r = pim
.req(
"bob",
"PUT",
&bob_href,
&[("if-schedule-tag-match", &tag)],
&accepted,
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
let bob_copy = unfold(&pim.req("bob", "GET", &bob_href, &[], "").await.text());
assert!(
bob_copy.contains("ORGANIZER;SCHEDULE-STATUS=1.2"),
"{bob_copy}"
);
// alice sees the answer; her Schedule-Tag stays, so her pending edit
// would still go through.
let r = pim.req("alice", "GET", ALICE_EVENT, &[], "").await;
assert_eq!(r.header("schedule-tag"), alice_tag);
let org = r.text();
assert_eq!(
attendee_param(&org, &addr("bob"), "SCHEDULE-STATUS").as_deref(),
Some("2.0")
);
assert_eq!(
attendee_param(&org, &addr("bob"), "PARTSTAT").as_deref(),
Some("ACCEPTED")
);
let replies = pim.inbox("alice").await;
assert_eq!(replies.len(), 1);
assert!(replies[0].contains("METHOD:REPLY"));
// carol's copy learns it quietly: same Schedule-Tag, no inbox entry.
let (_, carol) = pim.copy("carol").await;
assert_eq!(carol.header("schedule-tag"), carol_tag);
let seen = attendee_param(&carol.text(), &addr("bob"), "PARTSTAT");
assert_eq!(seen.as_deref(), Some("ACCEPTED"), "{}", carol.text());
assert_eq!(pim.inbox("carol").await.len(), 1);
// Deleting her copy declines for carol.
let (carol_href, _) = pim.copy("carol").await;
assert_eq!(
pim.req("carol", "DELETE", &carol_href, &[], "")
.await
.status,
StatusCode::NO_CONTENT
);
let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text();
assert_eq!(
attendee_param(&org, &addr("carol"), "PARTSTAT").as_deref(),
Some("DECLINED")
);
}
#[tokio::test]
async fn organizer_changes_reach_attendees() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob"), &addr("carol")]).await;
let (bob_href, got) = pim.copy("bob").await;
let accepted = unfold(&got.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
assert_eq!(
pim.req("bob", "PUT", &bob_href, &[], &accepted)
.await
.status,
StatusCode::NO_CONTENT
);
let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text();
assert_eq!(
attendee_param(&org, &addr("bob"), "PARTSTAT").as_deref(),
Some("ACCEPTED")
);
let bob_tag = pim.copy("bob").await.1.header("schedule-tag");
// Moving the meeting asks everyone again.
let moved = meeting("20260301T140000Z", &[&addr("bob"), &addr("carol")]);
assert_eq!(
pim.req("alice", "PUT", ALICE_EVENT, &[], &moved)
.await
.status,
StatusCode::NO_CONTENT
);
let (_, got) = pim.copy("bob").await;
let bob_copy = unfold(&got.text());
assert!(bob_copy.contains("DTSTART:20260301T140000Z"), "{bob_copy}");
let partstat = attendee_param(&bob_copy, &addr("bob"), "PARTSTAT");
assert_eq!(partstat.as_deref(), Some("NEEDS-ACTION"), "{bob_copy}");
assert_ne!(got.header("schedule-tag"), bob_tag);
assert_eq!(pim.inbox("bob").await.len(), 2);
// Dropping bob cancels his copy.
let without_bob = meeting("20260301T140000Z", &[&addr("carol")]);
pim.req("alice", "PUT", ALICE_EVENT, &[], &without_bob)
.await;
assert!(unfold(&pim.copy("bob").await.1.text()).contains("STATUS:CANCELLED"));
assert!(
pim.inbox("bob")
.await
.iter()
.any(|m| m.contains("METHOD:CANCEL"))
);
// Deleting the meeting cancels it for carol.
assert_eq!(
pim.req("alice", "DELETE", ALICE_EVENT, &[], "")
.await
.status,
StatusCode::NO_CONTENT
);
assert!(unfold(&pim.copy("carol").await.1.text()).contains("STATUS:CANCELLED"));
}
#[tokio::test]
async fn attendees_have_limits() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let (bob_href, got) = pim.copy("bob").await;
let bob_copy = unfold(&got.text());
let moved = bob_copy.replace("DTSTART:20260301T100000Z", "DTSTART:20260301T120000Z");
let r = pim.req("bob", "PUT", &bob_href, &[], &moved).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(CALDAV, "allowed-attendee-scheduling-object-change"));
let r = pim
.req(
"bob",
"PUT",
&bob_href,
&[("if-schedule-tag-match", "\"stale\"")],
&bob_copy,
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
let into_inbox = pim
.req(
"bob",
"PUT",
"/pim/calendars/bob/inbox/x.ics",
&[],
&bob_copy,
)
.await;
assert_eq!(into_inbox.status, StatusCode::FORBIDDEN);
// A silent removal answers nothing.
let r = pim
.req("bob", "DELETE", &bob_href, &[("schedule-reply", "F")], "")
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
assert!(!org.contains("DECLINED"), "{org}");
assert!(pim.inbox("alice").await.is_empty());
}
#[tokio::test]
async fn rooms_receive_bookings() {
let pim = Pim::new().await;
let r = pim
.admin
.post_json(
"/api/admin/rooms",
&json!({"name": "board", "display_name": "Board", "kind": "room"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
invite(&pim, &["mailto:board@rooms.filebrowser.invalid"]).await;
let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text();
let room = "mailto:board@rooms.filebrowser.invalid";
assert_eq!(
attendee_param(&org, room, "SCHEDULE-STATUS").as_deref(),
Some("1.2"),
"{org}"
);
// Everyone reads a room's bookings.
let members = pim.members("bob", "/pim/calendars/board/default/").await;
assert_eq!(members.len(), 1, "{members:?}");
}