CalDAV/CardDAV review fixes, round 4
- Deleting a disabled user retracts its meetings; the cancellations, address rewrites and the delete commit in one transaction - Multiget answers each href once and stops at 1000 hrefs or 32 MiB - Room auto-answer merges busy periods and searches them; above 100 conflicts it declines the series; a conflict inside a THISANDFUTURE override declines only that instance - Rules stop after rrule's period cap; objects with more than 4 rules per component or 50 zone rules are refused - The sync token check and the change read share one lock - Every PIM writer checks the collection and access under LOCK; share changes and PROPPATCH take it too, PROPPATCH after reading the body - A leading byte order mark is ignored; components of different types that share a UID import as separate objects; an empty UID is missing - Collection names and descriptions refuse control characters - SCHEDULE-AGENT=CLIENT gets no CANCEL; attendee state of a moved instance comes from its range override; SEQUENCE saturates; replies to unknown instances get 404; RDATE periods keep their length; a DATE UNTIL covers its day - Feed and share mount passwords are trimmed on both sides; feeds of a disabled owner stop; loans to a disabled user take a new mode - nresults 0 means no limit in principal search - Contacts search keeps its query when it hides the open contact; recurrence text shows COUNT, UNTIL and numbered days, raw otherwise; sequence guards survive a closed dialog - Tests for each change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mpimdav/src/bundle.rs
@@ -122,13 +122,16 @@ pub fn calendar_meta(text: &str) -> (Option<String>, Option<String>) {
/// overrides with their master, each with the VTIMEZONEs it names. Keeps
/// 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)`.
/// `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> {
let mut header: Option<Vec<String>> = None;
let mut zones: HashMap<String, String> = HashMap::new();
// (uid, components, TZIDs they name), in file order.
let mut groups: Vec<(String, String, HashSet<String>)> = Vec::new();
let mut by_uid: HashMap<String, usize> = HashMap::new();
// By the UID in the file and the component type.
let mut by_uid: HashMap<(String, String), usize> = HashMap::new();
let mut file_uids: HashSet<String> = HashSet::new();
// A file that holds the same component twice keeps one copy.
let mut seen: HashSet<String> = HashSet::new();
for cal in top_blocks(text, "VCALENDAR") {
@@ -152,27 +155,38 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
.iter()
.filter_map(|l| param(&unfold(l), "TZID"))
.collect();
let (uid, text) = match c.prop("UID") {
Some(uid) => (uid, lines_text(&c.lines)),
None => {
let uid = new_uid(&lines_text(&c.lines));
let file_uid = c.prop("UID").filter(|u| !u.trim().is_empty());
let group = file_uid
.as_ref()
.and_then(|u| by_uid.get(&(u.clone(), c.name.clone())).copied());
let uid = match (&file_uid, group) {
(_, Some(i)) => groups[i].0.clone(),
(Some(u), None) if !file_uids.contains(u) => u.clone(),
_ => new_uid(&lines_text(&c.lines)),
};
let text = match file_uid.as_ref() == Some(&uid) {
true => lines_text(&c.lines),
false => {
let rest = without_own(&c.lines, "UID");
let mut text = String::new();
push_lines(&mut text, &c.lines[..1]);
push_lines(&mut text, &rest[..1]);
text.push_str(&format!("UID:{uid}\r\n"));
push_lines(&mut text, &c.lines[1..]);
(uid, text)
push_lines(&mut text, &rest[1..]);
text
}
};
if !seen.insert(text.clone()) {
continue;
}
match by_uid.get(&uid) {
Some(&i) => {
match group {
Some(i) => {
groups[i].1.push_str(&text);
groups[i].2.extend(tzids);
}
None => {
by_uid.insert(uid.clone(), groups.len());
let key = file_uid.unwrap_or_else(|| uid.clone());
file_uids.insert(key.clone());
by_uid.insert((key, c.name.clone()), groups.len());
groups.push((uid, text, tzids));
}
}
@@ -214,12 +228,13 @@ pub fn split_cards(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<S
.into_iter()
.map(|card| {
let text = lines_text(&card.lines);
if card.prop("UID").is_some() {
if card.prop("UID").is_some_and(|u| !u.trim().is_empty()) {
return text;
}
let (body, end) = match card.lines.split_last() {
let lines = without_own(&card.lines, "UID");
let (body, end) = match lines.split_last() {
Some((last, body)) if name(last) == "END" => (body, Some(*last)),
_ => (&card.lines[..], None),
_ => (&lines[..], None),
};
let mut out = String::new();
push_lines(&mut out, body);
@@ -251,6 +266,24 @@ impl Block<'_> {
}
}
/// A component's lines without its own `prop` lines. Nested components keep
/// theirs.
fn without_own<'a>(lines: &[&'a str], prop: &str) -> Vec<&'a str> {
let mut depth = 0usize;
let mut out = Vec::with_capacity(lines.len());
for &line in lines {
let n = name(line);
match n.as_str() {
"BEGIN" => depth += 1,
"END" => depth = depth.saturating_sub(1),
_ if depth == 1 && n == prop => continue,
_ => {}
}
out.push(line);
}
out
}
/// `lines` without their BEGIN and END line.
fn inner<'a, 'b>(lines: &'b [&'a str]) -> &'b [&'a str] {
let start = usize::from(lines.first().is_some_and(|l| name(l) == "BEGIN"));
Mpimdav/src/expand.rs
@@ -333,8 +333,13 @@ fn expand_group(
if let Some(l) = &length {
slack = slack.max(l.max());
}
set.entry(m.key(&s))
.or_insert(Member { local, utc, length });
// A period on DTSTART or a rule instance still sets its length.
let member = set.entry(m.key(&s)).or_insert(Member {
local,
utc,
length: None,
});
member.length = length.or(member.length.take());
}
}
for (_, rid, t) in &future {
@@ -676,7 +681,8 @@ pub(crate) fn occurrences(
r.build(wall(start))
.ok()
.filter(|_| valid)
.map(|set| (&set).into_iter())
// Stops a rule that never matches after 100k periods, not at year 9999.
.map(|set| (&set.limit()).into_iter())
.into_iter()
.flatten()
.map(|t| t.naive_utc())
Mpimdav/src/itip.rs
@@ -17,7 +17,7 @@ use xmltree::Element;
use crate::expand::expand;
use crate::filter::TimeRange;
use crate::freebusy::Period;
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};
@@ -167,6 +167,10 @@ pub fn answer_horizon(copy: &ICalendar) -> TimeDelta {
TimeDelta::days(if endless { 731 } else { 3653 })
}
/// Conflicting instances of a series a room declines one by one. Beyond
/// that it declines the series.
const MAX_DECLINED_INSTANCES: usize = 100;
/// The answer of a room or resource to the invitation in its copy:
/// ACCEPTED, and DECLINED where an instance in `window` overlaps `taken`. A
/// declined instance of a series gets an override of its own.
@@ -177,36 +181,65 @@ pub fn auto_answer(
window: &TimeRange,
floating: &Zone,
) -> ICalendar {
let taken = merge(
taken
.iter()
.map(|p| Period {
kind: Busy::Busy,
start: p.start,
end: p.end,
})
.collect(),
);
let conflicts = |s: DateTime<Utc>, e: DateTime<Utc>| {
taken.iter().any(|p| match s == e {
true => p.start <= s && s < p.end,
false => s < p.end && e > p.start,
})
let i = taken.partition_point(|p| p.end <= s);
taken
.get(i)
.is_some_and(|p| p.start < e || (s == e && p.start <= s))
};
let mut obj = Obj::new(copy);
let mut declined: Vec<Option<i64>> = Vec::new();
let mut instances: Vec<DateTime<Utc>> = Vec::new();
let form = obj
.master()
.and_then(|m| m.c.property(&ICalendarProperty::Dtstart))
.cloned();
let mut declined: HashSet<Option<i64>> = HashSet::new();
let mut slots: Vec<DateTime<Utc>> = Vec::new();
for x in expand(copy, window.clone(), floating.clone()).instances {
if !conflicts(x.start, x.end) {
continue;
}
match (obj.key(©.components[x.component]), x.recurrence_id) {
(None, Some(rid)) => instances.push(rid),
(key, _) => declined.push(key),
match x.recurrence_id {
Some(rid) if form.is_some() => slots.push(rid),
_ => {
declined.insert(obj.key(©.components[x.component]));
}
}
}
if let Some(master) = obj.master().cloned() {
for rid in instances {
if let Some(entry) = obj.recurrence_id(&master, rid, floating) {
let inst = obj.instance(&master, &entry);
// One override each would bloat the copy and the REPLY.
let all = slots.len() > MAX_DECLINED_INSTANCES;
if let (false, Some(form)) = (all, &form) {
// In order, so a range moved on past one instance can move again.
slots.sort_unstable();
for rid in slots {
let Some(key) = obj
.recurrence_id(form, rid, floating)
.and_then(|id| obj.instant(&id))
else {
continue;
};
if obj.find(Some(key)).is_some() {
obj.narrow(copy, rid, key, floating);
} else if let Some(inst) = obj.single(rid, floating) {
obj.root.children.push(inst);
declined.push(obj.key(&obj.root.children.last().expect("pushed").c));
} else {
continue;
}
declined.insert(Some(key));
}
}
let keys: Vec<Option<i64>> = obj.comps().map(|c| obj.key(&c.c)).collect();
for (c, key) in obj.comps_mut().zip(&keys) {
let answer = match declined.contains(key) {
let answer = match all || declined.contains(key) {
true => ICalendarParticipationStatus::Declined,
false => ICalendarParticipationStatus::Accepted,
};
@@ -228,29 +261,40 @@ pub fn auto_answer(
/// The attendee's copy with `me` answering `answer`: for every component, or
/// only for the instance at `instance`, which gets an override of its own
/// when it has none. `floating` reads the instance of an all-day series.
/// Storing the result through a PUT sends the REPLY.
/// Storing the result through a PUT sends the REPLY. `None` if the series
/// has no instance at `instance`.
pub fn respond(
copy: &ICalendar,
me: Is,
answer: ICalendarParticipationStatus,
instance: Option<DateTime<Utc>>,
floating: &Zone,
) -> ICalendar {
) -> Option<ICalendar> {
let mut obj = Obj::new(copy);
let target = instance.map(|rid| {
let master = obj.master().cloned();
let entry = master
.as_ref()
.and_then(|m| obj.recurrence_id(m, rid, floating));
let key = entry.as_ref().and_then(|e| obj.instant(e));
if obj.find(key).is_none()
&& let (Some(m), Some(e)) = (&master, &entry)
{
let inst = obj.instance(m, e);
obj.root.children.push(inst);
let target = match instance {
None => None,
Some(rid) => {
let form = obj
.master()
.and_then(|m| m.c.property(&ICalendarProperty::Dtstart))
.or_else(|| {
obj.comps()
.find_map(|c| c.c.property(&ICalendarProperty::RecurrenceId))
})?
.clone();
let key = obj.instant(&obj.recurrence_id(&form, rid, floating)?)?;
if obj.find(Some(key)).is_some() {
obj.narrow(copy, rid, key, floating);
} else {
if !obj.occurs(copy, rid, key, floating) {
return None;
}
let inst = obj.single(rid, floating)?;
obj.root.children.push(inst);
}
Some(Some(key))
}
key
});
};
let keys: Vec<Option<i64>> = obj.comps().map(|c| obj.key(&c.c)).collect();
for (c, key) in obj.comps_mut().zip(&keys) {
if target.is_some_and(|t| t != *key) {
@@ -269,7 +313,7 @@ pub fn respond(
remove_param(e, &ICalendarParameterName::Rsvp);
}
}
obj.done()
Some(obj.done())
}
/// The messages a change of the organizer object sends, without touching
@@ -301,7 +345,22 @@ pub fn messages(
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),
(Some((src, b)), None) => {
// A component that still lists them has SCHEDULE-AGENT=CLIENT
// now: the client tells them.
let lost: Vec<Node> = b
.into_iter()
.filter(|c| {
!new.as_ref()
.and_then(|n| n.find(src.key(&c.c)))
.is_some_and(|nc| lists(&nc.c, a))
})
.collect();
if lost.is_empty() {
continue;
}
(Method::Cancel, false, cancelled(lost), src)
}
(None, Some((src, comps))) => (Method::Request, false, comps, src),
(Some((_, b)), Some((src, comps))) => {
let quiet = if normalized(&b, true) != normalized(&comps, true) {
@@ -406,18 +465,25 @@ pub fn attend(
let start =
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
if key.is_none() || master.is_none() || start != key {
let shifted = key.map(|k| k + old.future(k).map_or(0, |(_, s)| s));
if key.is_none() || master.is_none() || start != shifted {
return Err(Refused::AttendeeChange);
}
}
}
}
// The state of the others and of the organizer is the server's.
// The state of the others and of the organizer is the server's. A new
// override takes it from the THISANDFUTURE override that moves it.
let base = |key: Option<i64>| {
old.find(key)
.or_else(|| old.future(key?).map(|(n, _)| n))
.or(master)
};
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 {
let Some(base) = base(*key) else {
continue;
};
for e in &mut c.c.entries {
@@ -442,10 +508,7 @@ pub fn attend(
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));
let before = base(*key).and_then(|b| own_partstat(&b.c, me));
if now_stat.is_some() && (force || now_stat != before) {
replied.push(reply_part(c, me));
}
@@ -631,30 +694,34 @@ 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 mut parts: Vec<&Node> = rep.comps().collect();
// In order: a range narrowed for one instance moves on to the next.
parts.sort_by_key(|c| rep.key(&c.c));
for rc in parts {
let key = rep.key(&rc.c);
let at = match next.position(key) {
Some(at) => at,
Some(at) => {
if let Some(t) = key
&& !is_range(&rc.c)
&& let Some(rid) = DateTime::from_timestamp(t, 0)
{
next.narrow(org, rid, t, &Zone::Utc);
}
at
}
// A reply for one instance of the series gets its own override.
// Obj reads floating times in UTC; expand must match.
None => {
let (Some(t), Some(rid), Some(master)) = (
key,
rc.c.property(&ICalendarProperty::RecurrenceId),
next.master(),
) else {
let Some((t, at)) = key.and_then(|t| Some((t, DateTime::from_timestamp(t, 0)?)))
else {
continue;
};
// Obj reads floating times in UTC; expand must match.
let occurs = DateTime::from_timestamp(t, 0).is_some_and(|at| {
expand(org, at..at + TimeDelta::seconds(1), Zone::Utc)
.instances
.iter()
.any(|i| i.recurrence_id == Some(at))
});
if !occurs {
if !next.occurs(org, at, t, &Zone::Utc) {
continue;
}
let inst = next.instance(master, rid);
let Some(inst) = next.single(at, &Zone::Utc) else {
continue;
};
next.root.children.push(inst);
next.root.children.len() - 1
}
@@ -737,7 +804,7 @@ fn guard(
.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)
(*moved && sequence(&c.c) <= sequence(&oc.c)).then(|| sequence(&oc.c).saturating_add(1))
})
.collect();
@@ -804,10 +871,10 @@ 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(oc, nc)
moved || reinstated || rules_grew(new, oc, nc)
}
fn rules_grew(oc: &ICalendarComponent, nc: &ICalendarComponent) -> bool {
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()? {
@@ -835,8 +902,11 @@ fn rules_grew(oc: &ICalendarComponent, nc: &ICalendarComponent) -> bool {
if unbounded(o) != unbounded(n) {
return true;
}
// A DATE UNTIL includes its whole day.
let end =
|u: &PartialDateTime| Some(obj.at(u, None)? + if u.hour.is_none() { 86399 } else { 0 });
let shorter = match (&o.until, &n.until, o.count, n.count) {
(Some(ou), Some(nu), _, _) => nu <= ou,
(Some(ou), Some(nu), _, _) => end(nu) <= end(ou),
(_, _, Some(oc), Some(nc)) => nc <= oc,
(None, _, None, _) => true,
_ => false,
@@ -853,7 +923,7 @@ fn cancelled(comps: Vec<Node>) -> Vec<Node> {
ICalendarProperty::Status,
ICalendarValue::Status(ICalendarStatus::Cancelled),
);
let seq = sequence(&n.c) + 1;
let seq = sequence(&n.c).saturating_add(1);
set_prop(
&mut n.c,
ICalendarProperty::Sequence,
@@ -1170,15 +1240,13 @@ impl Obj {
ICalendar { components }
}
/// A RECURRENCE-ID for the instance of `master` at `rid`, in the form of
/// the master's DTSTART.
/// A RECURRENCE-ID for the instance at `rid`, in the form of `start`.
fn recurrence_id(
&self,
master: &Node,
start: &ICalendarEntry,
rid: DateTime<Utc>,
floating: &Zone,
) -> Option<ICalendarEntry> {
let start = master.c.property(&ICalendarProperty::Dtstart)?;
let v = start.values.first()?.as_partial_date_time()?;
let local = |zone: &Zone| zone.to_local(rid).and_utc().timestamp();
let value = if v.hour.is_none() {
@@ -1192,16 +1260,131 @@ impl Obj {
};
Some(ICalendarEntry {
name: ICalendarProperty::RecurrenceId,
params: start.params.clone(),
params: without(start.params.clone(), &ICalendarParameterName::Range),
values: vec![ICalendarValue::PartialDateTime(Box::new(value))],
})
}
/// 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 =
/// The latest THISANDFUTURE override at or before instance `key`, and
/// how far it moves its instances.
fn future(&self, key: i64) -> Option<(&Node, i64)> {
let (k, n) = 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))
}
/// 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));
// 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())
.instances
.iter()
.any(|i| i.recurrence_id == Some(rid))
}
/// An override for the instance at `rid`, from the master or from the
/// THISANDFUTURE override that moves it.
fn single(&self, rid: DateTime<Utc>, floating: &Zone) -> Option<Node> {
let master = self.master()?;
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 {
return Some(self.instance(master, &id, &id));
};
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),
))],
..id.clone()
},
Some(_) => self.recurrence_id(form, rid + TimeDelta::seconds(shift), floating)?,
};
Some(self.instance(base, &id, &start))
}
/// Narrows the THISANDFUTURE override at instance `rid` (key `key`) to
/// that instance. A copy of it carries the range on from the next instance
/// without an override of its own, found by expanding `cal`.
fn narrow(&mut self, cal: &ICalendar, rid: DateTime<Utc>, key: i64, floating: &Zone) {
let Some(at) = self.position(Some(key)) else {
return;
};
let Some(range) = self.root.children[at]
.c
.property(&ICalendarProperty::RecurrenceId)
.and_then(|e| e.parameter(&ICalendarParameterName::Range))
.cloned()
else {
return;
};
let later = self
.comps()
.filter(|c| is_range(&c.c))
.filter_map(|c| self.key(&c.c))
.filter(|k| *k > key)
.min();
let next = self
.next_free(cal, rid, key, floating)
.filter(|(_, k)| later.is_none_or(|l| *k < l))
.and_then(|(r, _)| self.single(r, floating));
if let Some(mut n) = next {
if let Some(e) =
n.c.entries
.iter_mut()
.find(|e| e.name == ICalendarProperty::RecurrenceId)
{
set_param(e, ICalendarParameterName::Range, range);
}
self.root.children.push(n);
}
if let Some(e) = self.root.children[at]
.c
.entries
.iter_mut()
.find(|e| e.name == ICalendarProperty::RecurrenceId)
{
remove_param(e, &ICalendarParameterName::Range);
}
}
/// The first instance after `rid` that has no override, and its key.
// ponytail: looks ten years ahead; a range beyond that falls back to the master.
fn next_free(
&self,
cal: &ICalendar,
rid: DateTime<Utc>,
key: i64,
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));
[1, 32, 400, 3700].into_iter().find_map(|days| {
expand(cal, from..from + TimeDelta::days(days), floating.clone())
.instances
.iter()
.filter_map(|i| i.recurrence_id)
.filter(|r| *r > rid)
.filter_map(|r| Some((r, self.instant(&self.recurrence_id(form, r, floating)?)?)))
.filter(|(_, k)| self.find(Some(*k)).is_none())
.min()
})
}
/// An override of `base` for the instance `rid` starting at `start`, so
/// it can hold a status of its own. Its length becomes a DURATION.
fn instance(&self, base: &Node, rid: &ICalendarEntry, start: &ICalendarEntry) -> Node {
let mut n = base.clone();
let begin =
n.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| self.instant(e));
let end_prop = match n.c.component_type {
@@ -1209,7 +1392,7 @@ impl Obj {
_ => ICalendarProperty::Dtend,
};
let end = n.c.property(&end_prop).and_then(|e| self.instant(e));
if let (Some(s), Some(e)) = (start, end) {
if let (Some(s), Some(e)) = (begin, end) {
n.c.entries.retain(|x| x.name != end_prop);
n.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Duration,
@@ -1232,8 +1415,8 @@ impl Obj {
});
n.c.entries.push(ICalendarEntry {
name: ICalendarProperty::Dtstart,
params: without(rid.params.clone(), &ICalendarParameterName::Range),
values: rid.values.clone(),
params: without(start.params.clone(), &ICalendarParameterName::Range),
values: start.values.clone(),
});
n.c.entries.push(ICalendarEntry {
name: ICalendarProperty::RecurrenceId,
@@ -1275,6 +1458,15 @@ fn addresses(c: &ICalendarComponent) -> Vec<String> {
v
}
fn is_range(c: &ICalendarComponent) -> bool {
c.property(&ICalendarProperty::RecurrenceId)
.is_some_and(|e| e.parameter(&ICalendarParameterName::Range).is_some())
}
fn lists(c: &ICalendarComponent, a: &str) -> bool {
attendees(c).any(|e| address(e).is_some_and(|x| x.eq_ignore_ascii_case(a)))
}
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)))
Mpimdav/src/object.rs
@@ -61,6 +61,9 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
if too_deep(&cal) {
return Err(Invalid::CalendarData);
}
if too_many_rules(&cal) {
return Err(Invalid::CalendarResource);
}
let mut found: Option<CalendarObject> = None;
for c in root
.component_ids
@@ -126,6 +129,30 @@ fn too_deep(cal: &ICalendar) -> bool {
false
}
/// A rule that never matches costs up to 25 ms per expansion. Exported
/// VTIMEZONEs can hold dozens of observances.
const MAX_RULES: usize = 4;
const MAX_ZONE_RULES: usize = 50;
fn too_many_rules(cal: &ICalendar) -> bool {
let mut zone_rules = 0;
for c in &cal.components {
let rules = c
.entries
.iter()
.filter(|e| matches!(e.name, ICalendarProperty::Rrule | ICalendarProperty::Exrule))
.count();
match c.component_type {
ICalendarComponentType::Standard | ICalendarComponentType::Daylight => {
zone_rules += rules
}
_ if rules > MAX_RULES => return true,
_ => {}
}
}
zone_rules > MAX_ZONE_RULES
}
/// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A
/// card without one is accepted: several clients omit it.
pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
Mpimdav/src/principal.rs
@@ -4,7 +4,7 @@
use xmltree::Element;
use crate::filter::{Collation, MatchType, TextMatch};
use crate::report::Refused;
use crate::report::{Refused, limit};
use crate::xml::{CALDAV, CALSERVER, DAV, Name, Propfind, child, elements, text};
/// The `calendar-user-type` of a principal.
@@ -126,13 +126,6 @@ fn prop_names(root: &Element) -> Propfind {
Propfind::Prop(names)
}
fn nresults(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
child(root, ns, "limit")
.and_then(|l| child(l, ns, "nresults"))
.map(|n| text(n).parse().map_err(|_| Refused::Invalid))
.transpose()
}
/// A `DAV:principal-property-search` body.
pub fn property_search(root: &Element) -> Result<Search, Refused> {
let mut terms = Vec::new();
@@ -148,7 +141,7 @@ pub fn property_search(root: &Element) -> Result<Search, Refused> {
all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
kind: None,
find: prop_names(root),
limit: nresults(root, DAV)?,
limit: limit(root, DAV)?,
})
}
@@ -175,7 +168,7 @@ pub fn calendarserver_search(root: &Element) -> Result<Search, Refused> {
all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
kind,
find: prop_names(root),
limit: nresults(root, CALSERVER)?,
limit: limit(root, CALSERVER)?,
})
}
Mpimdav/src/report.rs
@@ -175,7 +175,7 @@ fn props(root: &Element) -> Result<Props, Refused> {
}
/// `<limit><nresults>n</nresults></limit>` in the namespace `ns`.
fn limit(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
pub(crate) fn limit(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
child(root, ns, "limit")
.and_then(|l| child(l, ns, "nresults"))
.map(|n| text(n).parse().map_err(|_| Refused::Invalid))
Mpimdav/src/text.rs
@@ -1,8 +1,10 @@
//! Content lines of iCalendar and vCard text (RFC 5545, 3.1; RFC 6350, 3.2),
//! for edits that must leave every other byte alone.
/// Physical lines joined with their folded continuation lines.
/// Physical lines joined with their folded continuation lines. A leading
/// byte order mark is dropped: Outlook and Notepad write one.
pub(crate) fn logical_lines(text: &str) -> Vec<&str> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut out = Vec::new();
let mut start = 0;
let mut pos = 0;
Mpimdav/tests/bundle.rs
@@ -192,3 +192,53 @@ fn calendar_names_itself() {
let bare = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nNAME:not me\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
assert_eq!(bundle::calendar_meta(bare), (None, None));
}
#[test]
fn byte_order_mark_is_ignored() {
let file = format!(
"\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::calendar_meta("\u{feff}BEGIN:VCALENDAR\r\nNAME:N\r\nEND:VCALENDAR\r\n")
.0
.as_deref(),
Some("N")
);
let card = "\u{feff}BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:C\r\nEND:VCARD\r\n";
assert_eq!(bundle::split_cards(card, &mut uids()).len(), 1);
// A stored object keeps its mark; feeds still show it.
assert!(bundle::calendar(&[&file], None, Detail::All).contains("UID:a"));
}
#[test]
fn shared_uid_across_types_splits_and_empty_uid_is_missing() {
let file = object(
"BEGIN:VEVENT\r\nUID:x\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\n\
BEGIN:VTODO\r\nUID:x\r\nSUMMARY:t\r\nEND:VTODO\r\n\
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());
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]);
assert!(!parts[1].contains("UID:x"));
assert!(parts[2].contains("UID:new-2\r\n"));
assert!(!parts[2].contains("UID:\r\n"));
for p in &parts {
assert!(
object::calendar(p.as_bytes(), &["VEVENT", "VTODO"]).is_ok(),
"{p}"
);
}
let cards = bundle::split_cards(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:\r\nFN:C\r\nEND:VCARD\r\n",
&mut uids(),
);
assert_eq!(
cards[0],
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:C\r\nUID:new-1\r\nEND:VCARD\r\n"
);
}
Mpimdav/tests/expand.rs
@@ -363,3 +363,30 @@ fn instance_for_finds_instances_moved_by_this_and_future() {
("2024-01-04T12:00".into(), "2024-01-04T14:00".into())
);
}
#[test]
fn rdate_period_on_dtstart_keeps_its_length() {
let body = event(
"a",
"DTSTART:20260601T100000Z\r\nDTEND:20260601T110000Z\r\nRDATE;VALUE=PERIOD:20260601T100000Z/PT5H\r\n",
);
assert_eq!(
instances(&body, "2026-06-01T00:00", "2026-06-02T00:00"),
["2026-06-01T10:00/2026-06-01T15:00"]
);
}
#[test]
fn a_rule_that_never_matches_stops_early() {
let body = event(
"a",
"DTSTART:20260601T100000Z\r\nRRULE:FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30\r\n",
);
let t = std::time::Instant::now();
assert_eq!(
instances(&body, "2026-06-01T00:00", "2026-07-01T00:00"),
["2026-06-01T10:00/2026-06-01T10:00"]
);
// Without the period cap it runs on to year 9999, seconds in a debug build.
assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
}
Mpimdav/tests/itip.rs
@@ -504,3 +504,242 @@ fn forget_a_deleted_principal() {
None
);
}
const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
fn at(d: u32, h: u32) -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap()
}
fn booked(start: chrono::DateTime<Utc>, end: chrono::DateTime<Utc>) -> pimdav::freebusy::Period {
pimdav::freebusy::Period {
kind: pimdav::freebusy::Busy::Busy,
start,
end,
}
}
#[test]
fn a_room_declines_a_series_with_many_conflicts_as_a_whole() {
use pimdav::zone::Zone;
let copy = cal(&format!(
"BEGIN:VEVENT\nUID:h1\nDTSTART:20260105T000000Z\nDURATION:PT30M\nRRULE:FREQ=HOURLY;COUNT=200\n\
ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
));
let taken = [booked(at(1, 0), at(31, 0))];
let got = text(&itip::auto_answer(
©,
&is(ROOM),
&taken,
&(at(1, 0)..at(31, 0)),
&Zone::Utc,
));
assert!(!got.contains("RECURRENCE-ID"), "{got}");
assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
}
#[test]
fn a_room_declines_one_instance_a_this_and_future_override_moves() {
use pimdav::zone::Zone;
// From the 12th on, the meeting runs two hours later.
let copy = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
));
let taken = [booked(at(19, 12), at(19, 13))];
let answer = itip::auto_answer(©, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
let got = text(&answer);
assert!(
got.contains("RECURRENCE-ID:20260119T100000Z\r\nDTSTART:20260119T120000Z")
|| got.contains("DTSTART:20260119T120000Z\r\nRECURRENCE-ID:20260119T100000Z"),
"{got}"
);
assert_eq!(
got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
1,
"{got}"
);
assert_eq!(
got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
2,
"{got}"
);
// The moved override passes as the room's own answer, and the
// organizer's copy takes it.
let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap();
let mut org = copy.clone();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)));
let org = text(&org);
assert!(org.contains("DTSTART:20260119T120000Z"), "{org}");
}
#[test]
fn a_client_scheduled_attendee_gets_no_cancel() {
let old = meeting(&format!("ATTENDEE:{BOB}\n"));
let new = meeting(&format!("ATTENDEE;SCHEDULE-AGENT=CLIENT:{BOB}\n"));
let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
}
#[test]
fn the_highest_sequence_does_not_overflow() {
let old = meeting(&format!(
"SEQUENCE:9223372036854775807\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"
));
let moved = text(&old)
.replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z")
.replace(&format!("ATTENDEE:{CAROL}\r\n"), "");
let (store, msgs) = itip::organize(
Some(&old),
Some(ICalendar::parse(&moved).unwrap()),
&is(ALICE),
now(),
);
assert!(text(&store.unwrap()).contains("SEQUENCE:9223372036854775807"));
assert!(text(&to(&msgs, CAROL).unwrap().cal).contains("SEQUENCE:9223372036854775807"));
}
#[test]
fn answering_an_instance_the_series_lacks_changes_nothing() {
use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
use pimdav::zone::Zone;
let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
// A Tuesday is no instance.
assert!(itip::respond(©, &is(BOB), Declined, Some(at(13, 10)), &Zone::Utc).is_none());
let got =
text(&itip::respond(©, &is(BOB), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
assert_eq!(
got.matches(&format!("PARTSTAT=DECLINED:{BOB}")).count(),
1,
"{got}"
);
}
#[test]
fn a_date_until_on_the_same_day_extends_the_series() {
let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")))
.replace("COUNT=4", "UNTIL=20260119T000000Z");
let old = ICalendar::parse(&old).unwrap();
// The DATE includes the instance at 10:00 on the 19th.
let longer = text(&old).replace("UNTIL=20260119T000000Z", "UNTIL=20260119");
let (store, _) = itip::organize(
Some(&old),
Some(ICalendar::parse(&longer).unwrap()),
&is(ALICE),
now(),
);
let store = text(&store.unwrap());
assert!(
store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
"{store}"
);
}
#[test]
fn a_room_declines_only_the_first_instance_of_a_this_and_future_override() {
use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
use pimdav::zone::Zone;
// From the 12th on, the meeting runs two hours later.
let copy = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
));
let taken = [booked(at(12, 12), at(12, 13))];
let answer = itip::auto_answer(©, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
let got = text(&answer);
assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
assert!(
got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
"{got}"
);
assert!(got.contains("DTSTART:20260119T120000Z"), "{got}");
let declined = format!("PARTSTAT=DECLINED:{ROOM}");
let accepted = format!("PARTSTAT=ACCEPTED:{ROOM}");
assert_eq!(got.matches(&declined).count(), 1, "{got}");
assert_eq!(got.matches(&accepted).count(), 2, "{got}");
// The organizer's copy splits its range the same way.
let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap();
let mut org = copy.clone();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)));
let org = text(&org);
assert!(
org.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
"{org}"
);
assert_eq!(org.matches("PARTSTAT=DECLINED").count(), 1, "{org}");
assert_eq!(org.matches("PARTSTAT=ACCEPTED").count(), 2, "{org}");
// So does an answer for that one instance from the web.
let got =
text(&itip::respond(©, &is(ROOM), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
assert!(
got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
"{got}"
);
assert_eq!(got.matches(&declined).count(), 1, "{got}");
}
#[test]
fn a_new_override_takes_the_others_state_from_its_range() {
let old = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{BOB}\nATTENDEE;PARTSTAT=ACCEPTED:{CAROL}\nEND:VEVENT\n"
));
let new = cal(&format!(
"{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
DTEND:20260119T130000Z\nORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\
ATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n",
text(&old)
.trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
.trim_end_matches("END:VCALENDAR\r\n")
.replace("\r\n", "\n")
));
let (store, reply) = itip::attend(&old, new, &is(BOB), now()).unwrap();
let store = text(&store);
assert_eq!(
store.matches(&format!("PARTSTAT=ACCEPTED:{CAROL}")).count(),
2,
"{store}"
);
assert!(reply.is_some());
}
#[test]
fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override() {
let over = |agent: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T120000Z\n\
DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE{agent}:{BOB}\nEND:VEVENT\n"
)
};
let series = |extra: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\n{extra}END:VEVENT\n"
)
};
let old = cal(&format!(
"{}{}",
series(&format!("ATTENDEE:{BOB}\n")),
over("")
));
let new = cal(&format!("{}{}", series(""), over(";SCHEDULE-AGENT=CLIENT")));
let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let cancel = to(&msgs, BOB).expect("a CANCEL for the series");
assert_eq!(cancel.method, Method::Cancel);
assert!(
!text(&cancel.cal).contains("RECURRENCE-ID"),
"{}",
text(&cancel.cal)
);
}
Mpimdav/tests/principal.rs
@@ -98,3 +98,14 @@ fn list_all_and_stray_properties() {
])
);
}
#[test]
fn zero_nresults_means_no_limit() {
let s = search(
r#"<cs:calendarserver-principal-search xmlns:cs="http://calendarserver.org/ns/">
<cs:search-token>bo</cs:search-token>
<cs:limit><cs:nresults>0</cs:nresults></cs:limit>
</cs:calendarserver-principal-search>"#,
);
assert_eq!(s.limit, None);
}
Mpimdav/tests/protocol.rs
@@ -225,3 +225,25 @@ fn missing_dtstamp_is_inserted() {
lf.replace("VJOURNAL\nUID", "VJOURNAL\nDTSTAMP:20260921T141320Z\nUID")
);
}
#[test]
fn too_many_rules_are_refused() {
let rules = |n: usize| "RRULE:FREQ=DAILY\r\n".repeat(n);
let event = |n| EVENT.replace("END:VEVENT", &format!("{}END:VEVENT", rules(n)));
assert!(object::calendar(&ics(&event(4)), &["VEVENT"]).is_ok());
assert_eq!(
object::calendar(&ics(&event(5)), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let zone = |n| {
format!(
"BEGIN:VTIMEZONE\r\nTZID:X\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n{}END:STANDARD\r\nEND:VTIMEZONE\r\n",
rules(n)
)
};
assert!(object::calendar(&ics(&format!("{}{EVENT}", zone(50))), &["VEVENT"]).is_ok());
assert_eq!(
object::calendar(&ics(&format!("{}{EVENT}", zone(51))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
}
Mpimdav/tests/report.rs
@@ -451,3 +451,14 @@ fn outbox_free_busy_request() {
assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}");
}
}
#[test]
fn text_match_ignores_pretty_printing() {
let body =
"BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20240101T100000Z\r\nSUMMARY:John Smith\r\nEND:VEVENT\r\n";
let filter = r#"<c:comp-filter name="VEVENT"><c:prop-filter name="SUMMARY">
<c:text-match match-type="equals">
John Smith
</c:text-match></c:prop-filter></c:comp-filter>"#;
assert!(hit(body, filter));
}
Mserver/src/api/admin.rs
@@ -18,7 +18,7 @@ use crate::api::common::{
use crate::api::pim::{INBOX, principal_href};
use crate::api::shares;
use crate::api::{pim_api, pim_schedule};
use crate::db::{PimKind, PimPrincipal, RootRow, UserType};
use crate::db::{PimKind, PimOp, PimPrincipal, RootRow, UserType};
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -226,8 +226,8 @@ pub async fn delete_user(
let pid = state.db.principal_of(id).await?;
let ops = match state.db.pim_principal_by_id(pid).await? {
Some(p) => {
retract_all(&state, &p).await?;
pim_schedule::forget(&state, &p).await?
let retracted = retract_all(&state, &p).await?;
pim_schedule::forget(&state, &p, retracted).await?
}
None => Vec::new(),
};
@@ -245,13 +245,10 @@ pub async fn delete_user(
// Shares
// ---------------------------------------------------------------------------
/// Commits the cancellations and declines for everything `p` owns, before
/// `forget`. `deliver` writes Put ops on attendee copies and inbox messages,
/// and `forget` builds its ops from stored data. In one transaction its Puts
/// would overwrite the cancellations, and the new inbox messages would keep
/// the real address. Hold the scheduling lock.
async fn retract_all(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
let dir = pim_schedule::Directory::load(state).await?;
/// 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 ids: Vec<i64> = state
.db
.pim_collections(p.id, PimKind::Calendar)
@@ -260,16 +257,9 @@ async fn retract_all(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError>
.filter(|c| c.slug != INBOX)
.map(|c| c.id)
.collect();
match pim_schedule::retract(state, &dir, p, &ids).await? {
Ok(ops) => state.db.pim_apply(&ops).await?,
Err(_) => {
return Err(ApiError::new(
StatusCode::CONFLICT,
"the meetings cannot be cancelled",
));
}
}
Ok(())
pim_schedule::retract(state, &dir, p, &ids)
.await?
.map_err(|_| ApiError::new(StatusCode::CONFLICT, "the meetings cannot be cancelled"))
}
/// GET /api/admin/shares — every share on the server with its creator.
@@ -430,8 +420,8 @@ pub async fn delete_room(
else {
return Err(room_not_found());
};
retract_all(&state, &room).await?;
let ops = pim_schedule::forget(&state, &room).await?;
let retracted = retract_all(&state, &room).await?;
let ops = pim_schedule::forget(&state, &room, retracted).await?;
if !state.db.delete_room(id, &ops).await? {
return Err(room_not_found());
}
Mserver/src/api/dav.rs
@@ -97,6 +97,8 @@ pub async fn share(State(state): State<Arc<AppState>>, req: Request<Body>) -> Re
let Some((_, password)) = auth::basic_credentials(req.headers()) else {
return challenge();
};
// The hash is of the trimmed password.
let password = password.trim().to_owned();
let (pw, id, tok) = (password.clone(), row.id, token.clone());
let ok = auth::verify_cached(row.id, "", &password, move || async move {
// Throttled like `POST /api/share/{token}/unlock`, keyed the same
Mserver/src/api/pim.rs
@@ -16,6 +16,7 @@
//! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto
//! the store and assembles the responses.
use std::collections::HashSet;
use std::sync::Arc;
use api_types::PIM;
@@ -55,8 +56,17 @@ const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
const MAX_SLUG: usize = 255;
const MAX_COLLECTIONS: usize = 100;
const MAX_DISPLAYNAME: usize = 256;
const MAX_DESCRIPTION: usize = 1024;
pub(super) const MAX_DISPLAYNAME: usize = 256;
pub(super) const MAX_DESCRIPTION: usize = 1024;
/// A trimmed name (`lines` false) or description within `max` characters.
pub(super) fn valid_text(v: &str, max: usize, lines: bool) -> bool {
let v = v.trim();
v.chars().count() <= max
&& !v
.chars()
.any(|c| c.is_control() && !(lines && matches!(c, '\n' | '\r' | '\t')))
}
/// Largest XML request body.
const MAX_XML_SIZE: usize = 1024 * 1024;
@@ -813,7 +823,7 @@ impl Cx<'_> {
/// Deletes a collection of principal `owner`. A calendar's scheduling
/// objects are cancelled for their attendees first. `Err` names the
/// precondition that refuses it: the calendar that receives invitations
/// stays.
/// stays. Takes [`pim_schedule::LOCK`].
pub(super) async fn delete_own(
state: &AppState,
owner: i64,
@@ -821,6 +831,8 @@ pub(super) async fn delete_own(
col: &PimCollection,
) -> Result<Result<(), Element>, ApiError> {
let db = &state.db;
// A PUT checks under the lock that its collection still exists.
let _lock = pim_schedule::LOCK.lock().await;
if kind == PimKind::Calendar && col.slug != INBOX {
if db
.pim_calendar_for(owner, "VEVENT")
@@ -829,7 +841,6 @@ pub(super) async fn delete_own(
{
return Ok(Err(el(CALDAV, "default-calendar-needed")));
}
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(state).await?;
let owner = dir
.get(owner)
@@ -1430,6 +1441,14 @@ fn content_type(kind: PimKind, component: &str) -> String {
impl Cx<'_> {
async fn proppatch(&self, target: &Target, body: Body) -> Reply {
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let Ok(mut update) = xml::update(&body) else {
return Ok(status(StatusCode::BAD_REQUEST));
};
// Read before the lock, so a slow client cannot hold it.
let _lock = pim_schedule::LOCK.lock().await;
let (href, place, res, mut col) = match target {
Target::Collection(kind, _, slug) => {
let Some(col) = self.collection(*kind, slug).await? else {
@@ -1470,12 +1489,6 @@ impl Cx<'_> {
_ => return Ok(status(StatusCode::FORBIDDEN)),
};
let before = col.as_ref().map(|(_, c)| c.clone());
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let Ok(mut update) = xml::update(&body) else {
return Ok(status(StatusCode::BAD_REQUEST));
};
// The inbox names the calendar that receives invitations (RFC 6638,
// 9.2). `Some(Err(()))`: it names none of the owner's calendars.
let default_url = Name::new(CALDAV, "schedule-default-calendar-URL");
@@ -1830,12 +1843,13 @@ fn set_own(
) -> Option<bool> {
let cal = kind == PimKind::Calendar;
let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
let short =
|v: &Option<String>, max: usize| v.as_ref().is_none_or(|v| v.chars().count() <= max);
let short = |v: &Option<String>, max: usize, lines: bool| {
v.as_ref().is_none_or(|v| valid_text(v, max, lines))
};
Some(match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => {
let v = value();
let valid = short(&v, MAX_DISPLAYNAME);
let valid = short(&v, MAX_DISPLAYNAME, false);
if valid {
col.displayname = v;
}
@@ -1843,7 +1857,7 @@ fn set_own(
}
(CALDAV, "calendar-description") if cal => {
let v = value();
let valid = short(&v, MAX_DESCRIPTION);
let valid = short(&v, MAX_DESCRIPTION, true);
if valid {
col.description = v;
}
@@ -1851,7 +1865,7 @@ fn set_own(
}
(CARDDAV, "addressbook-description") if !cal => {
let v = value();
let valid = short(&v, MAX_DESCRIPTION);
let valid = short(&v, MAX_DESCRIPTION, true);
if valid {
col.description = v;
}
@@ -2053,6 +2067,14 @@ impl Cx<'_> {
let data = stamped.as_deref().unwrap_or(&data);
let _lock = pim_schedule::LOCK.lock().await;
// A DELETE of the collection or of the share may have run meanwhile.
let access = match self.collection(*kind, slug).await? {
Some(now) if now.c.id == col.id => now.access,
_ => return Ok(status(StatusCode::CONFLICT)),
};
if access < Access::Write {
return Ok(denied(&space.collection(*kind, slug), "bind"));
}
let db = &self.state.db;
let current = self.member(&col, name).await?;
if refuses(headers, current.as_ref().map(|(o, _)| o)) {
@@ -2139,6 +2161,12 @@ impl Cx<'_> {
Target::Object(k, _, s, n) => (k, s, Some(n)),
_ => return Ok(status(StatusCode::FORBIDDEN)),
};
// Under the lock, so a revoked share applies at once. `delete_own`
// takes it for a collection.
let _lock = match name {
Some(_) => Some(pim_schedule::LOCK.lock().await),
None => None,
};
let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
@@ -2157,6 +2185,7 @@ impl Cx<'_> {
},
// Deleting a lent collection only takes it out of this home.
_ if slug.starts_with(SHARED_PREFIX) && space.mine => {
let _lock = pim_schedule::LOCK.lock().await;
db.pim_remove_share(col.id, self.me.id).await?;
status(StatusCode::NO_CONTENT)
}
@@ -2166,7 +2195,6 @@ impl Cx<'_> {
if access < Access::Write {
return Ok(denied(&href, "unbind"));
}
let _lock = pim_schedule::LOCK.lock().await;
let Some((obj, data)) = self.member(&col, name).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
@@ -2294,8 +2322,18 @@ impl Cx<'_> {
Report::CalendarMultiget { props, hrefs }
| Report::AddressbookMultiget { props, hrefs } => {
let members = self.generated_members(&col).await?;
let mut found = Vec::with_capacity(hrefs.len());
let mut seen = HashSet::new();
let mut found = Vec::new();
let mut loaded = 0;
let mut cut = false;
for href in hrefs {
if !seen.insert(href.clone()) {
continue;
}
if found.len() >= MAX_MULTIGET_HREFS || loaded > MAX_MULTIGET_BYTES {
cut = true;
break;
}
let hit = match self.own_object(*kind, &href) {
Some((slug, name)) if slug == col.slug => match &members {
Some(m) => m.get(&name).cloned(),
@@ -2303,13 +2341,14 @@ impl Cx<'_> {
},
_ => None,
};
loaded += hit.as_ref().map_or(0, |(_, data)| data.len());
found.push((href, hit));
}
blocking(move || -> Reply {
let mut responses = Vec::new();
for (href, hit) in found {
if out.full() {
responses.push(out.over_limit());
cut = true;
break;
}
responses.push(match hit {
@@ -2321,6 +2360,9 @@ impl Cx<'_> {
None => xml::Response::status(href, 404),
});
}
if cut {
responses.push(out.over_limit());
}
Ok(multistatus(&responses, None))
})
.await
@@ -2399,17 +2441,10 @@ impl Cx<'_> {
Some((id, seq)) if id == col.id && generated(id) && seq == col.seq => {
Some(seq)
}
Some((id, seq))
if id == col.id
&& !generated(id)
&& seq <= col.seq
&& seq >= self.state.db.pim_pruned_seq(id).await? =>
{
Some((id, seq)) if id == col.id && !generated(id) && seq <= col.seq => {
Some(seq)
}
_ => {
return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token")));
}
_ => return Ok(invalid_sync_token()),
},
};
// A generated collection has no change log to resume a cut
@@ -2419,7 +2454,10 @@ impl Cx<'_> {
// only makes the next sync refetch a member.
let mut changes = match generated(col.id) {
true => Vec::new(),
false => self.state.db.pim_changes(col.id, since).await?,
false => match self.state.db.pim_changes(col.id, since).await? {
Some(c) => c,
None => return Ok(invalid_sync_token()),
},
};
// An initial sync reads every member at once, not one per change.
let mut members = match since {
@@ -2585,6 +2623,10 @@ fn search_property_set() -> Response<Body> {
/// Beyond it the answer is cut short with a 507, as for a client limit.
const MAX_EXPANDED_PER_ANSWER: usize = 20_000;
/// Hrefs and object bytes one multiget loads. Beyond them it answers 507.
const MAX_MULTIGET_HREFS: usize = 1000;
const MAX_MULTIGET_BYTES: usize = 32 * 1024 * 1024;
/// What a REPORT answer about one collection needs. Owned, so the answer
/// can be built on the blocking pool.
struct Out {
@@ -2638,6 +2680,10 @@ impl Out {
}
}
fn invalid_sync_token() -> Response<Body> {
error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))
}
fn too_many() -> Response<Body> {
error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
}
@@ -2706,6 +2752,7 @@ impl Cx<'_> {
return Ok(status(StatusCode::FORBIDDEN));
}
let space = self.space();
let _lock = pim_schedule::LOCK.lock().await;
let Some(from) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
@@ -2724,7 +2771,6 @@ impl Cx<'_> {
if !from.owner.eq_ignore_ascii_case(&to.owner) {
return Ok(status(StatusCode::FORBIDDEN));
}
let _lock = pim_schedule::LOCK.lock().await;
let Some((obj, _)) = self.member(&from.c, name).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
Mserver/src/api/pim_api.rs
@@ -39,8 +39,8 @@ 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, OUTBOX, SHARED_PREFIX,
collection_href, delete_own, etag_of, generated, members_of,
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,
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::api::pim_views;
@@ -241,6 +241,15 @@ async fn create_collection(
if name.is_empty() {
return Err(bad_request("a name is required"));
}
if !valid_text(name, MAX_DISPLAYNAME, false) {
return Err(bad_request("invalid name"));
}
if description
.as_deref()
.is_some_and(|d| !valid_text(d, MAX_DESCRIPTION, true))
{
return Err(bad_request("invalid description"));
}
let components = match kind {
PimKind::Calendar if components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::Calendar => {
@@ -308,6 +317,9 @@ pub async fn update(
if name.is_empty() {
return Err(bad_request("a name is required"));
}
if !valid_text(name, MAX_DISPLAYNAME, false) {
return Err(bad_request("invalid name"));
}
col.displayname = Some(name.to_string());
}
if let Some(color) = body.color {
@@ -318,6 +330,9 @@ pub async fn update(
col.color = (!color.is_empty()).then(|| color.to_string());
}
if let Some(d) = body.description {
if !valid_text(&d, MAX_DESCRIPTION, true) {
return Err(bad_request("invalid description"));
}
col.description = (!d.trim().is_empty()).then(|| d.trim().to_string());
}
if let Some(t) = body.transparent {
@@ -347,6 +362,7 @@ pub async fn delete(
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
if owner != pid {
let _lock = pim_schedule::LOCK.lock().await;
state.db.pim_remove_share(id, auth.user.id).await?;
return Ok(Json(OkResp {}));
}
@@ -419,12 +435,19 @@ pub async fn share(
Json(body): Json<CreatePimShare>,
) -> Result<Json<PimShareInfo>, ApiError> {
let id = own(&state, &auth, id).await?;
let user = state
.db
.pim_principal(body.user.trim())
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
let Some(user_id) = user.user_id else {
let name = body.user.trim();
let found = match state.db.pim_principal(name).await? {
Some(p) => p.user_id.map(|uid| (uid, p.name)),
// The lookup hides disabled accounts. Their loans still take a new mode.
None => state
.db
.pim_shares(id)
.await?
.into_iter()
.find(|(_, n, _)| n == name)
.map(|(uid, n, _)| (uid, n)),
};
let Some((user_id, user_name)) = found else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
};
if user_id == auth.user.id {
@@ -433,10 +456,12 @@ 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,
user_name: user.name,
user_name,
mode: body.mode,
}))
}
@@ -448,6 +473,7 @@ pub async fn unshare(
AxumPath((id, user_id)): AxumPath<(i64, i64)>,
) -> Result<Json<OkResp>, ApiError> {
let id = own(&state, &auth, id).await?;
let _lock = pim_schedule::LOCK.lock().await;
if !state.db.pim_remove_share(id, user_id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
}
@@ -685,10 +711,12 @@ pub async fn feed(
let Some((_, password)) = auth::basic_credentials(&headers) else {
return Ok(challenge());
};
let (pw, id, tok) = (password.clone(), link.id, link.token.clone());
// The hash is of the trimmed password, as for file shares.
let password = password.trim();
let (pw, id, tok) = (password.to_string(), link.id, link.token.clone());
// A negative realm: share ids are positive, and one share's password
// must never open a feed with the same id.
let ok = auth::verify_cached(-link.id, "", &password, move || async move {
let ok = auth::verify_cached(-link.id, "", password, move || async move {
auth::throttle(&tok).await;
let ok = auth::verify_password_async(&pw, &hash).await;
auth::record_login(&tok, ok);
@@ -819,16 +847,13 @@ pub async fn import(
AxumPath(id): AxumPath<i64>,
body: Body,
) -> Result<Json<PimImportResult>, ApiError> {
let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
let (_, kind, col, writable) = reachable(&state, &auth, id).await?;
if !writable {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let may_schedule = pim_views::may_answer(&state, &auth, owner, id).await?;
let text = read_import(body).await?;
let parts = split_import(kind, &text)?;
Ok(Json(
import_parts(&state, owner, kind, &col, may_schedule, parts).await?,
))
Ok(Json(import_parts(&state, &auth, kind, &col, parts).await?))
}
#[derive(serde::Deserialize)]
@@ -856,12 +881,14 @@ pub async fn import_new(
PimKind::AddressBook => (None, None),
};
let nonempty = |s: Option<String>| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
// A name from the file that cannot be stored falls back to the next one.
let usable = |s: Option<String>| nonempty(s).filter(|s| valid_text(s, MAX_DISPLAYNAME, false));
let stem = q
.file
.map(|f| f.rsplit_once('.').map_or(f.clone(), |(s, _)| s.to_string()));
let name = nonempty(q.name)
.or(nonempty(own_name))
.or(nonempty(stem))
.or(usable(own_name))
.or(usable(stem))
.ok_or_else(|| bad_request("a name is required"))?;
// COLOR may be a CSS color name, which the web UI cannot show.
let color = own_color
@@ -875,7 +902,7 @@ pub async fn import_new(
.pim_collection_by_id(info.id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let result = import_parts(&state, pid, kind, &col, true, parts).await;
let result = import_parts(&state, &auth, kind, &col, parts).await;
let keep = matches!(&result, Ok(r) if r.created + r.updated > 0);
if !keep {
// Empty and never lent or synced: nothing to cancel, nobody to tell.
@@ -919,15 +946,13 @@ fn split_import(kind: PimKind, text: &str) -> Result<Vec<String>, ApiError> {
Ok(parts)
}
/// `may_schedule`: the importer may change scheduling objects, as the
/// owner or with `rw+schedule`. Without it such objects are skipped, as a
/// PUT would refuse them.
/// Scheduling objects need the owner or `rw+schedule`. Without it they are
/// skipped, as a PUT would refuse them.
async fn import_parts(
state: &AppState,
owner: i64,
auth: &SessionUser,
kind: PimKind,
col: &PimCollection,
may_schedule: bool,
parts: Vec<String>,
) -> Result<PimImportResult, ApiError> {
let supported: Vec<String> = col.components.split(',').map(str::to_string).collect();
@@ -942,6 +967,12 @@ async fn import_parts(
.await?;
let _lock = pim_schedule::LOCK.lock().await;
// The collection or the share may have gone while the file was checked.
let (owner, _, _, writable) = reachable(state, auth, col.id).await?;
if !writable {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let may_schedule = pim_views::may_answer(state, auth, owner, col.id).await?;
let dir = Directory::load(state).await?;
let owner = dir
.get(owner)
Mserver/src/api/pim_schedule.rs
@@ -8,6 +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 chrono::{DateTime, Utc};
use percent_encoding::percent_decode_str;
use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
@@ -99,6 +101,14 @@ impl Directory {
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<'_> {
@@ -140,23 +150,41 @@ impl Directory {
}
/// The writes that make other principals' objects forget `gone` before it
/// is deleted. Its addresses become a tombstone in `deleted.` of the mail
/// domain, which names no one, so a later principal of the same name gets
/// nothing meant for the old one. Commit them together with the delete,
/// holding [`LOCK`].
pub(crate) async fn forget(state: &AppState, gone: &PimPrincipal) -> Result<Vec<PimOp>, ApiError> {
/// is deleted, after `retracted`, the writes of [`retract`]. Its addresses
/// become a tombstone in `deleted.` of the mail domain, which names no one,
/// so a later principal of the same name gets nothing meant for the old one.
/// Commit them together with the delete, holding [`LOCK`].
pub(crate) async fn forget(
state: &AppState,
gone: &PimPrincipal,
mut retracted: Vec<PimOp>,
) -> Result<Vec<PimOp>, ApiError> {
let dir = Directory(vec![gone.clone()]);
let is_gone = dir.is(gone.id);
let encoded = local_part(&gone.name);
let tombstone = format!("mailto:{encoded}-{}@deleted.{MAIL_DOMAIN}", gone.id);
let uuid = principal_uuid(gone.id);
let needles = [gone.name.as_str(), encoded.as_str(), uuid.as_str()];
let rewrite = |data: &[u8]| {
itip::forget(&String::from_utf8_lossy(data), &is_gone, &tombstone).map(String::into_bytes)
};
let retracted_puts: HashSet<(i64, &str)> = retracted
.iter()
.filter_map(|op| match op {
PimOp::Put {
collection_id, obj, ..
} => Some((*collection_id, obj.name.as_str())),
_ => None,
})
.collect();
let mut ops = Vec::new();
for (collection_id, obj, data) in state.db.pim_objects_mentioning(gone.id, &needles).await? {
let Some(new) = itip::forget(&String::from_utf8_lossy(&data), &is_gone, &tombstone) else {
if retracted_puts.contains(&(collection_id, obj.name.as_str())) {
continue;
}
let Some(data) = rewrite(&data) else {
continue;
};
let data = new.into_bytes();
ops.push(PimOp::Put {
collection_id,
obj: PimObject {
@@ -166,6 +194,17 @@ pub(crate) async fn forget(state: &AppState, gone: &PimPrincipal) -> Result<Vec<
data,
});
}
for op in &mut retracted {
if let PimOp::Put { obj, data, .. } | PimOp::Inbox { obj, data, .. } = op
&& let Some(new) = rewrite(data)
{
obj.etag = etag_of(&new);
*data = new;
}
}
// Last, because inbox writes drop the oldest messages; a rewrite after
// them would bring a dropped one back.
ops.extend(retracted);
Ok(ops)
}
Mserver/src/api/pim_views.rs
@@ -503,12 +503,12 @@ pub async fn reply(
}
};
let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?;
let _lock = pim_schedule::LOCK.lock().await;
let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?;
if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? {
return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here"));
}
let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
let _lock = pim_schedule::LOCK.lock().await;
let (obj, old) = state
.db
.pim_object(col.id, &body.name)
@@ -525,7 +525,9 @@ pub async fn reply(
));
}
let zone = floating(body.tz.as_deref());
let new = itip::respond(&cal, &owns, answer, rid, &zone).to_string();
let new = itip::respond(&cal, &owns, answer, rid, &zone)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "instance not found"))?
.to_string();
let me = state.db.principal_of(auth.user.id).await?;
let w = Writer {
owner: &principal,
Mserver/src/db.rs
@@ -1235,15 +1235,17 @@ impl Db {
/// Deletes an account after `ops`, in one transaction. The ops make other
/// principals' objects forget it (`pim_schedule::forget`). `false` means
/// no row matched.
/// no row matched, and then no op is applied.
pub async fn delete_user(&self, id: i64, ops: &[PimOp]) -> DbResult<bool> {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
apply_ops(&tx, ops)?;
let deleted = tx.execute("DELETE FROM users WHERE id = ?1", [id])? > 0;
if tx.execute("DELETE FROM users WHERE id = ?1", [id])? == 0 {
return Ok(false);
}
tx.commit()?;
self.forget_defaults();
Ok(deleted)
Ok(true)
}
// ---------- shares ----------
@@ -1354,16 +1356,6 @@ impl Db {
Ok(())
}
/// Sync tokens at or below this seq are no longer answerable.
pub async fn pim_pruned_seq(&self, collection_id: i64) -> DbResult<i64> {
let c = self.conn.lock().await;
c.query_row(
"SELECT pruned_seq FROM pim_collections WHERE id = ?1",
[collection_id],
|r| r.get(0),
)
}
/// Whether `token` is a live unlock for `share_id`.
///
/// The share id is part of the lookup, so an unlock for one share cannot
@@ -1821,17 +1813,28 @@ impl Db {
}
/// `(name, seq, deleted)` of the members changed after `since`, oldest
/// first. Without `since`, the members that exist.
/// first. Without `since`, the members that exist. `None` when pruning
/// removed deletions after `since`.
pub async fn pim_changes(
&self,
collection_id: i64,
since: Option<i64>,
) -> DbResult<Vec<(String, i64, bool)>> {
) -> 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 {
return Ok(None);
}
}
// Two statements, not one with `?2 IS NULL OR ...`: the OR keeps
// SQLite from using the seq range of the index.
let map = |r: &rusqlite::Row| Ok((r.get(0)?, r.get(1)?, r.get(2)?));
match since {
let rows: DbResult<Vec<_>> = match since {
None => c
.prepare_cached(
"SELECT name, seq, 0 FROM pim_changes
@@ -1846,7 +1849,8 @@ impl Db {
)?
.query_map(params![collection_id, seq], map)?
.collect(),
}
};
rows.map(Some)
}
/// Moves an object to `to_name` in collection `to`, which may be the
@@ -2073,10 +2077,13 @@ impl Db {
stmt.query_map([collection_id], map_pim_link)?.collect()
}
/// `None` also when the owner's account is disabled.
pub async fn pim_link_by_token(&self, token: &str) -> DbResult<Option<PimLink>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE token = ?1"
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE token = ?1 AND collection_id IN (
SELECT c.id FROM pim_collections c JOIN principals p ON p.id = c.principal_id
LEFT JOIN users u ON u.id = p.user_id WHERE {VISIBLE})"
))?;
stmt.query_row([token], map_pim_link).optional()
}
@@ -2203,13 +2210,16 @@ impl Db {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
apply_ops(&tx, ops)?;
let deleted = tx.execute(
if tx.execute(
"DELETE FROM principals WHERE id = ?1 AND user_id IS NULL",
[id],
)? > 0;
)? == 0
{
return Ok(false);
}
tx.commit()?;
self.forget_defaults();
Ok(deleted)
Ok(true)
}
/// A principal by id, a disabled account's too.
Mserver/tests/api_dav.rs
@@ -564,6 +564,18 @@ async fn a_protected_share_asks_for_its_password_over_basic() {
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
}
#[tokio::test]
async fn a_share_password_with_edge_spaces_opens_the_mount() {
let env = Env::new().await;
let admin = env.admin().await;
let (token, _) = share(&admin, "docs", false, Some(" sharepass1 ")).await;
let url = format!("/dav-share/{token}/");
for pw in [" sharepass1 ", "sharepass1"] {
let r = dav(&env, "PROPFIND", &url, Some(&basic("", pw)), b"").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{pw:?}: {}", r.text());
}
}
#[tokio::test]
async fn a_writable_share_can_be_written_and_expiry_ends_it() {
let env = Env::new().await;
Mserver/tests/api_pim.rs
@@ -392,6 +392,18 @@ async fn make_and_patch_collections() {
Some("Job")
);
let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop>
<d:displayname>Tab	bed</d:displayname></d:prop></d:set></d:propertyupdate>"#;
let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
let ms = parse_multistatus(&r);
assert!(ms[0].1.iter().all(|(c, _)| *c != 200), "{}", r.text());
let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, work, DAV, "displayname").as_deref(),
Some("Job")
);
let r = req(&env, "DELETE", work, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
@@ -1669,6 +1681,16 @@ async fn pruned_tombstones_invalidate_old_sync_tokens() {
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&mid)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let parse = |t: &str| {
let (id, seq) = t.rsplit_once(':').unwrap().1.rsplit_once('-').unwrap();
(id.parse::<i64>().unwrap(), seq.parse::<i64>().unwrap())
};
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());
}
#[tokio::test]
@@ -1770,7 +1792,7 @@ 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();
let changes = db.pim_changes(col.id, Some(before)).await.unwrap().unwrap();
assert!(changes.is_empty(), "{changes:?}");
}
@@ -1826,3 +1848,88 @@ async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
#[tokio::test]
async fn multiget_answers_each_href_once_and_caps_the_count() {
let (env, auth) = setup().await;
put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
let multiget = |hrefs: &[String]| {
let hrefs: String = hrefs
.iter()
.map(|h| format!("<d:href>{h}</d:href>"))
.collect();
format!(
r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/></d:prop>{hrefs}</c:calendar-multiget>"#
)
};
let todo = format!("{CAL}todo.ics");
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[],
&multiget(&[todo.clone(), todo.clone()]),
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
assert_eq!(statuses(&r).len(), 1, "{}", r.text());
let many: Vec<String> = (0..1001).map(|i| format!("{CAL}{i}.ics")).collect();
let r = req(&env, "REPORT", CAL, &auth, &[], &multiget(&many)).await;
let got = statuses(&r);
assert_eq!(got.len(), 1001);
assert_eq!(got.last().unwrap(), &(CAL.to_string(), Some(507)));
}
#[tokio::test]
async fn a_put_racing_the_collection_delete_never_fails_with_500() {
let (env, auth) = setup().await;
let col = "/pim/calendars/alice/race/";
for i in 0..20 {
let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let todo = ics(&TODO.replace("UID:todo", &format!("UID:race{i}")));
let path = format!("{col}{i}.ics");
let (put, delete) = tokio::join!(
req(&env, "PUT", &path, &auth, &[], &todo),
req(&env, "DELETE", col, &auth, &[], ""),
);
assert_eq!(delete.status, StatusCode::NO_CONTENT);
assert!(
[StatusCode::CREATED, StatusCode::CONFLICT].contains(&put.status),
"{}",
put.status
);
}
}
#[tokio::test]
async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500() {
use tower::ServiceExt;
let (env, auth) = setup().await;
let col = "/pim/calendars/alice/race/";
let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:x="urn:x"><d:set><d:prop>
<x:note>dead</x:note></d:prop></d:set></d:propertyupdate>"#;
let (send, arrive) = tokio::sync::oneshot::channel::<()>();
let body = axum::body::Body::from_stream(futures_util::stream::once(async move {
arrive.await.ok();
Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(patch))
}));
let request = axum::http::Request::builder()
.method("PROPPATCH")
.uri(col)
.header("host", "files.example.com")
.header("authorization", &auth)
.body(body)
.unwrap();
let pending = tokio::spawn(env.app.clone().oneshot(request));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let r = req(&env, "DELETE", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
send.send(()).unwrap();
let r = pending.await.unwrap().unwrap();
assert_eq!(r.status(), StatusCode::NOT_FOUND);
}
Mserver/tests/api_pim_io.rs
@@ -535,3 +535,143 @@ async fn a_skipped_import_into_a_new_user_leaves_only_the_default_calendar() {
.collect();
assert_eq!(slugs, ["default"]);
}
#[tokio::test]
async fn collection_names_and_descriptions_are_checked() {
let io = Io::new().await;
let create = |body: Value| {
let client = &io.alice;
async move { client.post_json("/api/pim/collections", &body).await }
};
let long = "x".repeat(257);
for name in [long.as_str(), "bell\u{7}"] {
let r = create(json!({"kind": "calendar", "name": name})).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{name:?}");
}
let r =
create(json!({"kind": "calendar", "name": "Ok", "description": "x".repeat(1025)})).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = create(json!({"kind": "calendar", "name": "Ok", "description": "two\nlines"})).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let id = r.json()["id"].as_i64().unwrap();
let url = format!("/api/pim/collections/{id}");
for body in [
json!({"name": long}),
json!({"description": "x".repeat(1025)}),
] {
let r = io.alice.put_json(&url, &body).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{body}");
}
// A file's own name that cannot be stored gives way to the file name.
let ics = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:{long}\r\nBEGIN:VEVENT\r\nUID:n1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let r = io
.alice
.raw(
Method::POST,
"/api/pim/import?kind=calendar&file=Trips.ics",
&[("content-type", "text/calendar")],
ics.into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["collection"]["name"], "Trips");
}
#[tokio::test]
async fn a_feed_password_matches_with_edge_spaces() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let locked = io.link(cal, json!({ "password": " feedpass123 " })).await;
for pw in [" feedpass123 ", "feedpass123"] {
let r = io.anon(&locked, &[("authorization", &basic("", pw))]).await;
assert_eq!(r.status, StatusCode::OK, "{pw:?}");
}
}
#[tokio::test]
async fn feeds_of_a_disabled_owner_stop() {
let io = Io::new().await;
let admin = login(&io.env, "admin", "admin1234").await;
let feed = io.link(io.id(CAL).await, json!({})).await;
let alice = user_id(&admin, "alice").await;
for (active, status) in [(false, StatusCode::NOT_FOUND), (true, StatusCode::OK)] {
let r = admin
.put_json(
&format!("/api/admin/users/{alice}"),
&json!({ "active": active }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(io.anon(&feed, &[]).await.status, status);
}
}
#[tokio::test]
async fn a_loan_to_a_disabled_user_takes_a_new_mode() {
let io = Io::new().await;
let admin = login(&io.env, "admin", "admin1234").await;
create_user(&admin, "carol", PW, &[]).await;
let id = io.id(CAL).await;
let shares = format!("/api/pim/collections/{id}/shares");
let r = io
.alice
.post_json(&shares, &json!({"user": "bob", "mode": "ro"}))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
for name in ["bob", "carol"] {
let uid = user_id(&admin, name).await;
let r = admin
.put_json(
&format!("/api/admin/users/{uid}"),
&json!({"active": false}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
}
let r = io
.alice
.post_json(&shares, &json!({"user": "bob", "mode": "rw"}))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["user_name"], "bob");
let list = io.alice.get(&shares).await.json();
assert_eq!(list[0]["mode"], "rw", "{list}");
// No new loan goes to a disabled account.
let r = io
.alice
.post_json(&shares, &json!({"user": "carol", "mode": "ro"}))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_import_racing_the_collection_delete_never_fails_with_500() {
let io = Io::new().await;
let url = "/pim/calendars/alice/race/";
for i in 0..20 {
let r = io.dav("alice", "MKCALENDAR", url, "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let path = format!("/api/pim/collections/{}/import", io.id(url).await);
let (import, delete) = tokio::join!(
io.alice.raw(
Method::POST,
&path,
&[("content-type", "text/calendar")],
event(&format!("race{i}"), "x", "").into_bytes(),
),
io.dav("alice", "DELETE", url, ""),
);
assert_eq!(delete.status, StatusCode::NO_CONTENT);
assert!(
[StatusCode::OK, StatusCode::NOT_FOUND].contains(&import.status),
"{}: {}",
import.status,
import.text()
);
}
}
Mserver/tests/api_pim_schedule.rs
@@ -1144,6 +1144,77 @@ async fn deleting_an_organizer_cancels_and_forgets_it() {
);
}
#[tokio::test]
async fn deleting_a_disabled_user_retracts_its_meetings() {
let disable_and_delete = async |pim: &Pim, user: &str| {
let id = user_id(&pim.admin, user).await;
let r = pim
.admin
.put_json(
&format!("/api/admin/users/{id}"),
&json!({ "active": false }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await;
assert_eq!(r.status, StatusCode::OK);
};
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
disable_and_delete(&pim, "bob").await;
let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
assert!(org.contains("PARTSTAT=DECLINED"), "{org}");
assert!(
pim.inbox("alice")
.await
.iter()
.any(|m| m.contains("METHOD:REPLY")),
"the decline reaches the organizer's inbox"
);
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
disable_and_delete(&pim, "alice").await;
assert!(
pim.inbox("bob")
.await
.iter()
.any(|m| m.contains("METHOD:CANCEL")),
"the cancel reaches the attendee's inbox"
);
}
#[tokio::test]
async fn a_delete_that_matches_nothing_writes_nothing() {
use server::db::{PimObject, PimOp};
let pim = Pim::new().await;
let db = &pim.env.state.db;
let pid = db
.principal_of(user_id(&pim.admin, "bob").await)
.await
.unwrap();
db.pim_ensure_defaults(pid).await.unwrap();
let cal = db
.pim_collection(pid, server::db::PimKind::Calendar, "default")
.await
.unwrap()
.unwrap();
let ops = [PimOp::Put {
collection_id: cal.id,
obj: PimObject {
name: "x.ics".into(),
uid: "x".into(),
component: "VEVENT".into(),
etag: "\"e\"".into(),
..Default::default()
},
data: b"x".to_vec(),
}];
assert!(!db.delete_user(i64::MAX, &ops).await.unwrap());
assert!(!db.delete_room(i64::MAX, &ops).await.unwrap());
assert!(db.pim_object(cal.id, "x.ics").await.unwrap().is_none());
}
#[tokio::test]
async fn find_uid_prefers_the_scheduling_copy() {
use server::db::{PimCollection, PimKind, PimObject, PimOp};
Mweb/src/i18n.rs
@@ -574,10 +574,21 @@ i18n_keys! {
PIM_ROOM_NAME = "pim_room_name" => "Name (part of its address, cannot change)",
PIM_ROOMS = "pim_rooms" => "Rooms and resources",
PIM_ROOMS_HINT = "pim_rooms_hint" => "Rooms and resources can be invited to meetings. They accept free times and decline busy ones on their own.",
PIM_RRULE_COUNT = "pim_rrule_count" => "{} times",
PIM_RRULE_DAILY = "pim_rrule_daily" => "Every day",
PIM_RRULE_DAILY_N = "pim_rrule_daily_n" => "Every {} days",
PIM_RRULE_LAST_DAY = "pim_rrule_last_day" => "last day",
PIM_RRULE_MONTH_DAY = "pim_rrule_month_day" => "day {}",
PIM_RRULE_MONTHLY = "pim_rrule_monthly" => "Every month",
PIM_RRULE_MONTHLY_N = "pim_rrule_monthly_n" => "Every {} months",
PIM_RRULE_NTH = "pim_rrule_nth" => "{} {}",
PIM_RRULE_ORD_1 = "pim_rrule_ord_1" => "1st",
PIM_RRULE_ORD_2 = "pim_rrule_ord_2" => "2nd",
PIM_RRULE_ORD_3 = "pim_rrule_ord_3" => "3rd",
PIM_RRULE_ORD_4 = "pim_rrule_ord_4" => "4th",
PIM_RRULE_ORD_5 = "pim_rrule_ord_5" => "5th",
PIM_RRULE_ORD_LAST = "pim_rrule_ord_last" => "last",
PIM_RRULE_UNTIL = "pim_rrule_until" => "until {}",
PIM_RRULE_WEEKLY = "pim_rrule_weekly" => "Every week",
PIM_RRULE_WEEKLY_N = "pim_rrule_weekly_n" => "Every {} weeks",
PIM_RRULE_YEARLY = "pim_rrule_yearly" => "Every year",
@@ -1437,10 +1448,21 @@ const DE: &[(&str, &str)] = &[
"pim_rooms_hint",
"Räume und Ressourcen können zu Terminen eingeladen werden. Sie nehmen freie Zeiten an und lehnen belegte selbst ab.",
),
("pim_rrule_count", "{}-mal"),
("pim_rrule_daily", "Jeden Tag"),
("pim_rrule_daily_n", "Alle {} Tage"),
("pim_rrule_last_day", "letzter Tag"),
("pim_rrule_month_day", "am {}."),
("pim_rrule_monthly", "Jeden Monat"),
("pim_rrule_monthly_n", "Alle {} Monate"),
("pim_rrule_nth", "{} {}"),
("pim_rrule_ord_1", "1."),
("pim_rrule_ord_2", "2."),
("pim_rrule_ord_3", "3."),
("pim_rrule_ord_4", "4."),
("pim_rrule_ord_5", "5."),
("pim_rrule_ord_last", "letzter"),
("pim_rrule_until", "bis {}"),
("pim_rrule_weekly", "Jede Woche"),
("pim_rrule_weekly_n", "Alle {} Wochen"),
("pim_rrule_yearly", "Jedes Jahr"),
@@ -2417,10 +2439,21 @@ const FR: &[(&str, &str)] = &[
"pim_rooms_hint",
"Les salles et ressources peuvent être invitées aux réunions. Elles acceptent les créneaux libres et refusent les occupés d'elles-mêmes.",
),
("pim_rrule_count", "{} fois"),
("pim_rrule_daily", "Tous les jours"),
("pim_rrule_daily_n", "Tous les {} jours"),
("pim_rrule_last_day", "dernier jour"),
("pim_rrule_month_day", "le {}"),
("pim_rrule_monthly", "Tous les mois"),
("pim_rrule_monthly_n", "Tous les {} mois"),
("pim_rrule_nth", "{} {}"),
("pim_rrule_ord_1", "1er"),
("pim_rrule_ord_2", "2e"),
("pim_rrule_ord_3", "3e"),
("pim_rrule_ord_4", "4e"),
("pim_rrule_ord_5", "5e"),
("pim_rrule_ord_last", "dernier"),
("pim_rrule_until", "jusqu'au {}"),
("pim_rrule_weekly", "Toutes les semaines"),
("pim_rrule_weekly_n", "Toutes les {} semaines"),
("pim_rrule_yearly", "Tous les ans"),
Mweb/src/views/calendar.rs
@@ -1287,39 +1287,126 @@ fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) {
}
}
/// `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` as words; an unknown FREQ stays raw.
/// `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` as words; a rule it cannot put into
/// words stays raw.
fn rrule_text(rule: &str) -> String {
rrule_words(rule, weekday_name, month_name, until_date).unwrap_or_else(|| rule.to_string())
}
fn rrule_words(
rule: &str,
day: fn(&str) -> Option<String>,
month: fn(u32) -> Option<String>,
date: fn(&str) -> Option<String>,
) -> Option<String> {
let part = |key: &str| {
rule.split(';')
.find_map(|p| p.strip_prefix(key)?.strip_prefix('='))
};
let list = |key: &str| part(key).into_iter().flat_map(|v| v.split(','));
let n = part("INTERVAL")
.and_then(|n| n.parse::<u32>().ok())
.unwrap_or(1);
let keys = match part("FREQ") {
let freq = part("FREQ");
let keys = match freq {
Some("DAILY") => (k::PIM_RRULE_DAILY, k::PIM_RRULE_DAILY_N),
Some("WEEKLY") => (k::PIM_RRULE_WEEKLY, k::PIM_RRULE_WEEKLY_N),
Some("MONTHLY") => (k::PIM_RRULE_MONTHLY, k::PIM_RRULE_MONTHLY_N),
Some("YEARLY") => (k::PIM_RRULE_YEARLY, k::PIM_RRULE_YEARLY_N),
_ => return rule.to_string(),
_ => return None,
};
let base = match n {
let mut text = match n {
1 => i18n::t(keys.0).to_string(),
n => i18n::t_fmt(keys.1, &n.to_string()),
};
// Plain weekdays of a weekly rule, e.g. "Every week: Tue, Thu".
let days: Vec<String> = match part("FREQ") {
Some("WEEKLY") => part("BYDAY")
.unwrap_or_default()
.split(',')
.filter_map(weekday_name)
.collect(),
_ => Vec::new(),
let numbered = matches!(freq, Some("MONTHLY" | "YEARLY"));
let worded = |key: &str| match key {
"FREQ" | "INTERVAL" | "UNTIL" | "COUNT" | "WKST" | "BYDAY" => true,
"BYMONTH" => freq == Some("YEARLY"),
"BYMONTHDAY" => numbered,
_ => false,
};
match days.is_empty() {
true => base,
false => format!("{base}: {}", days.join(", ")),
let all_worded = rule
.split(';')
.all(|p| p.is_empty() || p.split_once('=').is_some_and(|(key, _)| worded(key)));
// Without BYMONTH, a yearly BYDAY or BYMONTHDAY counts through the whole year.
let yearwide = freq == Some("YEARLY")
&& part("BYMONTH").is_none()
&& (part("BYDAY").is_some() || part("BYMONTHDAY").is_some());
if !all_worded || yearwide {
return None;
}
let mut on = Vec::new();
for m in list("BYMONTH") {
on.push(month(m.parse().ok()?)?);
}
for d in list("BYDAY") {
let split = d.len().checked_sub(2)?;
let (nth, code) = d.split_at_checked(split)?;
let name = day(code)?;
on.push(match nth {
"" => name,
_ if !numbered => return None,
nth => i18n::t_fmt2(k::PIM_RRULE_NTH, ordinal(nth)?, &name),
});
}
for d in list("BYMONTHDAY") {
on.push(match d {
"-1" => i18n::t(k::PIM_RRULE_LAST_DAY).to_string(),
d => i18n::t_fmt(k::PIM_RRULE_MONTH_DAY, &d.parse::<u8>().ok()?.to_string()),
});
}
if !on.is_empty() {
text = format!("{text}: {}", on.join(", "));
}
if let Some(u) = part("UNTIL") {
text = format!("{text}; {}", i18n::t_fmt(k::PIM_RRULE_UNTIL, &date(u)?));
} else if let Some(c) = part("COUNT") {
text = format!(
"{text}; {}",
i18n::t_fmt(k::PIM_RRULE_COUNT, &c.parse::<u32>().ok()?.to_string())
);
}
Some(text)
}
/// The ordinal of `2TU` or `-1FR`. Others are rare enough to stay raw.
fn ordinal(nth: &str) -> Option<&'static str> {
Some(i18n::t(match nth {
"1" | "+1" => k::PIM_RRULE_ORD_1,
"2" | "+2" => k::PIM_RRULE_ORD_2,
"3" | "+3" => k::PIM_RRULE_ORD_3,
"4" | "+4" => k::PIM_RRULE_ORD_4,
"5" | "+5" => k::PIM_RRULE_ORD_5,
"-1" => k::PIM_RRULE_ORD_LAST,
_ => return None,
}))
}
fn month_name(m: u32) -> Option<String> {
let m = (1..=12).contains(&m).then_some(m)?;
let date = js_sys::Date::new_with_year_month_day(2024, m as i32 - 1, 1);
Some(intl(&date, &[("month", "long")]))
}
/// UNTIL (`20261231` or `20261231T225959Z`) as a local date.
fn until_date(u: &str) -> Option<String> {
let num = |r: std::ops::Range<usize>| u.get(r)?.parse::<u32>().ok();
let (y, m, d) = (num(0..4)?, num(4..6)?, num(6..8)?);
let date = match u.get(8..)? {
t if t.len() == 8 && t.is_ascii() && t.ends_with('Z') => {
let iso = format!(
"{y:04}-{m:02}-{d:02}T{}:{}:{}Z",
&t[1..3],
&t[3..5],
&t[5..7]
);
js_sys::Date::new(&iso.into())
}
// A DATE, or floating: the wall-clock date.
_ => js_sys::Date::new_with_year_month_day(y, m as i32 - 1, d as i32),
};
(!date.get_time().is_nan()).then(|| intl(&date, &[("dateStyle", "medium")]))
}
/// A weekly rule without BYDAY repeats on its start's weekday. Naming it
@@ -1333,7 +1420,7 @@ fn with_start_day(rule: &str, first: i64) -> String {
format!("{rule};BYDAY={code}")
}
/// `TU` as the locale's short weekday name. Numbered days (`2TU`) are skipped.
/// `TU` as the locale's short weekday name.
fn weekday_name(code: &str) -> Option<String> {
let offset = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
.iter()
@@ -1378,7 +1465,10 @@ fn EventDialog(
let t = target.clone();
move || {
let t = t.clone();
let seq = load_seq.get_value() + 1;
// Also runs after an await, when the view may be gone.
let Some(seq) = load_seq.try_get_value().map(|s| s + 1) else {
return;
};
load_seq.set_value(seq);
spawn_local(async move {
let r = api::pim_object(
@@ -1462,9 +1552,15 @@ fn EventDialog(
<dt>{i18n::t(k::PIM_WHEN)}</dt>
<dd>{fmt_span(&span_at(s, e, d.all_day))}</dd>
})}
{d.rrule.clone().map(|r| view! {
<dt>{i18n::t(k::PIM_REPEATS)}</dt>
<dd title=r.clone()>{rrule_text(&r)}</dd>
{d.rrule.clone().map(|r| {
let text = match d.start.as_deref().zip(d.end.as_deref()) {
Some((s, e)) => rrule_text(&with_start_day(&r, span_at(s, e, d.all_day).first)),
None => rrule_text(&r),
};
view! {
<dt>{i18n::t(k::PIM_REPEATS)}</dt>
<dd title=r.clone()>{text}</dd>
}
})}
{d.location.clone().map(|l| view! {
<dt>{i18n::t(k::PIM_LOCATION)}</dt>
@@ -1694,6 +1790,55 @@ mod tests {
assert_eq!(with_start_day("FREQ=DAILY", friday), "FREQ=DAILY");
}
#[test]
fn a_rule_reads_as_words() {
let words = |r: &str| {
rrule_words(
r,
|d| Some(d.to_string()),
|m| Some(format!("M{m}")),
|u| Some(u[..8].to_string()),
)
};
assert_eq!(
words("FREQ=WEEKLY;BYDAY=TU,TH").as_deref(),
Some("Every week: TU, TH")
);
assert_eq!(
words("FREQ=MONTHLY;BYDAY=2TU;COUNT=3").as_deref(),
Some("Every month: 2nd TU; 3 times")
);
assert_eq!(
words("FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=15,-1;UNTIL=20261231T225959Z").as_deref(),
Some("Every 2 months: day 15, last day; until 20261231")
);
assert_eq!(
words("FREQ=YEARLY;BYMONTH=5;BYDAY=-1SU").as_deref(),
Some("Every year: M5, last SU")
);
assert_eq!(words("FREQ=MONTHLY;BYDAY=-2FR"), None);
assert_eq!(words("FREQ=WEEKLY;BYDAY=2TU"), None);
assert_eq!(words("FREQ=HOURLY"), None);
for raw in [
"FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1",
"FREQ=MONTHLY;BYMONTH=6;BYMONTHDAY=1",
"FREQ=WEEKLY;BYMONTHDAY=15",
"FREQ=DAILY;BYMONTHDAY=1",
"FREQ=YEARLY;BYMONTHDAY=15",
"FREQ=YEARLY;BYDAY=MO",
"FREQ=YEARLY;BYYEARDAY=100",
"FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO",
"FREQ=DAILY;BYHOUR=9,17",
"FREQ=WEEKLY;X-FOO=1",
] {
assert_eq!(words(raw), None, "{raw}");
}
assert_eq!(
words("FREQ=WEEKLY;WKST=SU;BYDAY=MO;").as_deref(),
Some("Every week: MO")
);
}
#[test]
fn a_bar_hidden_on_a_busy_day_splits() {
let week = grid_start(2026, 10, 1);
Mweb/src/views/contacts.rs
@@ -12,7 +12,7 @@ use crate::api::{
use crate::components::icon::icon_svg;
use crate::i18n::{self, k};
use crate::icons::IconName;
use crate::router::{self, Location};
use crate::router::{self, Location, Section};
use crate::util::intl;
use crate::views::calendar::linkified;
use crate::views::pim::{browser_tz, display_name, safe_color};
@@ -134,15 +134,20 @@ pub fn ContactsMain(
// One request per pause in typing, and no history entry per key.
timer.set_value(Some(gloo_timers::callback::Timeout::new(300, move || {
let mut l = loc.get_untracked();
if l.section != Section::Contacts {
return;
}
l.search = v.trim().to_string();
router::replace(&l);
})));
};
// Back and Forward change the query from outside the box.
// Back and Forward change the query from outside the box. A pending
// timer would undo that.
Effect::new(move |_| {
let q = query.get();
if input.get_untracked().trim() != q {
timer.set_value(None);
input.set(q);
}
});
@@ -174,7 +179,8 @@ pub fn ContactsMain(
&& !hidden.with_untracked(|h| h.contains(&id))
&& !list.iter().any(|c| c.collection_id == id && c.name == name)
{
router::close_open();
// Not Back: that entry holds the query from before the typing.
router::clear_open();
}
let _ = contacts.try_set(Some(r.map_err(|e| e.to_string())));
});
Mweb/src/views/pim.rs
@@ -159,7 +159,10 @@ pub fn PimView(
let reload_seq = StoredValue::new(0u32);
let reload = Callback::new(move |_| {
set_load_err.set(None);
let seq = reload_seq.get_value() + 1;
// Also runs after an await, when the view may be gone.
let Some(seq) = reload_seq.try_get_value().map(|s| s + 1) else {
return;
};
reload_seq.set_value(seq);
spawn_local(async move {
let r = api::pim_collections().await;
@@ -762,7 +765,10 @@ fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
// Borrowers drop out of the candidates, so both lists reload together.
let load_seq = StoredValue::new(0u32);
let load = move || {
let seq = load_seq.get_value() + 1;
// Also runs after an await, when the view may be gone.
let Some(seq) = load_seq.try_get_value().map(|s| s + 1) else {
return;
};
load_seq.set_value(seq);
spawn_local(async move {
let latest = move || load_seq.try_get_value() == Some(seq);
@@ -929,9 +935,19 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
let (custom, set_custom) = signal(String::new());
let (password, set_password) = signal(String::new());
let (busy, set_busy) = signal(false);
let load_seq = StoredValue::new(0u32);
let load = move || {
// Also runs after an await, when the view may be gone.
let Some(seq) = load_seq.try_get_value().map(|s| s + 1) else {
return;
};
load_seq.set_value(seq);
spawn_local(async move {
match api::pim_links(id).await {
let links = api::pim_links(id).await;
if load_seq.try_get_value() != Some(seq) {
return;
}
match links {
Ok(v) => {
let _ = set_links.try_set(Some(v));
}