CalDAV/CardDAV review fixes, round 5
- Outbox free-busy answers each principal once, at most 100 recipients, and 3.7 for disabled accounts - calendar-query expands an object once per window, not per component - Objects with more than one master per UID, a blank UID, a missing END:VCALENDAR or nested components are refused; COUNT is capped at 10,000 for sub-daily rules and 100,000 otherwise - Busy feeds write EXDATEs once; import drops cut-off components and stops with 413 when the split output passes 128 MiB - sync-collection stops at the expansion limit; a cut initial sync hands out a token that stays valid past pruned tombstones - The JSON API keeps the DAV limits on collections and object size - Disabled users stay scheduling recipients, so their copies stay current; import schedules like a PUT - A plain event with the same UID no longer blocks a meeting copy - Deleting a user rewrites addresses split by a line fold - THISANDFUTURE shifts keep the local time across DST; a new override at the shifted time is no move; real moves bump SEQUENCE; a cancelled range cancels the later instances; EXRULE changes reschedule; rules compare by structure - If-Match compares strongly; a DELETE of a missing object records no change; pim_props gets indexes (schema 16) - Web: recurrence words only where exact, a stable calendar toolbar that keeps focus, refresh after a failed reply, the series dialog shows the next instance, midnight ends, live calendar names and badge - Tests for each change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mpimdav/src/bundle.rs
@@ -123,8 +123,13 @@ pub fn calendar_meta(text: &str) -> (Option<String>, Option<String>) {
/// VERSION, PRODID and CALSCALE of the file and drops the other calendar
/// properties, METHOD among them. A component without UID gets
/// `new_uid(its text)`, and so does a component type that reuses the UID of
/// another type: one object holds one type only.
pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<String> {
/// another type: one object holds one type only. `None` when the objects
/// would pass `max_bytes` together: each one repeats the zones it names.
pub fn split_calendar(
text: &str,
new_uid: &mut dyn FnMut(&str) -> String,
max_bytes: usize,
) -> Option<Vec<String>> {
let mut header: Option<Vec<String>> = None;
let mut zones: HashMap<String, String> = HashMap::new();
// (uid, components, TZIDs they name), in file order.
@@ -143,7 +148,7 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
.map(|l| l.to_string())
.collect()
});
for c in children {
for c in children.into_iter().filter(Block::complete) {
if c.name == "VTIMEZONE" {
if let Some(id) = c.prop("TZID") {
zones.entry(id).or_insert_with(|| lines_text(&c.lines));
@@ -204,7 +209,19 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
for l in &header {
push_lines(&mut head, &[l.as_str()]);
}
groups
const END: &str = "END:VCALENDAR\r\n";
let size = |(_, components, tzids): &(String, String, HashSet<String>)| {
let zones: usize = tzids
.iter()
.filter_map(|id| zones.get(id))
.map(String::len)
.sum();
head.len() + zones + components.len() + END.len()
};
if groups.iter().map(size).sum::<usize>() > max_bytes {
return None;
}
let objects = groups
.into_iter()
.map(|(_, components, tzids)| {
let mut out = head.clone();
@@ -215,9 +232,10 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
out.push_str(z);
}
}
out + &components + "END:VCALENDAR\r\n"
out + &components + END
})
.collect()
.collect();
Some(objects)
}
/// Splits a vCard file into one text per card. A card without UID gets
@@ -226,6 +244,7 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
pub fn split_cards(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<String> {
top_blocks(text, "VCARD")
.into_iter()
.filter(Block::complete)
.map(|card| {
let text = lines_text(&card.lines);
if card.prop("UID").is_some_and(|u| !u.trim().is_empty()) {
@@ -255,6 +274,16 @@ struct Block<'a> {
}
impl Block<'_> {
/// Not cut off by the end of the text.
fn complete(&self) -> bool {
let depth = self.lines.iter().fold(0i64, |d, l| match name(l).as_str() {
"BEGIN" => d + 1,
"END" => d - 1,
_ => d,
});
self.lines.len() > 1 && depth == 0
}
/// The value of the first own property called `prop`, not one of a
/// nested component, unfolded and unescaped as TEXT.
fn prop(&self, prop: &str) -> Option<String> {
@@ -366,7 +395,7 @@ fn blocks_time(e: &Block) -> bool {
/// hold host names or mail addresses.
fn push_busy(out: &mut String, events: &[&Block]) {
let (kept, free): (Vec<&&Block>, Vec<&&Block>) = events.iter().partition(|e| blocks_time(e));
let exdates: Vec<String> = free
let mut exdates: Vec<String> = free
.iter()
.filter_map(|e| {
let (props, _) = split_level(inner(&e.lines));
@@ -376,6 +405,7 @@ fn push_busy(out: &mut String, events: &[&Block]) {
.map(exdate)
})
.collect();
// One master takes them. A second master of the same UID is invalid.
for e in kept {
out.push_str("BEGIN:VEVENT\r\nSUMMARY:Busy\r\n");
let (props, _) = split_level(inner(&e.lines));
@@ -393,8 +423,8 @@ fn push_busy(out: &mut String, events: &[&Block]) {
}
}
if master {
for x in &exdates {
out.push_str(x);
for x in std::mem::take(&mut exdates) {
out.push_str(&x);
}
}
out.push_str("END:VEVENT\r\n");
Mpimdav/src/expand.rs
@@ -413,10 +413,12 @@ fn expand_group(
}
let recurrence_id = recurs.then_some(member.utc);
// The latest THISANDFUTURE override before this instance moves it by
// the same offset and gives it the override's length.
// the same offset and gives it the override's length. The offset is
// wall-clock time, so later instances keep their local time across DST.
let instance = match future.iter().rev().find(|f| f.0 <= *key) {
Some((_, rid, t)) => {
let start = add(member.utc, t.start.utc() - *rid);
let shift = mz.to_local(t.start.utc()) - mz.to_local(*rid);
let start = mz.to_utc(add_local(member.local, shift));
Instance {
start,
end: t.end_at(t.start.zone.to_local(start), start),
Mpimdav/src/filter.rs
@@ -2,10 +2,13 @@
//! (RFC 6352, 10.5). Parsing and evaluation.
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::ops::Range;
use std::rc::Rc;
use calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendarParameterValue, ICalendarProperty, ICalendarRelated, ICalendarValue,
};
use calcard::vcard::{VCard, VCardVersion};
@@ -13,7 +16,7 @@ use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use unicode_normalization::UnicodeNormalization;
use xmltree::Element;
use crate::expand::{expand, stamp};
use crate::expand::{Expansion, expand, stamp};
use crate::freebusy::periods;
use crate::report::Refused;
use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text};
@@ -280,10 +283,19 @@ pub fn time_range(e: &Element) -> Result<TimeRange, Refused> {
/// Whether a calendar object matches `filter`. `floating` interprets values
/// without a zone.
pub fn matches_calendar(cal: &ICalendar, filter: &CompFilter, floating: &Zone) -> bool {
let alarm_reach = cal
.components
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VAlarm)
.filter_map(relative_reach)
.max()
.unwrap_or_default();
let ctx = Ctx {
cal,
zones: Zones::new(cal, floating.clone()),
floating,
alarm_reach,
expanded: RefCell::default(),
};
ctx.comp(None, filter)
}
@@ -292,9 +304,57 @@ struct Ctx<'a> {
cal: &'a ICalendar,
zones: Zones,
floating: &'a Zone,
/// The widest reach of any relative alarm, so all alarms share one
/// expansion.
alarm_reach: TimeDelta,
/// One expansion per window, not one per component.
expanded: RefCell<HashMap<TimeRange, Rc<Expansion>>>,
}
/// The repetitions of alarm `a` and the time between them.
fn repetitions(a: &ICalendarComponent) -> (i32, TimeDelta) {
let repeat = a
.property(&ICalendarProperty::Repeat)
.and_then(|e| e.values.first()?.as_integer())
.unwrap_or(0)
.clamp(0, 1000) as i32;
let every = match a
.property(&ICalendarProperty::Duration)
.and_then(|e| e.values.first())
{
Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(),
_ => TimeDelta::zero(),
};
(repeat, every)
}
/// How far from its instance a relative alarm, repetitions included, can
/// fire, plus one second. `None` for an absolute trigger.
fn relative_reach(a: &ICalendarComponent) -> Option<TimeDelta> {
let Some(ICalendarValue::Duration(d)) = a.property(&ICalendarProperty::Trigger)?.values.first()
else {
return None;
};
let (repeat, every) = repetitions(a);
Some(
d.to_time_delta()?
.abs()
.checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX))
.unwrap_or(TimeDelta::MAX)
.checked_add(&TimeDelta::seconds(1))
.unwrap_or(TimeDelta::MAX),
)
}
impl Ctx<'_> {
fn expand(&self, window: TimeRange) -> Rc<Expansion> {
self.expanded
.borrow_mut()
.entry(window.clone())
.or_insert_with(|| Rc::new(expand(self.cal, window, self.floating.clone())))
.clone()
}
fn comp(&self, parent: Option<usize>, f: &CompFilter) -> bool {
let children: Vec<usize> = match parent {
None => (!self.cal.components.is_empty())
@@ -350,11 +410,7 @@ impl Ctx<'_> {
// One second wider, so that the exact rules below decide the
// instances that only touch the range.
let second = TimeDelta::seconds(1);
let exp = expand(
self.cal,
add(r.start, -second)..add(r.end, second),
self.floating.clone(),
);
let exp = self.expand(add(r.start, -second)..add(r.end, second));
// Unknown instances may overlap, so the object stays in.
if exp.truncated {
return true;
@@ -413,18 +469,7 @@ impl Ctx<'_> {
let Some(trigger) = a.property(&ICalendarProperty::Trigger) else {
return false;
};
let repeat = a
.property(&ICalendarProperty::Repeat)
.and_then(|e| e.values.first()?.as_integer())
.unwrap_or(0)
.clamp(0, 1000) as i32;
let every = match a
.property(&ICalendarProperty::Duration)
.and_then(|e| e.values.first())
{
Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(),
_ => TimeDelta::zero(),
};
let (repeat, every) = repetitions(a);
let hit = |base: DateTime<Utc>| {
(0..=repeat).any(|k| {
let t = add(base, every.checked_mul(k).unwrap_or(TimeDelta::MAX));
@@ -449,17 +494,8 @@ impl Ctx<'_> {
};
let from_end = trigger.parameter(&ICalendarParameterName::Related)
== Some(&ICalendarParameterValue::Related(ICalendarRelated::End));
let reach = offset
.abs()
.checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX))
.unwrap_or(TimeDelta::MAX)
.checked_add(&TimeDelta::seconds(1))
.unwrap_or(TimeDelta::MAX);
let exp = expand(
self.cal,
add(r.start, -reach)..add(r.end, reach),
self.floating.clone(),
);
let exp =
self.expand(add(r.start, -self.alarm_reach)..add(r.end, self.alarm_reach));
if exp.truncated {
return true;
}
Mpimdav/src/itip.rs
@@ -10,7 +10,8 @@ use calcard::common::PartialDateTime;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry,
ICalendarMethod, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue,
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue, Uri,
ICalendarParticipationStatus, ICalendarProperty, ICalendarRecurrenceRule, ICalendarStatus,
ICalendarValue, ICalendarWeekday, Uri,
};
use chrono::{DateTime, TimeDelta, Utc};
use xmltree::Element;
@@ -20,7 +21,7 @@ use crate::filter::TimeRange;
use crate::freebusy::{Busy, Period, merge};
use crate::text::{fold, logical_lines, name, param_parts, unfold, value};
use crate::xml::{CALDAV, el};
use crate::zone::{Zone, Zones};
use crate::zone::{Zone, Zones, add_local};
/// Whether an address belongs to someone in particular.
pub type Is<'a> = &'a dyn Fn(&str) -> bool;
@@ -465,7 +466,7 @@ pub fn attend(
let start =
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
let shifted = key.map(|k| k + old.future(k).map_or(0, |(_, s)| s));
let shifted = key.map(|k| old.moved(k));
if key.is_none() || master.is_none() || start != shifted {
return Err(Refused::AttendeeChange);
}
@@ -656,9 +657,37 @@ pub fn receive(copy: Option<&ICalendar>, msg: &Message) -> Option<ICalendar> {
Some((msg_obj.instant(rid)?, rid.clone()))
})
.collect();
let range =
|rid: &ICalendarEntry| rid.parameter(&ICalendarParameterName::Range).cloned();
let hit = |k: i64| {
gone.iter()
.any(|(g, rid)| *g == k || (*g < k && range(rid).is_some()))
};
// A cancelled range stands for later instances too, which an
// EXDATE cannot cover: the override carries the range.
let mut missing = Vec::new();
for (k, rid) in gone.iter().filter(|_| !whole) {
let Some(r) = range(rid) else { continue };
let at = next.position(Some(*k)).or_else(|| {
let n = next.single(DateTime::from_timestamp(*k, 0)?, &Zone::Utc)?;
next.root.children.push(n);
Some(next.root.children.len() - 1)
});
let entry = at.and_then(|at| {
next.root.children[at]
.c
.entries
.iter_mut()
.find(|e| e.name == ICalendarProperty::RecurrenceId)
});
match entry {
Some(e) => set_param(e, ICalendarParameterName::Range, r),
None => missing.push(rid.clone()),
}
}
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)) {
if whole || key.is_some_and(hit) {
set_prop(
&mut c.c,
ICalendarProperty::Status,
@@ -667,10 +696,14 @@ pub fn receive(copy: Option<&ICalendar>, msg: &Message) -> Option<ICalendar> {
}
}
if !whole {
let missing: Vec<ICalendarEntry> = gone
missing.extend(
gone.into_iter()
.filter(|(k, rid)| range(rid).is_none() && !keys.contains(&Some(*k)))
.map(|(_, rid)| rid),
);
let missing: Vec<ICalendarEntry> = missing
.into_iter()
.filter(|(k, _)| !keys.contains(&Some(*k)))
.map(|(_, rid)| ICalendarEntry {
.map(|rid| ICalendarEntry {
name: ICalendarProperty::Exdate,
params: without(rid.params, &ICalendarParameterName::Range),
values: rid.values,
@@ -781,7 +814,14 @@ fn guard(
) -> ICalendar {
let old = old.map(Obj::new);
let mut next = Obj::new(new);
let master = old.as_ref().and_then(Obj::master);
// A new override takes the state of the THISANDFUTURE override that moves
// its instance, else of the master.
let base_of = |key: Option<i64>| {
let o = old.as_ref()?;
o.find(key)
.or_else(|| o.future(key?).map(|(n, _)| n))
.or(o.master())
};
let keys: Vec<Option<i64>> = next.comps().map(|c| next.key(&c.c)).collect();
let moved: Vec<bool> = next
.comps()
@@ -793,23 +833,25 @@ fn guard(
let start =
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
next.key(&c.c).is_none() || start != next.key(&c.c)
let key = next.key(&c.c);
key.is_none() || start != key.map(|k| o.moved(k))
}
},
None => true,
})
.collect();
let bumps: Vec<Option<i64>> = next
.comps()
let bumps: Vec<Option<i64>> = keys
.iter()
.zip(next.comps())
.zip(&moved)
.map(|(c, moved)| {
let oc = old.as_ref()?.find(next.key(&c.c))?;
.map(|((key, c), moved)| {
let oc = base_of(*key)?;
(*moved && sequence(&c.c) <= sequence(&oc.c)).then(|| sequence(&oc.c).saturating_add(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);
let base = base_of(*key);
for e in &mut c.c.entries {
match e.name {
ICalendarProperty::Organizer => {
@@ -871,18 +913,50 @@ fn rescheduled(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComp
let reinstated = !old
.times(oc, &ICalendarProperty::Exdate)
.is_subset(&new.times(nc, &ICalendarProperty::Exdate));
moved || reinstated || rules_grew(new, oc, nc)
// A changed or removed EXRULE can bring instances back; a new one cannot.
let exrules = (
rules(oc, &ICalendarProperty::Exrule),
rules(nc, &ICalendarProperty::Exrule),
);
let exrule_changed = !exrules.0.is_empty() && exrules.0 != exrules.1;
moved || reinstated || exrule_changed || rules_grew(new, oc, nc)
}
/// A rule in one form: BY lists sorted, INTERVAL=1 and WKST=MO as absent.
fn canonical(r: &ICalendarRecurrenceRule) -> ICalendarRecurrenceRule {
fn tidy<T: Ord>(v: &mut Vec<T>) {
v.sort();
v.dedup();
}
let mut r = r.clone();
tidy(&mut r.bysecond);
tidy(&mut r.byminute);
tidy(&mut r.byhour);
tidy(&mut r.byday);
tidy(&mut r.bymonthday);
tidy(&mut r.byyearday);
tidy(&mut r.byweekno);
tidy(&mut r.bymonth);
tidy(&mut r.bysetpos);
r.interval = r.interval.filter(|i| *i > 1);
r.wkst = r.wkst.filter(|w| *w != ICalendarWeekday::Monday);
r
}
fn rules(c: &ICalendarComponent, prop: &ICalendarProperty) -> Vec<ICalendarRecurrenceRule> {
let mut v: Vec<_> = c
.properties(prop)
.filter_map(|e| match e.values.first()? {
ICalendarValue::RecurrenceRule(r) => Some(canonical(r)),
_ => None,
})
.collect();
v.sort_by_key(|r| format!("{r:?}"));
v
}
fn rules_grew(obj: &Obj, 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 rules = |c| rules(c, &ICalendarProperty::Rrule);
let (o, n) = (rules(oc), rules(nc));
if o == n {
return false;
@@ -1146,7 +1220,10 @@ impl Obj {
Some(t) => t.to_string(),
None => format!("{v:?}"),
},
None => format!("{v:?}"),
None => match v {
ICalendarValue::RecurrenceRule(r) => format!("{:?}", canonical(r)),
_ => format!("{v:?}"),
},
}
}
@@ -1266,22 +1343,40 @@ impl Obj {
}
/// The latest THISANDFUTURE override at or before instance `key`, and
/// how far it moves its instances.
/// its key.
fn future(&self, key: i64) -> Option<(&Node, i64)> {
let (k, n) = self
.comps()
self.comps()
.filter(|c| is_range(&c.c))
.filter_map(|c| Some((self.key(&c.c)?, c)))
.filter(|(k, _)| *k <= key)
.max_by_key(|(k, _)| *k)?;
let start = self.instant(n.c.property(&ICalendarProperty::Dtstart)?)?;
Some((n, start - k))
.filter_map(|c| Some((c, self.key(&c.c)?)))
.filter(|(_, k)| *k <= key)
.max_by_key(|(_, k)| *k)
}
/// Where instance `key` starts once a THISANDFUTURE override moves it.
/// The move is wall-clock time in the zone of the series, as in expand.
fn moved(&self, key: i64) -> i64 {
let shifted = || {
let (n, k) = self.future(key)?;
let dtstart = n.c.property(&ICalendarProperty::Dtstart)?;
let start = self.instant(dtstart)?;
// A copy without its master still has the override's zone.
let series = self
.master()
.and_then(|m| m.c.property(&ICalendarProperty::Dtstart));
let zone = self.zones.get(series.unwrap_or(dtstart).tz_id());
let local = |t: i64| Some(zone.to_local(DateTime::from_timestamp(t, 0)?));
let shift = local(start)? - local(k)?;
Some(zone.to_utc(add_local(local(key)?, shift)).timestamp())
};
shifted().unwrap_or(key)
}
/// Whether the series in `cal` has an instance at `rid`, whose key is
/// `key`.
fn occurs(&self, cal: &ICalendar, rid: DateTime<Utc>, key: i64, floating: &Zone) -> bool {
let at = rid + TimeDelta::seconds(self.future(key).map_or(0, |(_, s)| s));
let Some(at) = DateTime::from_timestamp(self.moved(key), 0) else {
return false;
};
// A day either side: an all-day shift is whole days, not 24 hours.
let window = at - TimeDelta::days(1)..at + TimeDelta::days(1);
expand(cal, window, floating.clone())
@@ -1297,17 +1392,18 @@ impl Obj {
let form = master.c.property(&ICalendarProperty::Dtstart)?;
let id = self.recurrence_id(form, rid, floating)?;
let key = self.instant(&id)?;
let Some((base, shift)) = self.future(key) else {
let Some((base, _)) = self.future(key) else {
return Some(self.instance(master, &id, &id));
};
let at = self.moved(key);
let start = match form.values.first()?.as_partial_date_time()?.hour {
None => ICalendarEntry {
values: vec![ICalendarValue::PartialDateTime(Box::new(
PartialDateTime::from_date_timestamp(key + shift),
PartialDateTime::from_date_timestamp(at),
))],
..id.clone()
},
Some(_) => self.recurrence_id(form, rid + TimeDelta::seconds(shift), floating)?,
Some(_) => self.recurrence_id(form, DateTime::from_timestamp(at, 0)?, floating)?,
};
Some(self.instance(base, &id, &start))
}
@@ -1367,7 +1463,7 @@ impl Obj {
floating: &Zone,
) -> Option<(DateTime<Utc>, i64)> {
let form = self.master()?.c.property(&ICalendarProperty::Dtstart)?;
let from = rid + TimeDelta::seconds(self.future(key).map_or(0, |(_, s)| s));
let from = DateTime::from_timestamp(self.moved(key), 0)?;
[1, 32, 400, 3700].into_iter().find_map(|days| {
expand(cal, from..from + TimeDelta::days(days), floating.clone())
.instances
Mpimdav/src/object.rs
@@ -1,6 +1,10 @@
//! Validation of the calendar and address objects clients PUT.
use calcard::icalendar::{ICalendar, ICalendarComponentType, ICalendarProperty};
use std::collections::HashSet;
use calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarFrequency, ICalendarProperty, ICalendarValue,
};
use calcard::{Entry, Parser};
use chrono::{DateTime, Utc};
use xmltree::Element;
@@ -44,6 +48,12 @@ pub struct CalendarObject {
/// component types the collection takes.
pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::CalendarData)?;
// The parser accepts a body cut off before its END, and the store serves
// the body as it came.
let last = text.lines().rev().find(|l| !l.trim().is_empty());
if !last.is_some_and(|l| l.trim().eq_ignore_ascii_case("END:VCALENDAR")) {
return Err(Invalid::CalendarData);
}
let mut parser = Parser::new(text);
let Entry::ICalendar(cal) = parser.entry() else {
return Err(Invalid::CalendarData);
@@ -64,12 +74,29 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
if too_many_rules(&cal) {
return Err(Invalid::CalendarResource);
}
let mut found: Option<CalendarObject> = None;
for c in root
let scheduled = |t: &ICalendarComponentType| {
matches!(
t,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
};
let top = root
.component_ids
.iter()
.filter_map(|&id| cal.components.get(id as usize))
{
.filter_map(|&id| cal.components.get(id as usize));
// One nested inside another escapes the one-UID check below.
let all = cal
.components
.iter()
.filter(|c| scheduled(&c.component_type));
if all.count() != top.clone().filter(|c| scheduled(&c.component_type)).count() {
return Err(Invalid::CalendarResource);
}
let mut found: Option<CalendarObject> = None;
let mut masters = 0;
for c in top {
let component = match c.component_type {
ICalendarComponentType::VTimezone => continue,
ICalendarComponentType::VEvent => "VEVENT",
@@ -87,7 +114,16 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
if needs_start && !c.has_property(&ICalendarProperty::Dtstart) {
return Err(Invalid::CalendarData);
}
let uid = c.uid().ok_or(Invalid::CalendarResource)?;
let uid = c
.uid()
.filter(|u| !u.trim().is_empty())
.ok_or(Invalid::CalendarResource)?;
if !c.has_property(&ICalendarProperty::RecurrenceId) {
masters += 1;
if masters > 1 {
return Err(Invalid::CalendarResource);
}
}
match &found {
Some(f) if f.uid != uid || f.component != component => {
return Err(Invalid::CalendarResource);
@@ -133,15 +169,33 @@ fn too_deep(cal: &ICalendar) -> bool {
/// VTIMEZONEs can hold dozens of observances.
const MAX_RULES: usize = 4;
const MAX_ZONE_RULES: usize = 50;
/// A rule with COUNT expands from DTSTART on every query.
const MAX_COUNT: u32 = 100_000;
const MAX_COUNT_SUB_DAILY: u32 = 10_000;
fn too_many_rules(cal: &ICalendar) -> bool {
let mut zone_rules = 0;
for c in &cal.components {
let rules = c
let rules: Vec<_> = c
.entries
.iter()
.filter(|e| matches!(e.name, ICalendarProperty::Rrule | ICalendarProperty::Exrule))
.count();
.collect();
let costly = rules.iter().any(|e| match e.values.first() {
Some(ICalendarValue::RecurrenceRule(r)) => r.count.is_some_and(|n| {
n > match r.freq {
ICalendarFrequency::Secondly
| ICalendarFrequency::Minutely
| ICalendarFrequency::Hourly => MAX_COUNT_SUB_DAILY,
_ => MAX_COUNT,
}
}),
_ => false,
});
if costly {
return true;
}
let rules = rules.len();
match c.component_type {
ICalendarComponentType::Standard | ICalendarComponentType::Daylight => {
zone_rules += rules
@@ -164,7 +218,10 @@ pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
if !matches!(parser.entry(), Entry::Eof) {
return Err(Invalid::AddressData);
}
Ok(card.uid().map(str::to_string))
Ok(card
.uid()
.filter(|u| !u.trim().is_empty())
.map(str::to_string))
}
/// `data` with `DTSTAMP:<now>` inserted after the BEGIN line of each VEVENT,
@@ -176,7 +233,7 @@ pub fn with_dtstamp(data: &[u8], now: DateTime<Utc>) -> Option<Vec<u8>> {
let lines: Vec<&[u8]> = data.split_inclusive(|&b| b == b'\n').collect();
// (component, index of its BEGIN line, has DTSTAMP)
let mut open: Vec<(&[u8], usize, bool)> = Vec::new();
let mut missing = Vec::new();
let mut missing = HashSet::new();
for (i, line) in lines.iter().enumerate() {
if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') {
continue;
@@ -196,7 +253,7 @@ pub fn with_dtstamp(data: &[u8], now: DateTime<Utc>) -> Option<Vec<u8>> {
if let Some((comp, begin, false)) = open.pop()
&& STAMPED.iter().any(|s| comp.eq_ignore_ascii_case(s))
{
missing.push(begin);
missing.insert(begin);
}
} else if name.eq_ignore_ascii_case(b"DTSTAMP")
&& let Some(top) = open.last_mut()
Mpimdav/src/view.rs
@@ -200,7 +200,10 @@ pub fn instance_for(
.max()
.unwrap_or_default()
.max(TimeDelta::zero());
rid + low..rid + high + TimeDelta::seconds(1)
// A day each side: the move keeps wall-clock time, so a DST change
// between the instances shifts it by up to an hour.
let day = TimeDelta::days(1);
rid + low - day..rid + high + day
}
// An override, or an object that does not recur, starts at its DTSTART.
_ => {
Mpimdav/tests/bundle.rs
@@ -104,7 +104,7 @@ fn import_splits_by_uid_with_overrides_and_zones() {
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID;TZID=Europe/Berlin:20240102T100000\r\nDTSTART;TZID=Europe/Berlin:20240102T120000\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nDTSTART:20240106T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let parts = bundle::split_calendar(&file, &mut uids());
let parts = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap();
assert_eq!(parts.len(), 3);
let master = &parts[0];
assert_eq!(master.matches("BEGIN:VEVENT").count(), 2);
@@ -122,8 +122,9 @@ fn import_splits_by_uid_with_overrides_and_zones() {
// With a UID from the content, as the server makes it, a file holding
// everything twice gives the same objects.
let mut by_content = |t: &str| format!("len-{}", t.len());
let once = bundle::split_calendar(&file, &mut by_content);
let twice = bundle::split_calendar(&format!("{file}{file}"), &mut by_content);
let once = bundle::split_calendar(&file, &mut by_content, usize::MAX).unwrap();
let twice =
bundle::split_calendar(&format!("{file}{file}"), &mut by_content, usize::MAX).unwrap();
assert_eq!(twice, once);
}
@@ -131,7 +132,7 @@ fn import_splits_by_uid_with_overrides_and_zones() {
fn escaped_tzid_finds_its_zone() {
let file = "BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:(UTC+02:00) Athens\\, Bucharest\r\nEND:VTIMEZONE\r\n\
BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=\"(UTC+02:00) Athens, Bucharest\":20130412T150000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let parts = bundle::split_calendar(file, &mut uids());
let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap();
assert!(parts[0].contains("BEGIN:VTIMEZONE"), "{}", parts[0]);
assert_eq!(
bundle::calendar(&[&parts[0]], None, Detail::All)
@@ -168,7 +169,7 @@ fn quoted_tzid_keeps_its_zone() {
let file = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{zone}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=\"Work; late: shift\":20240101T100000\r\nRECURRENCE-ID;TZID=\"Work; late: shift\":20240101T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let objects = bundle::split_calendar(&file, &mut uids());
let objects = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap();
assert_eq!(objects.len(), 1);
assert!(objects[0].contains("TZID:Work\\; late: shift"));
}
@@ -199,7 +200,12 @@ fn byte_order_mark_is_ignored() {
"\u{feff}{}",
object("BEGIN:VEVENT\r\nUID:a\r\nEND:VEVENT\r\n")
);
assert_eq!(bundle::split_calendar(&file, &mut uids()).len(), 1);
assert_eq!(
bundle::split_calendar(&file, &mut uids(), usize::MAX)
.unwrap()
.len(),
1
);
assert_eq!(
bundle::calendar_meta("\u{feff}BEGIN:VCALENDAR\r\nNAME:N\r\nEND:VCALENDAR\r\n")
.0
@@ -220,7 +226,7 @@ fn shared_uid_across_types_splits_and_empty_uid_is_missing() {
BEGIN:VTODO\r\nUID:x\r\nRECURRENCE-ID:20240102T100000Z\r\nEND:VTODO\r\n\
BEGIN:VEVENT\r\nUID:\r\nDTSTART:20240103T100000Z\r\nEND:VEVENT\r\n",
);
let parts = bundle::split_calendar(&file, &mut uids());
let parts = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap();
assert_eq!(parts.len(), 3, "{parts:#?}");
assert!(parts[0].contains("BEGIN:VEVENT\r\nUID:x\r\n"));
assert_eq!(parts[1].matches("UID:new-1\r\n").count(), 2, "{}", parts[1]);
@@ -242,3 +248,27 @@ fn shared_uid_across_types_splits_and_empty_uid_is_missing() {
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:C\r\nUID:new-1\r\nEND:VCARD\r\n"
);
}
#[test]
fn busy_time_writes_exdates_once_and_import_drops_cut_parts() {
let events: String = (0..2)
.map(|h| format!("BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T1{h}0000Z\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n"))
.collect();
let free = "BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n";
let busy = bundle::calendar(&[&object(&format!("{events}{free}"))], None, Detail::Busy);
assert_eq!(busy.matches("EXDATE").count(), 1, "{busy}");
let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b\r\nSUMMARY:x\r\n";
let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap();
assert_eq!(parts.len(), 1, "{parts:?}");
assert!(parts[0].contains("UID:a"));
let cut = format!("{file}BEGIN:VALARM\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VCALENDAR\r\n");
assert_eq!(
bundle::split_calendar(&cut, &mut uids(), usize::MAX)
.unwrap()
.len(),
1
);
let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD\r\nBEGIN:VCARD\r\nFN:B\r\n";
assert_eq!(bundle::split_cards(cards, &mut uids()).len(), 1);
}
Mpimdav/tests/expand.rs
@@ -364,6 +364,24 @@ fn instance_for_finds_instances_moved_by_this_and_future() {
);
}
#[test]
fn instance_for_finds_an_instance_moved_across_dst() {
let body = event(
"a",
"DTSTART;TZID=Europe/Berlin:20240328T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=6\r\n",
) + &event(
"a",
"RECURRENCE-ID;RANGE=THISANDFUTURE;TZID=Europe/Berlin:20240330T100000\r\nDTSTART;TZID=Europe/Berlin:20240331T100000\r\nDURATION:PT1H\r\n",
);
let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
let cal = ICalendar::parse(&ics).unwrap();
// The instance of the 31st, 10:00, moved to the 1st, 10:00.
let rid = Some(utc("2024-03-31T08:00"));
let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap();
let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap();
assert_eq!(fmt(i.start), "2024-04-01T08:00");
}
#[test]
fn rdate_period_on_dtstart_keeps_its_length() {
let body = event(
@@ -390,3 +408,27 @@ fn a_rule_that_never_matches_stops_early() {
// Without the period cap it runs on to year 9999, seconds in a debug build.
assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
}
#[test]
fn a_this_and_future_move_across_dst_keeps_the_local_time() {
// From the 30th on, one day later. The move crosses the DST change, so in
// UTC it is 23 hours, but each later instance still starts at 10:00.
let body = event(
"a",
"DTSTART;TZID=Europe/Berlin:20240328T100000\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=6\r\n",
) + &event(
"a",
"RECURRENCE-ID;RANGE=THISANDFUTURE;TZID=Europe/Berlin:20240330T100000\r\nDTSTART;TZID=Europe/Berlin:20240331T100000\r\nDURATION:PT1H\r\n",
);
assert_eq!(
instances(&body, "2024-03-28T00:00", "2024-04-10T00:00"),
[
"2024-03-28T09:00/2024-03-28T10:00",
"2024-03-29T09:00/2024-03-29T10:00",
"2024-03-31T08:00/2024-03-31T09:00",
"2024-04-01T08:00/2024-04-01T09:00",
"2024-04-02T08:00/2024-04-02T09:00",
"2024-04-03T08:00/2024-04-03T09:00",
]
);
}
Mpimdav/tests/itip.rs
@@ -743,3 +743,167 @@ fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override()
text(&cancel.cal)
);
}
/// alice's weekly meeting, two hours later from the 12th on, with bob in it.
fn shifted_series() -> String {
format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
)
}
#[test]
fn a_new_override_at_the_shifted_time_is_no_move() {
let old = cal(&shifted_series());
let new = cal(&format!(
"{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
shifted_series()
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert_eq!(
store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
3,
"{store}"
);
assert!(!store.contains("SEQUENCE:1"), "{store}");
}
#[test]
fn a_copy_without_its_master_keeps_the_shift() {
let range = shifted_series()
.split_inclusive("END:VEVENT\n")
.nth(1)
.unwrap()
.to_string();
let old = cal(&range);
let new = cal(&format!(
"{range}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert_eq!(
store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
2,
"{store}"
);
}
#[test]
fn a_new_override_that_moves_bumps_the_sequence() {
let old = cal(&shifted_series());
let new = cal(&format!(
"{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T150000Z\n\
DTEND:20260119T160000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
shifted_series()
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert!(store.contains("SEQUENCE:1"), "{store}");
assert!(
store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
"{store}"
);
}
#[test]
fn a_changed_exrule_is_a_reschedule() {
let old = meeting(&format!(
"ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEXRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1\n"
));
let removed: String = text(&old)
.split_inclusive("\r\n")
.filter(|l| !l.starts_with("EXRULE"))
.collect();
assert_ne!(removed, text(&old));
let (store, _) = itip::organize(
Some(&old),
Some(ICalendar::parse(&removed).unwrap()),
&is(ALICE),
now(),
);
let store = text(&store.unwrap());
assert!(
store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
"{store}"
);
}
#[test]
fn a_rule_in_another_form_is_the_same_rule() {
let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))).replace(
"RRULE:FREQ=WEEKLY;COUNT=4",
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU;COUNT=4",
);
let old = ICalendar::parse(&old).unwrap();
// bob's client writes the rule back in its own form.
let new = text(&old)
.replace(
"RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
)
.replace(
&format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
&format!("PARTSTAT=ACCEPTED:{BOB}"),
);
assert_ne!(new, text(&old));
let (_, reply) = itip::attend(&old, ICalendar::parse(&new).unwrap(), &is(BOB), now()).unwrap();
assert!(reply.is_some());
// The same rewrite by the organizer keeps bob's answer.
let accepted = ICalendar::parse(text(&old).replace(
&format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
&format!("PARTSTAT=ACCEPTED:{BOB}"),
))
.unwrap();
let rewritten = text(&accepted).replace(
"RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
);
let (store, _) = itip::organize(
Some(&accepted),
Some(ICalendar::parse(&rewritten).unwrap()),
&is(ALICE),
now(),
);
assert!(
text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
"rule rewrite reset bob"
);
}
#[test]
fn a_cancelled_range_cancels_the_later_instances() {
use pimdav::expand::expand;
use pimdav::zone::Zone;
let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
let msg = Message {
to: BOB.to_string(),
method: Method::Cancel,
quiet: false,
cal: cal(&format!(
"METHOD:CANCEL\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
DTSTART:20260112T100000Z\nSEQUENCE:1\nSTATUS:CANCELLED\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
)),
};
use pimdav::calcard::icalendar::{ICalendarProperty, ICalendarStatus, ICalendarValue};
let got = itip::receive(Some(©), &msg).unwrap();
let mut out = expand(&got, at(1, 0)..at(31, 0), Zone::Utc).instances;
out.sort_by_key(|i| i.start);
let cancelled: Vec<bool> = out
.iter()
.map(|i| {
matches!(
got.components[i.component]
.property(&ICalendarProperty::Status)
.and_then(|e| e.values.first()),
Some(ICalendarValue::Status(ICalendarStatus::Cancelled))
)
})
.collect();
assert_eq!(cancelled, [false, true, true, true], "{}", text(&got));
}
Mpimdav/tests/protocol.rs
@@ -247,3 +247,53 @@ fn too_many_rules_are_refused() {
Err(Invalid::CalendarResource)
);
}
#[test]
fn a_rule_with_a_huge_count_is_refused() {
let with = |rule: &str| {
let event = EVENT.replace("END:VEVENT", &format!("{rule}\r\nEND:VEVENT"));
object::calendar(&ics(&event), &["VEVENT"])
};
assert!(with("RRULE:FREQ=DAILY;COUNT=100000").is_ok());
assert_eq!(
with("RRULE:FREQ=DAILY;COUNT=100001"),
Err(Invalid::CalendarResource)
);
assert!(with("RRULE:FREQ=MINUTELY;COUNT=10000").is_ok());
assert_eq!(
with("EXRULE:FREQ=SECONDLY;COUNT=10001"),
Err(Invalid::CalendarResource)
);
}
#[test]
fn one_master_per_object_and_no_blank_uid() {
let master2 = EVENT.replace("T100000Z\r\nEND", "T110000Z\r\nEND");
assert_eq!(
object::calendar(&ics(&format!("{EVENT}{master2}")), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let blank = EVENT.replace("UID:a", "UID: ");
assert_eq!(
object::calendar(&ics(&blank), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let card = b"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nUID: \r\nEND:VCARD\r\n";
assert_eq!(object::vcard(card), Ok(None));
}
#[test]
fn a_cut_or_nested_object_is_refused() {
let full = ics(EVENT);
let cut = &full[..full.len() - "END:VCALENDAR\r\n".len()];
assert_eq!(
object::calendar(cut, &["VEVENT"]),
Err(Invalid::CalendarData)
);
let inner = EVENT.replace("UID:a", "UID:b");
let nested = EVENT.replace("END:VEVENT", &format!("{inner}END:VEVENT"));
assert_eq!(
object::calendar(&ics(&nested), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
}
Mpimdav/tests/report.rs
@@ -462,3 +462,30 @@ fn text_match_ignores_pretty_printing() {
</c:text-match></c:prop-filter></c:comp-filter>"#;
assert!(hit(body, filter));
}
#[test]
fn many_overrides_expand_once_per_query() {
let mut body = String::from(
"BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\n",
);
for d in 0..2000 {
let day = chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap() + chrono::Days::new(d);
let t = day.format("%Y%m%dT100000Z");
body.push_str(&format!(
"BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID:{t}\r\nDTSTART:{t}\r\nSUMMARY:{d}\r\nBEGIN:VALARM\r\nTRIGGER:-PT{}M\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\n",
d % 50 + 1
));
}
let started = std::time::Instant::now();
assert!(!hit(
&body,
&range("VEVENT", "19990101T000000Z", "19990102T000000Z")
));
let alarm = r#"<c:comp-filter name="VEVENT"><c:comp-filter name="VALARM"><c:time-range start="19990101T000000Z" end="19990102T000000Z"/></c:comp-filter></c:comp-filter>"#;
assert!(!hit(&body, alarm));
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"{:?}",
started.elapsed()
);
}
Mserver/src/api/admin.rs
@@ -248,7 +248,7 @@ pub async fn delete_user(
/// The cancellations and declines for everything `p` owns. Hold the
/// scheduling lock.
async fn retract_all(state: &AppState, p: &PimPrincipal) -> Result<Vec<PimOp>, ApiError> {
let dir = pim_schedule::Directory::load(state).await?.with(p);
let dir = pim_schedule::Directory::load(state).await?;
let ids: Vec<i64> = state
.db
.pim_collections(p.id, PimKind::Calendar)
Mserver/src/api/pim.rs
@@ -52,10 +52,10 @@ use crate::db::{
use crate::error::{ApiError, AppState};
/// Largest object a PUT may store. Contacts carry photos inline.
const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
pub(super) const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
const MAX_SLUG: usize = 255;
const MAX_COLLECTIONS: usize = 100;
pub(super) const MAX_COLLECTIONS: usize = 100;
pub(super) const MAX_DISPLAYNAME: usize = 256;
pub(super) const MAX_DESCRIPTION: usize = 1024;
@@ -568,7 +568,7 @@ type MemberMap = std::collections::HashMap<String, (PimObject, Vec<u8>)>;
pub(super) async fn directory_collection(state: &AppState) -> Result<PimCollection, ApiError> {
let source: String = state
.db
.pim_principals()
.pim_principals(true)
.await?
.iter()
.map(|p| format!("{}\t{}\t{}\t{:?}\n", p.id, p.name, p.display(), p.kind))
@@ -585,7 +585,7 @@ pub(super) async fn directory_collection(state: &AppState) -> Result<PimCollecti
/// The members of the system address book: one card per visible principal.
pub(super) async fn directory(state: &AppState) -> Result<Members, ApiError> {
let mut members = Vec::new();
for p in state.db.pim_principals().await? {
for p in state.db.pim_principals(true).await? {
let uuid = principal_uuid(p.id);
let uid = format!("urn:uuid:{uuid}");
let addresses: [String; 0] = [];
@@ -922,7 +922,7 @@ impl Cx<'_> {
Target::Principals => {
list.push((format!("{PIM}/principals/"), Res::Principals));
if deep {
for p in self.state.db.pim_principals().await? {
for p in self.state.db.pim_principals(true).await? {
list.push((
principal_href(&p.name),
Res::Principal(PrincipalView::of(&p, self.me)),
@@ -1162,7 +1162,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
privileges(col.access),
supported_reports(*kind),
text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c.id, c.seq, None)),
text(
kind_ns(*kind),
"max-resource-size",
@@ -1241,7 +1241,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
(DAV, "sync-collection"),
]),
text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c.id, c.seq, None)),
]);
if let Some(v) = &c.displayname {
out.push(text(DAV, "displayname", v));
@@ -1423,9 +1423,13 @@ fn privilege_set<'a>(names: impl IntoIterator<Item = (&'a str, &'a str)>) -> Ele
}
/// Carries the collection id, so a token handed out for a deleted
/// collection never matches the one that later takes its URL.
fn sync_token(id: i64, seq: i64) -> String {
format!("urn:dovenest:sync:{id}-{seq}")
/// collection never matches the one that later takes its URL. A cut initial
/// sync also carries `issued`, the collection seq it began at.
fn sync_token(id: i64, seq: i64, issued: Option<i64>) -> String {
match issued {
Some(i) => format!("urn:dovenest:sync:{id}-{seq}.{i}"),
None => format!("urn:dovenest:sync:{id}-{seq}"),
}
}
fn content_type(kind: PimKind, component: &str) -> String {
@@ -2433,16 +2437,23 @@ impl Cx<'_> {
props,
limit,
} => {
let since = match token.is_empty() {
true => None,
let (since, issued) = match token.is_empty() {
true => (None, None),
false => match parse_sync_token(&token) {
// A generated collection has no change log: only its
// current token is valid.
Some((id, seq)) if id == col.id && generated(id) && seq == col.seq => {
Some(seq)
Some((id, seq, None))
if id == col.id && generated(id) && seq == col.seq =>
{
(Some(seq), None)
}
Some((id, seq)) if id == col.id && !generated(id) && seq <= col.seq => {
Some(seq)
Some((id, seq, issued))
if id == col.id
&& !generated(id)
&& seq <= col.seq
&& issued.is_none_or(|i| seq <= i && i <= col.seq) =>
{
(Some(seq), issued)
}
_ => return Ok(invalid_sync_token()),
},
@@ -2454,7 +2465,7 @@ impl Cx<'_> {
// only makes the next sync refetch a member.
let mut changes = match generated(col.id) {
true => Vec::new(),
false => match self.state.db.pim_changes(col.id, since).await? {
false => match self.state.db.pim_changes(col.id, since, issued).await? {
Some(c) => c,
None => return Ok(invalid_sync_token()),
},
@@ -2469,6 +2480,9 @@ impl Cx<'_> {
names.sort();
changes = names.into_iter().map(|n| (n, col.seq, false)).collect();
}
// The client of a cut initial sync saw nothing deleted before it
// began, so pruning up to there leaves its resume token valid.
let issued = issued.or(since.is_none().then_some(col.seq));
let truncated = limit.is_some_and(|n| changes.len() > n);
if let Some(n) = limit {
changes.truncate(n);
@@ -2481,7 +2495,7 @@ impl Cx<'_> {
(_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
};
let mut found = Vec::with_capacity(changes.len());
for (name, _, deleted) in changes {
for (name, change, deleted) in changes {
let hit = match (deleted, members.as_mut().and_then(|m| m.remove(&name))) {
(true, _) => None,
(false, Some(hit)) => Some(hit),
@@ -2491,12 +2505,20 @@ impl Cx<'_> {
}
(false, None) => None,
};
found.push((name, hit));
found.push((name, change, hit));
}
let slug = col.slug.clone();
let cuttable = !generated(col.id);
blocking(move || -> Reply {
let mut responses = Vec::new();
for (name, hit) in found {
let (mut responses, mut seq, mut truncated) = (Vec::new(), seq, truncated);
let mut last = seq;
for (name, change, hit) in found {
// Cut like a client limit: the token of the last change answered.
if cuttable && out.full() {
(seq, truncated) = (last, true);
break;
}
last = change;
responses.push(match hit {
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => r,
@@ -2510,9 +2532,10 @@ impl Cx<'_> {
if truncated {
responses.push(out.over_limit());
}
let token = sync_token(col.id, seq, issued.filter(|_| truncated));
Ok(multistatus(
&responses,
Some(with_text(el(DAV, "sync-token"), sync_token(col.id, seq))),
Some(with_text(el(DAV, "sync-token"), token)),
))
})
.await
@@ -2549,7 +2572,7 @@ impl Cx<'_> {
async fn principal_search(&self, search: &Search) -> Reply {
let mut responses = Vec::new();
let mut truncated = false;
for p in self.state.db.pim_principals().await? {
for p in self.state.db.pim_principals(true).await? {
let view = PrincipalView::of(&p, self.me);
let addresses = view.addresses();
let candidate = Principal {
@@ -2688,11 +2711,16 @@ fn too_many() -> Response<Body> {
error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
}
/// `(collection id, seq)` of a token [`sync_token`] made.
fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
/// `(collection id, seq, issued)` of a token [`sync_token`] made.
fn parse_sync_token(token: &str) -> Option<(i64, i64, Option<i64>)> {
let rest = token.strip_prefix("urn:dovenest:sync:")?;
let (rest, issued) = match rest.split_once('.') {
Some((r, i)) => (r, Some(i.parse().ok()?)),
None => (rest, None),
};
// The birthday calendar's id is negative.
let (id, seq) = token.strip_prefix("urn:dovenest:sync:")?.rsplit_once('-')?;
Some((id.parse().ok()?, seq.parse().ok()?))
let (id, seq) = rest.rsplit_once('-')?;
Some((id.parse().ok()?, seq.parse().ok()?, issued))
}
// ---------------------------------------------------------------------------
Mserver/src/api/pim_api.rs
@@ -32,6 +32,7 @@ use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG,
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use pimdav::bundle::{self, Detail};
use pimdav::principal::UserType;
use pimdav::{contact, object};
use sha2::{Digest, Sha256};
@@ -39,8 +40,9 @@ use crate::api::common::{SessionUser, blocking, hash_password, validate_password
use crate::api::dav::challenge;
use crate::api::files::disposition;
use crate::api::pim::{
BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, MAX_DESCRIPTION, MAX_DISPLAYNAME,
OUTBOX, SHARED_PREFIX, collection_href, delete_own, etag_of, generated, members_of, valid_text,
BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, MAX_COLLECTIONS, MAX_DESCRIPTION,
MAX_DISPLAYNAME, MAX_RESOURCE_SIZE, OUTBOX, SHARED_PREFIX, collection_href, delete_own,
etag_of, generated, mailto, members_of, valid_text,
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::api::pim_views;
@@ -51,6 +53,10 @@ use crate::error::{ApiError, AppState};
/// The largest file an import reads.
const MAX_IMPORT: usize = 20 * 1024 * 1024;
/// Largest total an import may split into. Each object carries a copy of
/// the time zones it names.
const MAX_SPLIT: usize = 128 * 1024 * 1024;
/// How many skipped objects an import names.
const MAX_SKIPPED: usize = 100;
@@ -250,6 +256,9 @@ async fn create_collection(
{
return Err(bad_request("invalid description"));
}
if state.db.pim_collections(pid, kind).await?.len() >= MAX_COLLECTIONS {
return Err(ApiError::new(StatusCode::FORBIDDEN, "too many collections"));
}
let components = match kind {
PimKind::Calendar if components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::Calendar => {
@@ -434,6 +443,9 @@ pub async fn share(
AxumPath(id): AxumPath<i64>,
Json(body): Json<CreatePimShare>,
) -> Result<Json<PimShareInfo>, ApiError> {
// PUT checks the access again under LOCK, so a narrower share applies at
// once. Under it, the collection cannot go before the share is written.
let _lock = pim_schedule::LOCK.lock().await;
let id = own(&state, &auth, id).await?;
let name = body.user.trim();
let found = match state.db.pim_principal(name).await? {
@@ -456,8 +468,6 @@ pub async fn share(
"a collection cannot be shared with its owner",
));
}
// PUT checks the access again under LOCK, so a narrower share applies at once.
let _lock = pim_schedule::LOCK.lock().await;
state.db.pim_set_share(id, user_id, body.mode).await?;
Ok(Json(PimShareInfo {
user_id,
@@ -934,7 +944,14 @@ fn split_import(kind: PimKind, text: &str) -> Result<Vec<String>, ApiError> {
// From the content, so importing the same file twice updates.
let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string();
let parts = match kind {
PimKind::Calendar => bundle::split_calendar(text, &mut new_uid),
PimKind::Calendar => {
bundle::split_calendar(text, &mut new_uid, MAX_SPLIT).ok_or_else(|| {
ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"the file splits into too much data",
)
})?
}
PimKind::AddressBook => bundle::split_cards(text, &mut new_uid),
};
if parts.is_empty() {
@@ -946,8 +963,8 @@ fn split_import(kind: PimKind, text: &str) -> Result<Vec<String>, ApiError> {
Ok(parts)
}
/// Scheduling objects need the owner or `rw+schedule`. Without it they are
/// skipped, as a PUT would refuse them.
/// Each part is stored as a PUT would store it, scheduling included. A part
/// a PUT would refuse is skipped.
async fn import_parts(
state: &AppState,
auth: &SessionUser,
@@ -973,11 +990,18 @@ async fn import_parts(
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let may_schedule = pim_views::may_answer(state, auth, owner, col.id).await?;
let me = state.db.principal_of(auth.user.id).await?;
let dir = Directory::load(state).await?;
let owner = dir
.get(owner)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let w = pim_schedule::Writer {
owner: &owner,
may_schedule,
sent_by: (owner.id != me)
.then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
};
let mut result = PimImportResult {
created: 0,
updated: 0,
@@ -1006,36 +1030,36 @@ async fn import_parts(
}
};
let existing = match names.get(&uid) {
Some(name) => Some(name.clone()),
Some(name) => {
// Scheduling reads the stored copy.
state.db.pim_apply(&std::mem::take(&mut ops)).await?;
Some(name.clone())
}
None => state.db.pim_uid_holder(col.id, &uid, "").await?,
};
let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind));
let schedule_tag = match kind {
let stored = match kind {
PimKind::Calendar => {
match pim_schedule::import_tag(state, &dir, &owner, (col.id, &name), &data).await? {
Ok(tag) => tag,
let old = match &existing {
Some(n) => state.db.pim_object(col.id, n).await?.map(|(_, d)| d),
None => None,
};
let at = (col.id, name.as_str());
match pim_schedule::put(state, &dir, &w, at, old.as_deref(), &data).await? {
Ok(s) => s,
Err(condition) => {
skip(Some(uid), &condition.name);
continue;
}
}
}
PimKind::AddressBook => None,
PimKind::AddressBook => pim_schedule::Stored {
data,
changed: false,
schedule_tag: None,
ops: Vec::new(),
},
};
if !may_schedule {
let replaces_scheduling = match &existing {
Some(n) => state
.db
.pim_object(col.id, n)
.await?
.is_some_and(|(o, _)| o.schedule_tag.is_some()),
None => false,
};
if schedule_tag.is_some() || replaces_scheduling {
skip(Some(uid), "need-privileges");
continue;
}
}
match existing {
Some(_) => updated += 1,
None => created += 1,
@@ -1047,12 +1071,17 @@ async fn import_parts(
name,
uid,
component,
etag: etag_of(&data),
schedule_tag,
etag: etag_of(&stored.data),
schedule_tag: stored.schedule_tag,
..Default::default()
},
data,
data: stored.data,
});
// Later parts see the copies and room bookings this one wrote.
if !stored.ops.is_empty() {
ops.extend(stored.ops);
state.db.pim_apply(&std::mem::take(&mut ops)).await?;
}
}
state.db.pim_apply(&ops).await?;
result.created = created;
@@ -1070,6 +1099,15 @@ fn check_part(
now: chrono::DateTime<chrono::Utc>,
part: String,
) -> Result<(String, String, Vec<u8>), Skip> {
// Read from the raw text when the object does not parse as a whole.
let raw_uid = |part: &str| {
part.lines()
.find_map(|l| l.strip_prefix("UID:"))
.map(|u| u.trim().to_string())
};
if part.len() > MAX_RESOURCE_SIZE {
return Err((raw_uid(&part), "max-resource-size".into()));
}
let checked = match kind {
PimKind::Calendar => {
object::calendar(part.as_bytes(), supported).map(|o| (o.uid, o.component.to_string()))
@@ -1078,14 +1116,7 @@ fn check_part(
object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
}
};
let (uid, component) = checked.map_err(|invalid| {
// Read from the raw text: the object did not parse as a whole.
let uid = part
.lines()
.find_map(|l| l.strip_prefix("UID:"))
.map(|u| u.trim().to_string());
(uid, invalid.condition().name)
})?;
let (uid, component) = checked.map_err(|invalid| (raw_uid(&part), invalid.condition().name))?;
let data = match kind {
PimKind::Calendar => {
object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
Mserver/src/api/pim_schedule.rs
@@ -8,7 +8,8 @@
//! Rooms and resources answer at once, from their own bookings. The outbox
//! answers free-busy requests from the recipients' calendars.
use std::collections::HashSet;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use percent_encoding::percent_decode_str;
@@ -49,6 +50,10 @@ const REFUSED: &str = "5.3";
/// sender's meeting (RFC 6638: no scheduling privileges).
const NO_AUTHORITY: &str = "3.8";
/// Recipients one free-busy request answers. Each costs an expansion of
/// their calendars.
const MAX_FREE_BUSY_ATTENDEES: usize = 100;
/// Who writes into a calendar, as far as scheduling cares.
pub(crate) struct Writer<'a> {
pub owner: &'a PimPrincipal,
@@ -93,22 +98,16 @@ fn found(p: Option<&PimPrincipal>) -> Recipient<'_> {
}
impl Directory {
/// Disabled accounts included: they cannot log in, but their copies stay
/// current.
pub(crate) async fn load(state: &AppState) -> Result<Self, ApiError> {
Ok(Directory(state.db.pim_principals().await?))
Ok(Directory(state.db.pim_principals(false).await?))
}
pub(crate) fn get(&self, id: i64) -> Option<&PimPrincipal> {
self.0.iter().find(|p| p.id == id)
}
/// With `p` added. [`Self::load`] leaves disabled accounts out.
pub(crate) fn with(mut self, p: &PimPrincipal) -> Self {
if self.get(p.id).is_none() {
self.0.push(p.clone());
}
self
}
/// 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<'_> {
@@ -316,29 +315,6 @@ pub(crate) async fn put(
}))
}
/// The Schedule-Tag an import stores with `body`, `None` for an object that
/// schedules nothing. An import sends no messages: the object is stored as
/// sent. `Err` names the precondition that refuses it.
pub(crate) async fn import_tag(
state: &AppState,
dir: &Directory,
owner: &PimPrincipal,
at: (i64, &str),
body: &[u8],
) -> Result<Result<Option<String>, Element>, ApiError> {
let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(body).as_ref()) else {
return Ok(Ok(None));
};
match itip::role(&cal, &dir.is(owner.id)) {
Err(refused) => Ok(Err(refused.condition())),
Ok(Role::None) => Ok(Ok(None)),
Ok(_) => Ok(match elsewhere(state, owner, &cal, at).await? {
Some(holder) => Err(holder),
None => Ok(Some(etag_of(body))),
}),
}
}
/// The resource name the server picks for an object it creates.
pub(crate) fn object_name(uid: &str, kind: PimKind) -> String {
let ext = match kind {
@@ -446,7 +422,8 @@ async fn elsewhere(
let Some((holder_id, holder, _)) = state.db.pim_find_uid(owner.id, &uid).await? else {
return Ok(None);
};
if holder_id == collection_id && holder.name == name {
// A plain event with the same UID schedules nothing.
if holder.schedule_tag.is_none() || (holder_id == collection_id && holder.name == name) {
return Ok(None);
}
let slug = match state.db.pim_collection_by_id(holder_id).await? {
@@ -560,11 +537,20 @@ pub(crate) async fn free_busy(
) -> Result<Vec<(String, &'static str, Option<String>)>, ApiError> {
let now = Utc::now();
let mut out = Vec::new();
for to in &req.attendees {
let mut known: HashMap<i64, Vec<Period>> = HashMap::new();
for (i, to) in req.attendees.iter().enumerate() {
let (status, data) = match dir.resolve(to) {
_ if i >= MAX_FREE_BUSY_ATTENDEES => ("5.1;Service unavailable", None),
// A disabled account still gets messages, but shows no busy time.
Recipient::Local(p) if !p.active => ("3.7;Invalid calendar user", None),
Recipient::Local(p) => {
let busy = busy_of(state, dir, p, &req.range, None).await?;
("2.0;Success", Some(freebusy::reply(&busy, req, to, now)))
if let Entry::Vacant(e) = known.entry(p.id) {
e.insert(busy_of(state, dir, p, &req.range, None).await?);
}
(
"2.0;Success",
Some(freebusy::reply(&known[&p.id], req, to, now)),
)
}
Recipient::Unknown => ("3.7;Invalid calendar user", None),
Recipient::External => ("5.2;Invalid calendar service", None),
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 = 15;
const SCHEMA_VERSION: i64 = 16;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -335,15 +335,24 @@ pub struct Precondition {
impl Precondition {
/// Whether the write may go ahead given the current ETag, if any.
pub fn allows(&self, current: Option<&str>) -> bool {
let listed = |header: &str| match current {
// If-Match compares strongly, If-None-Match weakly (RFC 9110, 13.1).
let listed = |header: &str, weak: bool| match current {
Some(etag) => header.split(',').any(|t| {
let t = t.trim();
t == "*" || t.strip_prefix("W/").unwrap_or(t) == etag
let t = if weak {
t.strip_prefix("W/").unwrap_or(t)
} else {
t
};
t == "*" || t == etag
}),
None => false,
};
self.if_match.as_deref().is_none_or(listed)
&& !self.if_none_match.as_deref().is_some_and(listed)
self.if_match.as_deref().is_none_or(|h| listed(h, false))
&& !self
.if_none_match
.as_deref()
.is_some_and(|h| listed(h, true))
}
}
@@ -639,6 +648,13 @@ impl Db {
"ALTER TABLE pim_collections ADD COLUMN pruned_seq INTEGER NOT NULL DEFAULT 0",
)?;
}
if version < 16 {
// The cascades from a deleted principal or collection.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_pim_props_principal ON pim_props(principal_id);
CREATE INDEX IF NOT EXISTS idx_pim_props_collection ON pim_props(collection_id);",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -1814,20 +1830,24 @@ impl Db {
/// `(name, seq, deleted)` of the members changed after `since`, oldest
/// first. Without `since`, the members that exist. `None` when pruning
/// removed deletions after `since`.
/// removed deletions after `since`, or after `issued` if given.
pub async fn pim_changes(
&self,
collection_id: i64,
since: Option<i64>,
issued: Option<i64>,
) -> DbResult<Option<Vec<(String, i64, bool)>>> {
let c = self.conn.lock().await;
if let Some(seq) = since {
let pruned: i64 = c.query_row(
"SELECT pruned_seq FROM pim_collections WHERE id = ?1",
[collection_id],
|r| r.get(0),
)?;
if seq < pruned {
if let Some(seq) = issued.or(since) {
let pruned: Option<i64> = c
.query_row(
"SELECT pruned_seq FROM pim_collections WHERE id = ?1",
[collection_id],
|r| r.get(0),
)
.optional()?;
// A collection deleted meanwhile has no valid token either.
if pruned.is_none_or(|p| seq < p) {
return Ok(None);
}
}
@@ -1933,10 +1953,12 @@ impl Db {
stmt.query_row([name], map_principal).optional()
}
pub async fn pim_principals(&self) -> DbResult<Vec<PimPrincipal>> {
/// Every principal, or with `visible_only` all but disabled accounts.
pub async fn pim_principals(&self, visible_only: bool) -> DbResult<Vec<PimPrincipal>> {
let c = self.conn.lock().await;
let filter = if visible_only { VISIBLE } else { "1" };
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE {VISIBLE} ORDER BY p.id"
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE {filter} ORDER BY p.id"
))?;
stmt.query_map([], map_principal)?.collect()
}
@@ -2194,6 +2216,7 @@ impl Db {
name: name.to_string(),
display_name: Some(display_name.to_string()),
kind,
active: true,
}))
}
@@ -2240,8 +2263,11 @@ impl Db {
needles: &[&str],
) -> DbResult<Vec<(i64, PimObject, Vec<u8>)>> {
let c = self.conn.lock().await;
// Unfolded first: a fold may split the address.
let flat = "replace(replace(replace(replace(lower(CAST(o.data AS TEXT)),
char(13, 10, 32), ''), char(13, 10, 9), ''), char(10, 32), ''), char(10, 9), '')";
let any: Vec<String> = (0..needles.len())
.map(|i| format!("instr(lower(CAST(o.data AS TEXT)), ?{}) > 0", i + 2))
.map(|i| format!("instr({flat}, ?{}) > 0", i + 2))
.collect();
let mut stmt = c.prepare(&format!(
"SELECT o.collection_id, {PIM_OBJECT_COLS_O}, o.data
@@ -2468,11 +2494,13 @@ fn apply_ops(tx: &rusqlite::Transaction, ops: &[PimOp]) -> DbResult<()> {
collection_id,
name,
} => {
tx.execute(
let gone = 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)?;
if gone > 0 {
record_pim_change(tx, *collection_id, name, true)?;
}
}
PimOp::Inbox {
principal_id,
@@ -2568,6 +2596,8 @@ pub struct PimPrincipal {
pub name: String,
pub display_name: Option<String>,
pub kind: UserType,
/// False for a disabled account.
pub active: bool,
}
impl PimPrincipal {
@@ -2576,7 +2606,9 @@ impl PimPrincipal {
}
}
const PRINCIPAL_COLS: &str = "p.id, p.user_id, p.name, p.display_name, p.kind";
// The last column repeats VISIBLE.
const PRINCIPAL_COLS: &str =
"p.id, p.user_id, p.name, p.display_name, p.kind, (p.user_id IS NULL OR u.active = 1)";
const PRINCIPALS: &str = "principals p LEFT JOIN users u ON u.id = p.user_id";
/// Disabled accounts are hidden.
const VISIBLE: &str = "(p.user_id IS NULL OR u.active = 1)";
@@ -2592,6 +2624,7 @@ fn map_principal(r: &rusqlite::Row) -> DbResult<PimPrincipal> {
"resource" => UserType::Resource,
_ => UserType::Individual,
},
active: r.get(5)?,
})
}
@@ -2987,6 +3020,18 @@ mod tests {
let p = db.pim_principal("legacy").await.unwrap().unwrap();
assert_eq!(p.user_id, Some(u.id));
assert_eq!(db.principal_of(u.id).await.unwrap(), p.id);
let indexes: i64 = db
.conn
.lock()
.await
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index'
AND name IN ('idx_pim_props_principal', 'idx_pim_props_collection')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(indexes, 2);
// Migrations are idempotent.
let db2 = Db::open(&path).await.unwrap();
assert_eq!(db2.user_count().await.unwrap(), 1);
Mserver/tests/api_pim.rs
@@ -475,6 +475,18 @@ async fn calendar_objects() {
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
// If-Match compares strongly: a weak tag never matches.
let weak = format!("W/{etag}");
let r = req(
&env,
"PUT",
&obj,
&auth,
&[("if-match", &weak)],
&event("a", "Two"),
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await);
let r = req(
@@ -881,6 +893,19 @@ async fn expand_answers_are_capped() {
let s = statuses(&r);
assert!(s.contains(&(CAL.to_string(), Some(507))), "{s:?}");
assert_eq!(s.len(), 4, "{s:?}");
// A sync stops at the same limit and resumes after its last member.
let sync = |token: &str| {
format!(
r#"<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level><d:prop>{expand}</d:prop></d:sync-collection>"#
)
};
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
let s = statuses(&r);
assert_eq!(s.len(), 4, "{s:?}");
assert_eq!(s[3], (CAL.to_string(), Some(507)));
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&sync_token_of(&r))).await;
assert_eq!(statuses(&r), [(format!("{CAL}h3.ics"), None)]);
}
#[tokio::test]
@@ -1689,8 +1714,54 @@ async fn pruned_tombstones_invalidate_old_sync_tokens() {
let (id, old_seq) = parse(&old);
let (_, mid_seq) = parse(&mid);
let db = &env.state.db;
assert_eq!(db.pim_changes(id, Some(old_seq)).await.unwrap(), None);
assert!(db.pim_changes(id, Some(mid_seq)).await.unwrap().is_some());
assert_eq!(db.pim_changes(id, Some(old_seq), None).await.unwrap(), None);
assert!(
db.pim_changes(id, Some(mid_seq), None)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn a_cut_initial_sync_resumes_past_pruned_tombstones() {
let (env, auth) = setup().await;
let sync = |token: &str| {
format!(
r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token>
<d:limit><d:nresults>1</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#
)
};
let obj = |n: &str| format!("{CAL}{n}.ics");
let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
put(&env, &auth, &obj("x"), &todo("x")).await;
for n in ["d1", "d2"] {
put(&env, &auth, &obj(n), &todo(n)).await;
req(&env, "DELETE", &obj(n), &auth, &[], "").await;
}
put(&env, &auth, &obj("y"), &todo("y")).await;
env.state.db.pim_prune(0).await.unwrap();
// The first page ends at x, below the pruned tombstones.
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
assert!(
r.text().contains("x.ics") && !r.text().contains("y.ics"),
"{}",
r.text()
);
let cut = sync_token_of(&r);
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&cut)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
assert!(r.text().contains("y.ics"), "{}", r.text());
// Deletions after the sync began still count: pruning them refuses it.
put(&env, &auth, &obj("z"), &todo("z")).await;
req(&env, "DELETE", &obj("z"), &auth, &[], "").await;
env.state.db.pim_prune(0).await.unwrap();
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&cut)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
}
#[tokio::test]
@@ -1792,7 +1863,11 @@ async fn moving_an_object_onto_itself_changes_nothing() {
assert_eq!(r, PimWrite::Updated);
let (_, data) = db.pim_object(col.id, "a.ics").await.unwrap().unwrap();
assert_eq!(data, b"data");
let changes = db.pim_changes(col.id, Some(before)).await.unwrap().unwrap();
let changes = db
.pim_changes(col.id, Some(before), None)
.await
.unwrap()
.unwrap();
assert!(changes.is_empty(), "{changes:?}");
}
@@ -1933,3 +2008,33 @@ async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500()
let r = pending.await.unwrap().unwrap();
assert_eq!(r.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn deleting_a_missing_object_records_no_change() {
use server::db::{PimKind, PimOp};
let (env, _) = setup().await;
let db = &env.state.db;
let user = db.find_user_by_name(ALICE).await.unwrap().unwrap();
let pid = db.principal_of(user.id).await.unwrap();
db.pim_ensure_defaults(pid).await.unwrap();
let seq = || async {
db.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap()
};
let before = seq().await;
let op = PimOp::Delete {
collection_id: before.id,
name: "missing.ics".into(),
};
db.pim_apply(&[op]).await.unwrap();
assert_eq!(seq().await.seq, before.seq);
assert!(
db.pim_changes(before.id, Some(before.seq), None)
.await
.unwrap()
.unwrap()
.is_empty()
);
}
Mserver/tests/api_pim_io.rs
@@ -281,7 +281,7 @@ async fn import_splits_and_updates() {
}
#[tokio::test]
async fn import_sends_nothing_and_keeps_uniqueness() {
async fn import_schedules_like_a_put_and_keeps_uniqueness() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let meeting = event(
@@ -301,12 +301,11 @@ async fn import_sends_nothing_and_keeps_uniqueness() {
.to_string();
let r = io.dav("alice", "GET", &href, "").await;
assert!(r.header("schedule-tag").is_some());
// bob got neither a copy nor a message.
// bob got a copy and the REQUEST, as after a PUT.
for path in ["/pim/calendars/bob/default/", "/pim/calendars/bob/inbox/"] {
let r = io.dav("bob", "PROPFIND", path, "").await;
assert!(!r.text().contains(".ics</"), "{path}: {}", r.text());
assert!(r.text().contains(".ics</"), "{path}: {}", r.text());
}
// One scheduling object per UID and owner (RFC 6638, 3.2.4.1).
io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
.await;
@@ -319,6 +318,22 @@ async fn import_sends_nothing_and_keeps_uniqueness() {
"unique-scheduling-object-resource"
);
// Imported again without bob, the meeting is cancelled for him.
let r = io.import(cal, &event("meet", "Meeting", "")).await;
assert_eq!(r["updated"], 1, "{r}");
let copy = io
.dav("bob", "PROPFIND", "/pim/calendars/bob/default/", "")
.await
.text();
let copy = copy
.split("<d:href>")
.filter_map(|s| s.split("</d:href>").next())
.find(|h| h.ends_with(".ics"))
.unwrap_or_else(|| panic!("{copy}"))
.to_string();
let r = io.dav("bob", "GET", ©, "").await;
assert!(r.text().contains("STATUS:CANCELLED"), "{}", r.text());
// A read-only loan cannot be imported into.
let bob = login(&io.env, "bob", PW).await;
io.alice
@@ -675,3 +690,79 @@ async fn an_import_racing_the_collection_delete_never_fails_with_500() {
);
}
}
#[tokio::test]
async fn the_web_api_keeps_the_dav_limits() {
let io = Io::new().await;
let cal = io.id(CAL).await;
// An object over the 10 MiB a PUT takes is skipped.
let big = event(
"big",
"Big",
&format!("DESCRIPTION:{}\r\n", "x".repeat(11 << 20)),
);
let r = io.import(cal, &(big + &event("small", "Small", ""))).await;
assert_eq!(r["created"], 1, "{r}");
assert_eq!(r["skipped"][0]["uid"], "big", "{r}");
// One large time zone copied into every event is refused up front.
let zone = format!(
"BEGIN:VTIMEZONE\r\nTZID:Big\r\n{}BEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n\
TZOFFSETFROM:+0000\r\nTZOFFSETTO:+0000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n",
format!("X-PAD:{}\r\n", "x".repeat(70)).repeat(15_000)
);
let events: String = (0..200)
.map(|i| {
format!(
"BEGIN:VEVENT\r\nUID:z{i}\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=Big:20260101T100000\r\nEND:VEVENT\r\n"
)
})
.collect();
let file = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{zone}{events}END:VCALENDAR\r\n"
);
let r = io
.alice
.raw(
Method::POST,
&format!("/api/pim/collections/{cal}/import"),
&[("content-type", "text/calendar")],
file.into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE, "{}", r.text());
// A zone no event names is not copied, and alarms are no objects.
let events: String = (0..150)
.map(|i| {
format!(
"BEGIN:VEVENT\r\nUID:a{i}\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260101T100000Z\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\n\
TRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VEVENT\r\n"
)
})
.collect();
let file = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{zone}{events}END:VCALENDAR\r\n"
);
let r = io.import(cal, &file).await;
assert_eq!(r["created"], 150, "{r}");
// No more collections than MKCOL allows.
let create = async |name: String| {
io.alice
.post_json(
"/api/pim/collections",
&json!({"kind": "addressbook", "name": name}),
)
.await
.status
};
let mut made = 0;
while create(format!("Book {made}")).await == StatusCode::OK {
made += 1;
assert!(made <= 100);
}
assert_eq!(create("One more".into()).await, StatusCode::FORBIDDEN);
}
Mserver/tests/api_pim_schedule.rs
@@ -1124,6 +1124,26 @@ async fn imports_on_a_plain_loan_skip_meetings() {
assert!(org.contains("DTSTART:20260301T100000Z"), "{org}");
}
#[tokio::test]
async fn deleting_a_user_forgets_an_address_split_by_a_fold() {
let pim = Pim::new().await;
// CLIENT: no copy for alice, so only the address rewrite reaches it.
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:fold-1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260301T100000Z\r\nDURATION:PT1H\r\n\
ORGANIZER:mailto:bob@dovenest.invalid\r\n\
ATTENDEE;SCHEDULE-AGENT=CLIENT:mailto:al\r\n ice@dovenest.invalid\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let href = "/pim/calendars/bob/default/fold.ics";
let r = pim.req("bob", "PUT", href, &[], ics).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let id = user_id(&pim.admin, "alice").await;
let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await;
assert_eq!(r.status, StatusCode::OK);
let copy = unfold(&pim.req("bob", "GET", href, &[], "").await.text());
assert!(!copy.contains("alice@dovenest.invalid"), "{copy}");
assert!(copy.contains("@deleted."), "{copy}");
}
#[tokio::test]
async fn deleting_an_organizer_cancels_and_forgets_it() {
let pim = Pim::new().await;
@@ -1268,3 +1288,105 @@ async fn find_uid_prefers_the_scheduling_copy() {
let (id, obj, _) = db.pim_find_uid(pid, "meet-1").await.unwrap().unwrap();
assert_eq!((id, obj.name.as_str()), (work.id, "meeting.ics"));
}
#[tokio::test]
async fn free_busy_answers_each_principal_once_and_caps_the_attendees() {
let pim = Pim::new().await;
let mut attendees = vec![addr("bob"), "/pim/principals/bob/".to_string()];
attendees.extend((0..100).map(|i| addr(&format!("nobody{i}"))));
let statuses = free_busy_statuses(&pim, &attendees).await;
assert_eq!(statuses.len(), 102);
assert!(statuses[0].starts_with("2.0") && statuses[1].starts_with("2.0"));
assert!(statuses[2].starts_with("3.7"), "{statuses:?}");
assert!(statuses[101].starts_with("5.1"), "{statuses:?}");
}
/// The REQUEST-STATUS per attendee of alice's free-busy request.
async fn free_busy_statuses(pim: &Pim, attendees: &[String]) -> Vec<String> {
let lines: String = attendees
.iter()
.map(|a| format!("ATTENDEE:{a}\r\n"))
.collect();
let body = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
BEGIN:VFREEBUSY\r\nUID:fb\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260310T000000Z\r\nDTEND:20260311T000000Z\r\nORGANIZER:{}\r\n\
{lines}END:VFREEBUSY\r\nEND:VCALENDAR\r\n",
addr("alice")
);
let r = pim
.req(
"alice",
"POST",
"/pim/calendars/alice/outbox/",
&[("content-type", "text/calendar")],
&body,
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| xml::text(xml::child(resp, CALDAV, "request-status").unwrap()))
.collect()
}
#[tokio::test]
async fn a_disabled_attendee_keeps_getting_updates_and_cancels() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let bob = user_id(&pim.admin, "bob").await;
let set_active = async |active: bool| {
let r = pim
.admin
.put_json(
&format!("/api/admin/users/{bob}"),
&json!({ "active": active }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
};
set_active(false).await;
// Free-busy treats bob as unknown while he is disabled.
let statuses = free_busy_statuses(&pim, &[addr("bob")]).await;
assert!(statuses[0].starts_with("3.7"), "{statuses:?}");
let moved = meeting("20260302T100000Z", &[&addr("bob")]);
let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &moved).await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
assert!(
attendee_param(&org, &addr("bob"), "SCHEDULE-STATUS").is_some_and(|s| s.starts_with('1')),
"{org}"
);
let r = pim.req("alice", "DELETE", ALICE_EVENT, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
set_active(true).await;
let (_, copy) = pim.copy("bob").await;
let copy = unfold(©.text());
assert!(copy.contains("DTSTART:20260302T100000Z"), "{copy}");
assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
}
#[tokio::test]
async fn a_plain_event_with_the_same_uid_does_not_block_a_meeting() {
let pim = Pim::new().await;
let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:meet-1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260301T100000Z\r\nDURATION:PT1H\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
pim.put_ok("alice", &format!("{}plain.ics", cal("alice")), plain)
.await;
let r = pim
.req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], "")
.await;
assert_eq!(r.status, StatusCode::CREATED);
let r = pim
.req(
"alice",
"PUT",
"/pim/calendars/alice/work/meet.ics",
&[],
&meeting("20260301T100000Z", &[&addr("bob")]),
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
Mweb/src/views/calendar.rs
@@ -217,7 +217,7 @@ pub fn when_on(s: &Span, day: i64) -> When {
When::Range(s.start_min, s.end_min)
} else if day == s.first {
When::From(s.start_min)
} else if day == s.last {
} else if day == s.last && s.end_min < 1440 {
When::Until(s.end_min)
} else {
When::AllDay
@@ -606,6 +606,7 @@ pub fn CalendarMain(
let invitations_seq = StoredValue::new(0u32);
Effect::new(move |_| {
tick.track();
collections.track();
let seq = invitations_seq.get_value() + 1;
invitations_seq.set_value(seq);
spawn_local(async move {
@@ -673,27 +674,46 @@ pub fn CalendarMain(
_ => 0,
})
};
let toolbar = move || {
let v = view.get();
let title = match v {
CalView::Month(y, m) => intl(
&js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, 1),
&[("month", "long"), ("year", "numeric")],
),
CalView::Agenda => i18n::t(k::PIM_UPCOMING).to_string(),
CalView::Invitations => i18n::t(k::PIM_INVITATIONS).to_string(),
};
let nav = match v {
CalView::Month(y, m) => Some(view! {
let is_month = Memo::new(move |_| matches!(view.get(), CalView::Month(..)));
// The month shown, or this month from another tab.
let this_month = move || match view.get_untracked() {
v @ CalView::Month(..) => v,
_ => {
let (y, m, _) = civil_from_days(today());
CalView::Month(y, m)
}
};
let step_month = move |step: i32| {
if let CalView::Month(y, m) = view.get_untracked() {
let (y, m) = add_months(y, m, step);
go(CalView::Month(y, m));
}
};
let title = move || match view.get() {
CalView::Month(y, m) => intl(
&js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, 1),
&[("month", "long"), ("year", "numeric")],
),
CalView::Agenda => i18n::t(k::PIM_UPCOMING).to_string(),
CalView::Invitations => i18n::t(k::PIM_INVITATIONS).to_string(),
};
let tab_of = |v: CalView| match v {
CalView::Month(..) => 0,
CalView::Agenda => 1,
CalView::Invitations => 2,
};
let selected = move |i: usize| tab_of(view.get()) == i;
let tab_at = move |i: usize| [this_month(), CalView::Agenda, CalView::Invitations][i];
// The buttons stay mounted across views, so a keyboard user keeps focus.
let toolbar = view! {
<div class="cal-toolbar">
{move || is_month.get().then(|| view! {
<div class="cal-nav">
<button
class="icon-btn icon-btn-sm"
title=i18n::t(k::PIM_PREV_MONTH)
aria-label=i18n::t(k::PIM_PREV_MONTH)
on:click=move |_| {
let (y, m) = add_months(y, m, -1);
go(CalView::Month(y, m));
}
on:click=move |_| step_month(-1)
>
{icon_svg(IconName::ChevronRight, "ic-btn flip")}
</button>
@@ -710,95 +730,49 @@ pub fn CalendarMain(
class="icon-btn icon-btn-sm"
title=i18n::t(k::PIM_NEXT_MONTH)
aria-label=i18n::t(k::PIM_NEXT_MONTH)
on:click=move |_| {
let (y, m) = add_months(y, m, 1);
go(CalView::Month(y, m));
}
on:click=move |_| step_month(1)
>
{icon_svg(IconName::ChevronRight, "ic-btn")}
</button>
</div>
}),
_ => None,
};
let this_month = match v {
CalView::Month(..) => v,
_ => {
let (y, m, _) = civil_from_days(today());
CalView::Month(y, m)
}
};
view! {
<div class="cal-toolbar">
{nav}
<h2 class="cal-title">{title}</h2>
<div class="cal-views" role="tablist" on:keydown=move |ev| {
let step = match ev.key().as_str() {
"ArrowRight" => 1,
"ArrowLeft" => -1,
_ => return,
};
ev.prevent_default();
let tabs = [this_month, CalView::Agenda, CalView::Invitations];
let at = tabs
.iter()
.position(|t| std::mem::discriminant(t) == std::mem::discriminant(&v))
.unwrap_or(0);
let to = (at as i32 + step).rem_euclid(3) as usize;
go(tabs[to]);
// The toolbar is rebuilt by the view change.
request_animation_frame(move || {
let tab = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(TAB_IDS[to]))
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
if let Some(tab) = tab {
let _ = tab.focus();
}
});
}>
<button
role="tab"
id=TAB_IDS[0]
aria-controls="cal-panel"
tabindex=if matches!(v, CalView::Month(..)) { "0" } else { "-1" }
class="cal-view-btn"
class:active=matches!(v, CalView::Month(..))
aria-selected=matches!(v, CalView::Month(..)).to_string()
on:click=move |_| go(this_month)
>
{i18n::t(k::PIM_MONTH)}
</button>
<button
role="tab"
id=TAB_IDS[1]
aria-controls="cal-panel"
tabindex=if v == CalView::Agenda { "0" } else { "-1" }
class="cal-view-btn"
class:active=v == CalView::Agenda
aria-selected=(v == CalView::Agenda).to_string()
on:click=move |_| go(CalView::Agenda)
>
{i18n::t(k::PIM_AGENDA)}
</button>
})}
<h2 class="cal-title">{title}</h2>
<div class="cal-views" role="tablist" on:keydown=move |ev| {
let step = match ev.key().as_str() {
"ArrowRight" => 1,
"ArrowLeft" => -1,
_ => return,
};
ev.prevent_default();
let to = (tab_of(view.get_untracked()) as i32 + step).rem_euclid(3) as usize;
go(tab_at(to));
let tab = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(TAB_IDS[to]))
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
if let Some(tab) = tab {
let _ = tab.focus();
}
}>
{(0..3).map(|i| view! {
<button
role="tab"
id=TAB_IDS[2]
id=TAB_IDS[i]
aria-controls="cal-panel"
tabindex=if v == CalView::Invitations { "0" } else { "-1" }
tabindex=move || if selected(i) { "0" } else { "-1" }
class="cal-view-btn"
class:active=v == CalView::Invitations
aria-selected=(v == CalView::Invitations).to_string()
on:click=move |_| go(CalView::Invitations)
class:active=move || selected(i)
aria-selected=move || selected(i).to_string()
on:click=move |_| go(tab_at(i))
>
{i18n::t(k::PIM_INVITATIONS)}
{move || (pending() > 0).then(|| view! {
{i18n::t([k::PIM_MONTH, k::PIM_AGENDA, k::PIM_INVITATIONS][i])}
{(i == 2).then_some(move || (pending() > 0).then(|| view! {
<span class="cal-badge">{pending()}</span>
})}
}))}
</button>
</div>
}).collect_view()}
</div>
}
</div>
};
let body = move || match view.get() {
@@ -822,11 +796,7 @@ pub fn CalendarMain(
<div
id="cal-panel"
role="tabpanel"
aria-labelledby=move || TAB_IDS[match view.get() {
CalView::Month(..) => 0,
CalView::Agenda => 1,
CalView::Invitations => 2,
}]
aria-labelledby=move || TAB_IDS[tab_of(view.get())]
>
{body}
</div>
@@ -1124,8 +1094,9 @@ fn AgendaView(
}
}
/// Answer an invitation, then refresh the lists. `busy` stays set on success;
/// `done` ends it once the refresh has landed.
/// Answer an invitation, then refresh the lists, also after a failure: the
/// invitation may have changed elsewhere. `busy` stays set on success; `done`
/// ends it once the refresh has landed.
fn reply(
toast: ToastMsg,
busy: RwSignal<bool>,
@@ -1153,6 +1124,7 @@ fn reply(
Err(e) => {
let _ = busy.try_set(false);
show_error(toast, e.to_string());
let _ = done.try_run(());
}
}
});
@@ -1214,14 +1186,18 @@ fn InvitationsView(
}
.into_any(),
Some(Ok(list)) => {
let names: HashMap<i64, String> = collections.with_untracked(|c| {
let names: HashMap<i64, String> = collections.with(|c| {
c.iter().flatten().map(|c| (c.id, display_name(c))).collect()
});
list.into_iter()
.map(|inv| {
// A series opens at its next instance, the one listed.
let o = Opened {
collection_id: inv.collection_id,
recurrence_id: inv.recurrence_id.clone(),
recurrence_id: inv
.recurrence_id
.clone()
.or_else(|| inv.recurring.then(|| inv.start.clone())),
name: inv.name.clone(),
};
let span = span_of(&inv_instance(&inv));
@@ -1333,7 +1309,9 @@ fn rrule_words(
let yearwide = freq == Some("YEARLY")
&& part("BYMONTH").is_none()
&& (part("BYDAY").is_some() || part("BYMONTHDAY").is_some());
if !all_worded || yearwide {
// BYDAY with BYMONTHDAY is an intersection; listing both reads as a union.
let both = part("BYDAY").is_some() && part("BYMONTHDAY").is_some();
if !all_worded || yearwide || both {
return None;
}
let mut on = Vec::new();
@@ -1345,7 +1323,10 @@ fn rrule_words(
let (nth, code) = d.split_at_checked(split)?;
let name = day(code)?;
on.push(match nth {
"" => name,
// A plain weekday outside WEEKLY means every such day of the
// period, which "Every month: Mon" does not say.
"" if freq == Some("WEEKLY") => name,
"" => return None,
_ if !numbered => return None,
nth => i18n::t_fmt2(k::PIM_RRULE_NTH, ordinal(nth)?, &name),
});
@@ -1493,11 +1474,14 @@ fn EventDialog(
load();
// For one instance of a series: answer it alone, or the whole series.
let only_this = RwSignal::new(true);
let collection = collections.with_untracked(|c| {
c.iter()
.flatten()
.find(|c| c.id == target.collection_id)
.map(|c| (display_name(c), safe_color(c.color.as_deref())))
let collection_id = target.collection_id;
let collection = Memo::new(move |_| {
collections.with(|c| {
c.iter()
.flatten()
.find(|c| c.id == collection_id)
.map(|c| (display_name(c), safe_color(c.color.as_deref())))
})
});
let body = move || match detail.get() {
None => view! { <p class="muted">{i18n::t(k::LOADING)}</p> }.into_any(),
@@ -1566,7 +1550,7 @@ fn EventDialog(
<dt>{i18n::t(k::PIM_LOCATION)}</dt>
<dd class="cal-pre">{linkified(&l)}</dd>
})}
{collection.clone().map(|(n, c)| view! {
{collection.get().map(|(n, c)| view! {
<dt>{i18n::t(k::PIM_CALENDAR)}</dt>
<dd><span class="cal-dot" style=format!("--c: {c}")></span>" "{n}</dd>
})}
@@ -1830,6 +1814,11 @@ mod tests {
"FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO",
"FREQ=DAILY;BYHOUR=9,17",
"FREQ=WEEKLY;X-FOO=1",
"FREQ=MONTHLY;BYDAY=MO",
"FREQ=YEARLY;BYMONTH=3;BYDAY=SU",
"FREQ=DAILY;BYDAY=MO,TU",
"FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13",
"FREQ=MONTHLY;BYDAY=1FR;BYMONTHDAY=1,2,3",
] {
assert_eq!(words(raw), None, "{raw}");
}
@@ -1872,6 +1861,9 @@ mod tests {
assert_eq!(when_on(&s, 10), When::From(1320));
assert_eq!(when_on(&s, 11), When::AllDay);
assert_eq!(when_on(&s, 12), When::Until(540));
// Ending at midnight, the last day runs to its end.
let s = Span { end_min: 1440, ..s };
assert_eq!(when_on(&s, 12), When::AllDay);
assert_eq!(when_on(&timed(5, 600), 5), When::Range(600, 660));
assert_eq!(when_on(&all_day(5, 5), 5), When::AllDay);
}