CalDAV/CardDAV and WebDAV review fixes, round 2

- XML request bodies deeper than 64 elements are refused before the tree
  is built, on the PIM routes and on /dav and /dav-share; a deep body
  overflowed the stack and aborted the server
- Calendar objects nested more than 4 levels below VCALENDAR fail
  valid-calendar-data; forbidden XML control characters become U+FFFD
  in responses
- A REPLY needs an attendee listed in the organizer object, else 3.8 and
  no inbox entry; it adds an override only for a real instance
- Import validates outside the scheduling lock; on a plain rw loan it
  skips meetings and entries that would replace one
- MOVE with Overwrite onto a meeting answers 403
- Loans of disabled owners are hidden; collection patches write only the
  changed columns; principal and home client properties reach only
  accounts that may change them
- DTSTART counts as the first of COUNT (RFC 5545 3.3.10, as ical.js);
  a UTC UNTIL on an all-day series keeps its last day in any zone
- Event detail returns the opened instance's start and end, also for
  THISANDFUTURE moves; the dialog uses them
- Web: custom expiry without a date is refused, collection reload has a
  sequence guard, PageUp/PageDown act only with focus in the grid, share
  rows wrap on phones
- Import split is linear; tests and docs for each change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit588ef2d8ceb8414746f0a9d7d71ccb5637a518fd
Parent2a3f377
30 files changed, 706 insertions(+), 160 deletions(-)
▾MCargo.lock
@@ -2318,6 +2318,7 @@ dependencies = [
"rrule",
"sha2 0.11.0",
"unicode-normalization",
"xml",
"xmltree",
]
▾MREADME.md
@@ -217,6 +217,8 @@ Notes:
- If another client has a file locked, your write waits or fails. An abandoned
lock clears after ten minutes, and a server restart clears all of them.
- If two people save the same file at once, the last save wins.
- An XML request (PROPFIND, PROPPATCH, LOCK) can be 64 KiB and 64
elements deep.
## Calendars and contacts
@@ -301,7 +303,8 @@ The Calendar page has three views:
days, timed events show their start. A day with more events than fit
shows "+N more". A click on a day lists its events below the grid. On a
phone the days show colored dots instead, and the list below the grid
shows the chosen day. PageUp and PageDown turn the month.
shows the chosen day. While a day has focus, PageUp and PageDown turn
the month.
- **Agenda**: today and the next six days, day by day. The month view is
for browsing further.
- **Invitations**: the invitations you have not answered, with Accept,
@@ -411,6 +414,8 @@ itself. You need write access: your own collection, or a `rw` or
RFC 6638 allows one copy per UID.
- An import never sends invitations or answers, also not for meetings
with attendees. A later change in an app schedules as usual.
- On a `rw` loan, meetings are skipped, and so are entries that would
replace one. Changing a meeting needs `rw+schedule`.
- The file's calendar name and `X-WR-TIMEZONE` are dropped. Times without
a zone then follow the calendar's own time zone.
@@ -488,8 +493,8 @@ for the old one.
### Limits
- One calendar entry or contact can be 10 MiB. An XML request can be
1 MiB. An imported file can be 20 MiB. Files that are not UTF-8 are
read as Latin-1.
1 MiB and 64 elements deep. An imported file can be 20 MiB. Files that
are not UTF-8 are read as Latin-1.
- An inbox keeps its newest 100 messages. The meetings themselves stay in
the calendar.
- A repeating event returned as single instances can have at most 10,000
▾Mapi-types/src/lib.rs
@@ -719,6 +719,9 @@ pub struct PimEventDetail {
pub status: Option<String>,
pub transparent: bool,
pub all_day: bool,
/// The instance's times as in [`PimInstance`]. `None` without DTSTART.
pub start: Option<String>,
pub end: Option<String>,
pub categories: Vec<String>,
/// The RRULE of the series, e.g. `FREQ=WEEKLY;BYDAY=MO`.
pub rrule: Option<String>,
▾Mpimdav/Cargo.toml
@@ -19,3 +19,5 @@ sha2 = "0.11"
# NFKD for the i;unicode-casemap collation (RFC 5051), the CardDAV default.
unicode-normalization = "0.1"
xmltree = "0.12"
# Streams a body once to bound its depth before xmltree builds it.
xml = "1"
▾Mpimdav/README.md
@@ -45,11 +45,16 @@ zero-length instance overlaps if it starts inside the window.
- DTSTART is always an instance, even when it does not match the RRULE.
The exception: every RRULE has an UNTIL before DTSTART. Then the series
has no instances from its rules and no DTSTART.
- DTSTART is the first of COUNT, also when the rule skips it (RFC 5545,
3.3.10). ical.js counts the same way; dateutil adds DTSTART on top.
- A UTC UNTIL is converted to wall time in the zone of DTSTART before the
comparison. The rule iterates in wall time. Without the conversion, a
series east of UTC loses its last instance.
- A DATE UNTIL on a DATE-TIME series includes that whole day. A floating
UNTIL on a zoned series counts as wall time.
- A UTC UNTIL on an all-day series includes a whole day: the later of its
UTC date and its date in the floating zone. Clients write midnight in
either zone.
- COUNT and UNTIL together are invalid. We ignore COUNT.
- EXRULE (RFC 2445) is applied.
- Several RRULEs are united.
@@ -173,6 +178,12 @@ rule with BYMONTH, that is `1TU` to `5TU`. Otherwise it is `1TU` to `53TU`.
Not checked: DTEND together with DURATION, DUE together with DURATION,
and a VTIMEZONE for every TZID. Clients get these wrong often enough
that rejecting them would lose data.
- Components nested more than 4 levels below VCALENDAR fail
`valid-calendar-data`. The deepest standard case, VEVENT > PARTICIPANT >
VLOCATION, has 3. Scheduling and rendering recurse once per level.
- XML 1.0 forbids most control characters, also as references. Stored
text keeps them. In a response they become U+FFFD, so one object cannot
break a whole multistatus.
### Text matching
@@ -303,13 +314,15 @@ server. The caller maps addresses onto its principals through closures.
sender organizes. A UID proves nothing, since anyone can pick any UID:
if the recipient holds the UID in any other object, the message is not
delivered and that object stays untouched. A reply likewise reaches
only the organizer's own object.
only the organizer's own object, and only from an attendee listed
there. Otherwise it gets 3.8 and leaves no inbox entry.
- An update keeps the attendee's alarms, TRANSP, PERCENT-COMPLETE and
COMPLETED.
- A CANCEL sets STATUS:CANCELLED on the copy. Nothing is deleted.
- An attendee who deleted their copy gets it back with the next organizer
update that is not quiet.
- A REPLY for one instance creates an override in the organizer object.
- A REPLY for one instance creates an override in the organizer object,
if the series has that instance.
- An update that only changes other attendees' answers is quiet. It keeps
the copy's Schedule-Tag and leaves no inbox entry.
@@ -533,6 +546,8 @@ python-caldav against the server.
properties. Past that the property gets 507.
- A borrower reads the owner's properties of a lent collection and cannot
change them. Admins change those of rooms and resources.
- The properties of a principal or a home reach only the accounts that
may change them. Other accounts see the live properties only.
### vCard versions
@@ -589,6 +604,7 @@ python-caldav against the server.
| Unknown property in PROPPATCH or MKCALENDAR | Stored | RFC 4918 allows dead properties; Apple fails on a 403 |
| TZID property with escaped commas | Unescaped as TEXT | The property is TEXT; the TZID parameter holds the plain value, so Outlook's `Athens\, Bucharest` must match `"Athens, Bucharest"` |
| Import with X-WR-TIMEZONE | Dropped | It is not standard; floating times follow the collection instead |
| MOVE with Overwrite onto a meeting | 403 | The meeting would vanish without a CANCEL; clients fall back to PUT and DELETE |
| MOVE into another owner's collection | 403 | UIDs are unique per owner and a meeting stays in its organizer's calendars; clients fall back to PUT and DELETE |
| A deleted principal in other principals' objects | Tombstone address, SCHEDULE-STATUS 3.7, organized copies cancelled | No RFC covers it; a same-named new principal must not inherit meetings |
| Age in a birthday event | Birth year in the summary, no age | One recurring event cannot carry an age that changes each year |
▾Mpimdav/src/bundle.rs
@@ -126,6 +126,9 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
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();
// 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") {
let (props, children) = split_level(inner(&cal.lines));
header.get_or_insert_with(|| {
@@ -158,14 +161,18 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
(uid, text)
}
};
match groups.iter_mut().find(|g| g.0 == uid) {
// A file that holds the same component twice.
Some(g) if g.1.contains(&text) => {}
Some(g) => {
g.1.push_str(&text);
g.2.extend(tzids);
if !seen.insert(text.clone()) {
continue;
}
match by_uid.get(&uid) {
Some(&i) => {
groups[i].1.push_str(&text);
groups[i].2.extend(tzids);
}
None => {
by_uid.insert(uid.clone(), groups.len());
groups.push((uid, text, tzids));
}
None => groups.push((uid, text, tzids)),
}
}
}
▾Mpimdav/src/expand.rs
@@ -472,6 +472,14 @@ fn occurrences_capped(
list.pop();
out.truncated = true;
}
// DTSTART is the first of COUNT (RFC 5545, 3.3.10), also when the rule
// skips it.
if let Some(n) = rule.count.filter(|_| until.is_none()).map(|n| n as usize)
&& list.first() != Some(&m.start.local)
&& list.len() >= n
{
list.truncate(n.saturating_sub(1));
}
list
}
@@ -493,7 +501,13 @@ fn until_local(u: &PartialDateTime, start: &Stamp) -> Option<NaiveDateTime> {
Some(match dt.offset {
Some(o) => {
let utc = dt.date_time - TimeDelta::seconds(o.local_minus_utc().into());
start.zone.to_local(utc.and_utc())
let local = start.zone.to_local(utc.and_utc());
match start.date {
// Clients write midnight in UTC or in their own zone. The
// later date is the last day either way.
true => utc.date().max(local.date()).and_hms_opt(23, 59, 59)?,
false => local,
}
}
// A DATE on a DATE-TIME series includes that whole day.
None if u.hour.is_none() && !start.date => dt.date_time.date().and_hms_opt(23, 59, 59)?,
▾Mpimdav/src/itip.rs
@@ -637,13 +637,23 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
Some(at) => at,
// A reply for one instance of the series gets its own override.
None => {
let (Some(_), Some(rid), Some(master)) = (
let (Some(t), Some(rid), Some(master)) = (
key,
rc.c.property(&ICalendarProperty::RecurrenceId),
next.master(),
) 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 {
continue;
}
let inst = next.instance(master, rid);
next.root.children.push(inst);
next.root.children.len() - 1
▾Mpimdav/src/object.rs
@@ -1,6 +1,6 @@
//! Validation of the calendar and address objects clients PUT.
use calcard::icalendar::{ICalendarComponentType, ICalendarProperty};
use calcard::icalendar::{ICalendar, ICalendarComponentType, ICalendarProperty};
use calcard::{Entry, Parser};
use chrono::{DateTime, Utc};
use xmltree::Element;
@@ -58,6 +58,9 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
if root.has_property(&ICalendarProperty::Method) {
return Err(Invalid::CalendarResource);
}
if too_deep(&cal) {
return Err(Invalid::CalendarData);
}
let mut found: Option<CalendarObject> = None;
for c in root
.component_ids
@@ -102,6 +105,27 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
Ok(found)
}
/// Levels below VCALENDAR, as in VEVENT > PARTICIPANT > VLOCATION, with
/// room to spare. Scheduling and rendering recurse once per level.
const MAX_NESTING: usize = 4;
fn too_deep(cal: &ICalendar) -> bool {
let mut stack = vec![(0, 0)];
while let Some((i, depth)) = stack.pop() {
if depth > MAX_NESTING {
return true;
}
let ids = cal.components.get(i).map_or(&[][..], |c| &c.component_ids);
stack.extend(
ids.iter()
.map(|&id| id as usize)
.filter(|&id| id > i)
.map(|id| (id, depth + 1)),
);
}
false
}
/// 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/report.rs
@@ -58,7 +58,7 @@ pub enum Report {
}
pub fn parse(body: &[u8]) -> Result<Report, Refused> {
let root = Element::parse(body).map_err(|_| Refused::Invalid)?;
let root = crate::xml::tree(body).map_err(|_| Refused::Invalid)?;
let hrefs = || {
elements(&root)
.filter(|e| Name::of(e).is(DAV, "href"))
▾Mpimdav/src/view.rs
@@ -4,8 +4,9 @@ use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendarProperty, ICalendarValue,
};
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use crate::expand::{Instance, expand};
use crate::itip::Is;
use crate::text::{logical_lines, name, param, param_parts, unescape_text, unfold, value};
use crate::zone::{Zone, Zones};
@@ -164,6 +165,55 @@ pub fn component_for(
.map(|(i, _)| i)
}
/// The instance at `recurrence_id`, else the first of a non-recurring
/// object. `index` is the component [`component_for`] picked.
pub fn instance_for(
cal: &ICalendar,
index: usize,
recurrence_id: Option<DateTime<Utc>>,
floating: &Zone,
) -> Option<Instance> {
let c = cal.components.get(index)?;
let zones = Zones::new(cal, floating.clone());
let window = match recurrence_id {
// A THISANDFUTURE override moves the later instances by its offset.
Some(rid) if !c.has_property(&ICalendarProperty::RecurrenceId) => {
let offsets: Vec<TimeDelta> = cal
.components
.iter()
.filter_map(|o| {
let r = o.property(&ICalendarProperty::RecurrenceId)?;
r.parameter(&ICalendarParameterName::Range)?;
let start = instant(&zones, o.property(&ICalendarProperty::Dtstart)?)?;
Some(start - instant(&zones, r)?)
})
.collect();
let low = offsets
.iter()
.copied()
.min()
.unwrap_or_default()
.min(TimeDelta::zero());
let high = offsets
.iter()
.copied()
.max()
.unwrap_or_default()
.max(TimeDelta::zero());
rid + low..rid + high + TimeDelta::seconds(1)
}
// An override, or an object that does not recur, starts at its DTSTART.
_ => {
let at = instant(&zones, c.property(&ICalendarProperty::Dtstart)?)?;
at..at + TimeDelta::seconds(1)
}
};
expand(cal, window, floating.clone())
.instances
.into_iter()
.find(|i| recurrence_id.is_none() || i.recurrence_id == recurrence_id)
}
/// A date or date-time property as a UTC instant. Floating times and dates
/// are read in the floating zone of `zones`.
pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option<DateTime<Utc>> {
▾Mpimdav/src/xml.rs
@@ -97,7 +97,7 @@ pub fn update(body: &[u8]) -> Result<Update, Invalid> {
if body.iter().all(u8::is_ascii_whitespace) {
return Ok(out);
}
let root = Element::parse(body).map_err(|_| Invalid)?;
let root = tree(body)?;
let expected = [
(DAV, "propertyupdate"),
(CALDAV, "mkcalendar"),
@@ -117,8 +117,36 @@ pub fn update(body: &[u8]) -> Result<Update, Invalid> {
Ok(out)
}
/// Nesting allowed in a request body. Building and dropping the tree
/// recurse once per level, so a deep body would overflow the stack.
const MAX_DEPTH: usize = 64;
/// Whether `body` nests elements deeper than [`MAX_DEPTH`]. Malformed XML is
/// left to the parser.
pub fn too_deep(body: &[u8]) -> bool {
let mut depth = 0;
for e in xml::reader::EventReader::new(body) {
match e {
Ok(xml::reader::XmlEvent::StartElement { .. }) if depth == MAX_DEPTH => return true,
Ok(xml::reader::XmlEvent::StartElement { .. }) => depth += 1,
Ok(xml::reader::XmlEvent::EndElement { .. }) => depth -= 1,
Ok(_) => {}
Err(_) => return false,
}
}
false
}
/// A request body as a tree, refused when nested deeper than [`MAX_DEPTH`].
pub(crate) fn tree(body: &[u8]) -> Result<Element, Invalid> {
if too_deep(body) {
return Err(Invalid);
}
Element::parse(body).map_err(|_| Invalid)
}
fn parse(body: &[u8], ns: &str, local: &str) -> Result<Element, Invalid> {
let root = Element::parse(body).map_err(|_| Invalid)?;
let root = tree(body)?;
if Name::of(&root).is(ns, local) {
Ok(root)
} else {
@@ -329,6 +357,13 @@ fn escape(s: &str) -> String {
// A raw CR reaches the client as LF: XML parsers normalize line
// ends. iCalendar and vCard data need their CRLF.
'\r' => out.push_str("&#13;"),
// XML 1.0 forbids these even as character references.
'\u{0}'..='\u{8}'
| '\u{b}'
| '\u{c}'
| '\u{e}'..='\u{1f}'
| '\u{fffe}'
| '\u{ffff}' => out.push('\u{fffd}'),
_ => out.push(c),
}
}
▾Mpimdav/tests/corpus/deviations.tsv
@@ -49,12 +49,14 @@
162.ics 37 0f322c180f96334c TZID on a DATE value: RFC 5545 forbids applying it, oracle applies it
163.ics 1 51775e34bf91afe9 oracle fails (BadRuleStringFormat); unparseable UNTIL: rule dropped (reviewed)
167.ics 1938 0e0f79dd99b7c745 oracle fails (ValueError); agrees with calcard
181.ics 57 038dc38070901473 DTSTART not matching RRULE is the first of COUNT (RFC 5545 3.3.10, as ical.js); oracle adds it on top
198.ics 1 80ccc81a8af90b2b oracle fails (ValueError); agrees with calcard
210.ics 0 cbf29ce484222325 oracle fails (KeyError); agrees with calcard
217.ics 2139 8d434978b3cfcdf0 we apply EXRULE (RFC 2445), oracle ignores it
219.ics 302 5fe0b53ce6430ba9 TZID on a DATE value: RFC 5545 forbids applying it, oracle applies it
220.ics 4 b47c7c173cdf7129 oracle fails (KeyError); agrees with calcard
221.ics 49 780830fed4d2f99d TZID on a DATE value: RFC 5545 forbids applying it, oracle applies it
222.ics 24 d751973bd4e56d6d DTSTART not matching RRULE is the first of COUNT (RFC 5545 3.3.10, as ical.js); oracle adds it on top
225.ics 0 cbf29ce484222325 oracle fails (KeyError); agrees with calcard
253.ics 6 51cdddceec67f42f oracle fails (BrokenCalendarProperty); overrides with another UID are orphans (reviewed)
266.ics 0 cbf29ce484222325 oracle fails (ValueError); agrees with calcard
@@ -135,10 +137,12 @@
710.ics 395 c165cc477c8026f9 oracle fails (BrokenCalendarProperty); two DTSTART lines: we take the first (reviewed)
712.ics 15 68cce3e686e9f36a UNTIL form differs from DTSTART: we read a DATE as the whole day and floating as wall time
734.ics 1066 02c5e6614bcc7294 oracle fails (BadRuleStringFormat); agrees with calcard
739.ics 21 b6e0cb17686f5589 DTSTART not matching RRULE is the first of COUNT (RFC 5545 3.3.10, as ical.js); oracle adds it on top
745.ics 1 bf188eee93f02fd5 DTEND before DTSTART: we clamp the length to 0, oracle takes DTEND as start
752.ics 1 74d781665f807cb5 DTEND before DTSTART: we clamp the length to 0, oracle takes DTEND as start
772.ics 3 fee1b84c6335dfe1 override with its own RRULE: we show it, oracle drops it
788.ics 194 9b53013aec9467bb UNTIL form differs from DTSTART: we read a DATE as the whole day and floating as wall time
813.ics 10 d2a52d37afa06d24 DTSTART not matching RRULE is the first of COUNT (RFC 5545 3.3.10, as ical.js); oracle adds it on top
826.ics 0 cbf29ce484222325 oracle fails (KeyError); agrees with calcard
827.ics 0 cbf29ce484222325 oracle fails (KeyError); agrees with calcard
851.ics 529 c5ac4fd42f33ce94 TZID on a DATE value: RFC 5545 forbids applying it, oracle applies it
▾Mpimdav/tests/expand.rs
@@ -257,3 +257,48 @@ fn runaway_rule_is_truncated() {
);
assert!(out.truncated);
}
#[test]
fn utc_until_on_an_all_day_series_keeps_its_day() {
let body = event(
"a",
"DTSTART;VALUE=DATE:20240108\r\nRRULE:FREQ=DAILY;UNTIL=20240110T000000Z\r\n",
);
let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
let cal = ICalendar::parse(&ics).unwrap();
let west = Zone::Iana(chrono_tz::America::New_York);
let out = expand(&cal, utc("2024-01-01T00:00")..utc("2024-02-01T00:00"), west);
assert_eq!(out.instances.len(), 3);
}
#[test]
fn dtstart_is_the_first_of_count() {
// A Tuesday, with a rule for Mondays.
let body = event(
"a",
"DTSTART:20260106T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3\r\n",
);
let got = instances(&body, "2026-01-01T00:00", "2026-03-01T00:00");
assert_eq!(got.len(), 3, "{got:?}");
assert_eq!(got[0], "2026-01-06T10:00/2026-01-06T11:00");
}
#[test]
fn instance_for_finds_instances_moved_by_this_and_future() {
let body = event(
"a",
"DTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\n",
) + &event(
"a",
"RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nDURATION:PT2H\r\n",
);
let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
let cal = ICalendar::parse(&ics).unwrap();
let rid = Some(utc("2024-01-04T10: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), fmt(i.end)),
("2024-01-04T12:00".into(), "2024-01-04T14:00".into())
);
}
▾Mpimdav/tests/itip.rs
@@ -305,6 +305,34 @@ fn declining_one_instance() {
);
}
#[test]
fn reply_for_a_missing_instance_adds_nothing() {
let reply = |rid: &str| {
cal(&format!(
"METHOD:REPLY
BEGIN:VEVENT
UID:m1
DTSTAMP:20260101T000000Z
RECURRENCE-ID:{rid}
DTSTART:{rid}
ORGANIZER:{ALICE}
ATTENDEE;PARTSTAT=DECLINED:{BOB}
END:VEVENT
"
))
};
let mut org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
// A Tuesday, and a Monday after COUNT ends.
for rid in ["20260113T100000Z", "20260202T100000Z"] {
assert!(!itip::apply_reply(&mut org, &reply(rid), &is(BOB)), "{rid}");
}
assert!(itip::apply_reply(
&mut org,
&reply("20260112T100000Z"),
&is(BOB)
));
}
#[test]
fn attendees_may_not_move_the_meeting() {
let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
▾Mpimdav/tests/protocol.rs
@@ -91,6 +91,28 @@ fn multistatus_round_trips() {
);
}
#[test]
fn deep_bodies_and_control_characters() {
let deep = format!(
"<d:propfind xmlns:d=\"DAV:\"><d:prop>{}{}</d:prop></d:propfind>",
"<a>".repeat(20_000),
"</a>".repeat(20_000)
);
assert!(xml::propfind(deep.as_bytes()).is_err());
assert!(xml::update(deep.replace("propfind", "propertyupdate").as_bytes()).is_err());
let mut r = xml::Response::new("/a/");
r.push(200, xml::with_text(xml::el(DAV, "displayname"), "a\u{1}b"));
let out = xml::multistatus(&Name::new(DAV, "multistatus"), &[r]);
let root = Element::parse(out.as_bytes()).unwrap();
let response = xml::child(&root, DAV, "response").unwrap();
let prop = xml::child(xml::child(response, DAV, "propstat").unwrap(), DAV, "prop").unwrap();
assert_eq!(
xml::text(xml::child(prop, DAV, "displayname").unwrap()),
"a\u{fffd}b"
);
}
fn ics(body: &str) -> Vec<u8> {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
.into_bytes()
@@ -146,6 +168,17 @@ fn calendar_objects() {
(b"not a calendar".to_vec(), Invalid::CalendarData),
(vec![0xff, 0xfe], Invalid::CalendarData),
([ics(EVENT), ics(EVENT)].concat(), Invalid::CalendarResource),
(
ics(&EVENT.replace(
"END:VEVENT",
&format!(
"{}{}END:VEVENT",
"BEGIN:VALARM\r\n".repeat(5),
"END:VALARM\r\n".repeat(5)
),
)),
Invalid::CalendarData,
),
];
for (body, want) in cases {
assert_eq!(
▾Mserver/src/api/dav.rs
@@ -190,6 +190,22 @@ async fn serve(
return StatusCode::FORBIDDEN.into_response();
}
// `dav-server` reads every other body whole, up to this size, and its
// XML parser recurses once per level: a deep body overflows the stack.
let req = match req.method().as_str() {
"PUT" | "PATCH" => req,
_ => {
let (parts, body) = req.into_parts();
let Ok(body) = axum::body::to_bytes(body, 65_536).await else {
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
};
if pimdav::xml::too_deep(&body) {
return StatusCode::BAD_REQUEST.into_response();
}
Request::from_parts(parts, Body::from(body))
}
};
// Resolved *before* the operation, while the item still exists: once
// DELETE or MOVE has run there is no path left to look a share up by.
let vacating = matches!(req.method().as_str(), "DELETE" | "MOVE");
▾Mserver/src/api/pim.rs
@@ -1015,11 +1015,15 @@ impl Cx<'_> {
blocking(move || -> Reply { Ok(answer(&me, space.as_ref())) }).await
}
/// The client properties stored for a resource.
/// The client properties stored for a resource. Those of a principal or
/// home only reach the accounts that may write them: they hold another
/// account's client settings.
async fn dead_props(&self, res: &Res) -> Result<Vec<Element>, ApiError> {
let place = match res {
Res::Principal(p) => PropPlace::Principal(p.id),
Res::Home(_, _, place) => *place,
Res::Principal(p) if p.me || (self.me.admin && p.kind != UserType::Individual) => {
PropPlace::Principal(p.id)
}
Res::Home(_, _, place) if self.may_edit(self.space()) => *place,
Res::Collection(_, col) | Res::Inbox(col, _) if !generated(col.c.id) => {
PropPlace::Collection(col.c.id)
}
@@ -1463,6 +1467,7 @@ 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));
};
@@ -1508,7 +1513,7 @@ impl Cx<'_> {
let db = &self.state.db;
db.pim_patch(
place,
col.as_ref().map(|(_, c)| c),
before.as_ref().zip(col.as_ref().map(|(_, c)| c)),
&patch.set,
&patch.remove,
)
@@ -2679,6 +2684,15 @@ impl Cx<'_> {
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
// Overwriting a meeting would drop it without telling its attendees.
if overwrite
&& self
.member(&to.c, &to_name)
.await?
.is_some_and(|(o, _)| o.schedule_tag.is_some())
{
return Ok(status(StatusCode::FORBIDDEN));
}
let written = self
.state
.db
▾Mserver/src/api/pim_api.rs
@@ -43,6 +43,7 @@ use crate::api::pim::{
collection_href, delete_own, etag_of, generated, members_of,
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::api::pim_views;
use crate::auth;
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace, User};
use crate::error::{ApiError, AppState};
@@ -301,6 +302,7 @@ pub async fn update(
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let before = col.clone();
if let Some(name) = body.name {
let name = name.trim();
if name.is_empty() {
@@ -326,7 +328,7 @@ pub async fn update(
}
state
.db
.pim_patch(PropPlace::Collection(id), Some(&col), &[], &[])
.pim_patch(PropPlace::Collection(id), Some((&before, &col)), &[], &[])
.await?;
let url = collection_href(&auth.user.name, kind, &col.slug, None);
Ok(Json(info(&col, kind, url, &auth.user.name, None)))
@@ -821,9 +823,12 @@ pub async fn import(
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, parts).await?))
Ok(Json(
import_parts(&state, owner, kind, &col, may_schedule, parts).await?,
))
}
#[derive(serde::Deserialize)]
@@ -869,7 +874,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, parts).await;
let result = import_parts(&state, pid, kind, &col, true, 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.
@@ -908,21 +913,34 @@ 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.
async fn import_parts(
state: &AppState,
owner: i64,
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();
let checked = blocking(move || -> Result<_, ApiError> {
let supported: Vec<&str> = supported.iter().map(String::as_str).collect();
let now = chrono::Utc::now();
Ok(parts
.into_iter()
.map(|part| check_part(kind, &supported, now, part))
.collect::<Vec<_>>())
})
.await?;
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(state).await?;
let owner = dir
.get(owner)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let supported: Vec<&str> = col.components.split(',').collect();
let now = chrono::Utc::now();
let mut result = PimImportResult {
created: 0,
updated: 0,
@@ -942,32 +960,14 @@ async fn import_parts(
let mut names: HashMap<String, String> = HashMap::new();
let mut ops = Vec::new();
let (mut created, mut updated) = (0, 0);
for part in parts {
let checked = match kind {
PimKind::Calendar => object::calendar(part.as_bytes(), &supported)
.map(|o| (o.uid, o.component.to_string())),
PimKind::AddressBook => {
object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
}
};
let (uid, component) = match checked {
for part in checked {
let (uid, component, data) = match part {
Ok(v) => v,
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());
skip(uid, &invalid.condition().name);
Err((uid, reason)) => {
skip(uid, &reason);
continue;
}
};
let data = match kind {
PimKind::Calendar => {
object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
}
PimKind::AddressBook => part.into_bytes(),
};
let existing = match names.get(&uid) {
Some(name) => Some(name.clone()),
None => state.db.pim_uid_holder(col.id, &uid, "").await?,
@@ -985,6 +985,20 @@ async fn import_parts(
}
PimKind::AddressBook => None,
};
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,
@@ -1008,3 +1022,38 @@ async fn import_parts(
result.updated = updated;
Ok(result)
}
/// A skipped import part: its UID if readable, and the reason.
type Skip = (Option<String>, String);
/// One import part as `(uid, component, data)`, or why it is skipped.
fn check_part(
kind: PimKind,
supported: &[&str],
now: chrono::DateTime<chrono::Utc>,
part: String,
) -> Result<(String, String, Vec<u8>), Skip> {
let checked = match kind {
PimKind::Calendar => {
object::calendar(part.as_bytes(), supported).map(|o| (o.uid, o.component.to_string()))
}
PimKind::AddressBook => {
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 data = match kind {
PimKind::Calendar => {
object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
}
PimKind::AddressBook => part.into_bytes(),
};
Ok((uid, component, data))
}
▾Mserver/src/api/pim_schedule.rs
@@ -612,8 +612,11 @@ async fn reply_to(
) {
return Ok(NO_AUTHORITY);
}
let mut after = before.clone();
let replier = dir.is(attendee.id);
if !matches!(itip::role(&before, &replier), Ok(Role::Attendee)) {
return Ok(NO_AUTHORITY);
}
let mut after = before.clone();
if itip::apply_reply(&mut after, &m.cal, &replier) {
let data = after.to_string().into_bytes();
ops.push(PimOp::Put {
▾Mserver/src/api/pim_views.rs
@@ -225,6 +225,7 @@ pub async fn object(
let dir = Directory::load(&state).await?;
let owns = dir.is(owner);
let info = view::event_info(&cal, index, &owns);
let instance = view::instance_for(&cal, index, rid, &zone);
let answers = may_answer(&state, &auth, owner, col.id).await?;
let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee));
Ok(Json(PimObjectDetail::Event(PimEventDetail {
@@ -239,6 +240,8 @@ pub async fn object(
status: info.status,
transparent: info.transparent,
all_day: info.all_day,
start: instance.as_ref().map(|i| rfc3339(i.start)),
end: instance.as_ref().map(|i| rfc3339(i.end)),
categories: info.categories,
rrule: info.rrule,
organizer: info.organizer.as_ref().map(wire_person),
@@ -326,7 +329,7 @@ fn contact_detail(
/// Whether the signed-in user may answer invitations in a calendar of
/// `owner`: their own, or one lent with `rw+schedule`.
async fn may_answer(
pub(super) async fn may_answer(
state: &AppState,
auth: &SessionUser,
owner: i64,
▾Mserver/src/db.rs
@@ -1663,32 +1663,58 @@ impl Db {
Ok(true)
}
/// Writes the properties of `col`, if given, and the client properties of
/// `place` in one transaction. A collection counts it as a change.
/// Writes the properties `col` changes from `before` to `after`, if
/// given, and the client properties of `place`, in one transaction. A
/// collection counts it as a change. Unchanged columns keep what a
/// concurrent writer stored.
pub async fn pim_patch(
&self,
place: PropPlace,
col: Option<&PimCollection>,
col: Option<(&PimCollection, &PimCollection)>,
set: &[DeadProp],
remove: &[(String, String)],
) -> DbResult<()> {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
if let Some(col) = col {
tx.execute(
"UPDATE pim_collections SET displayname = ?2, description = ?3, color = ?4,
timezone = ?5, sort_order = ?6, transparent = ?7, seq = seq + 1
WHERE id = ?1",
params![
col.id,
col.displayname,
col.description,
col.color,
col.timezone,
col.sort_order,
col.transparent
],
)?;
if let Some((before, after)) = col {
let columns: [(&str, bool, &dyn rusqlite::ToSql); 6] = [
(
"displayname",
before.displayname != after.displayname,
&after.displayname,
),
(
"description",
before.description != after.description,
&after.description,
),
("color", before.color != after.color, &after.color),
(
"timezone",
before.timezone != after.timezone,
&after.timezone,
),
(
"sort_order",
before.sort_order != after.sort_order,
&after.sort_order,
),
(
"transparent",
before.transparent != after.transparent,
&after.transparent,
),
];
let mut sql = "UPDATE pim_collections SET seq = seq + 1".to_string();
let mut values: Vec<&dyn rusqlite::ToSql> = vec![&after.id];
for (name, changed, value) in columns {
if changed {
values.push(value);
sql.push_str(&format!(", {name} = ?{}", values.len()));
}
}
sql.push_str(" WHERE id = ?1");
tx.execute(&sql, values.as_slice())?;
}
write_props(&tx, place, set, remove)?;
tx.commit()?;
@@ -1895,7 +1921,8 @@ impl Db {
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN principals p ON p.id = c.principal_id
WHERE s.user_id = ?1 AND c.kind = ?2 AND c.id = ?3"
LEFT JOIN users u ON u.id = p.user_id
WHERE {VISIBLE} AND s.user_id = ?1 AND c.kind = ?2 AND c.id = ?3"
))?;
stmt.query_row(params![user_id, kind.as_str(), collection_id], map_shared)
.optional()
@@ -1913,7 +1940,8 @@ impl Db {
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN principals p ON p.id = c.principal_id
WHERE s.user_id = ?1 AND c.kind = ?2 ORDER BY c.id"
LEFT JOIN users u ON u.id = p.user_id
WHERE {VISIBLE} AND s.user_id = ?1 AND c.kind = ?2 ORDER BY c.id"
))?;
stmt.query_map(params![user_id, kind.as_str()], map_shared)?
.collect()
@@ -2739,6 +2767,34 @@ mod tests {
(db, admin)
}
#[tokio::test]
async fn pim_patch_keeps_concurrent_changes() {
let (db, admin) = db_with_admin().await;
let pid = db.principal_of(admin.id).await.unwrap();
db.pim_ensure_defaults(pid).await.unwrap();
let before = db.pim_collections(pid, PimKind::Calendar).await.unwrap()[0].clone();
let place = PropPlace::Collection(before.id);
let named = PimCollection {
displayname: Some("Work".into()),
..before.clone()
};
let colored = PimCollection {
color: Some("#ff0000".into()),
..before.clone()
};
// Two writers that both read `before`.
db.pim_patch(place, Some((&before, &named)), &[], &[])
.await
.unwrap();
db.pim_patch(place, Some((&before, &colored)), &[], &[])
.await
.unwrap();
let (_, _, now) = db.pim_collection_by_id(before.id).await.unwrap().unwrap();
assert_eq!(now.displayname.as_deref(), Some("Work"));
assert_eq!(now.color.as_deref(), Some("#ff0000"));
assert_eq!(now.seq, before.seq + 2);
}
#[tokio::test]
async fn fresh_db_state() {
let db = mem().await;
▾Mserver/tests/api_dav.rs
@@ -1405,3 +1405,13 @@ async fn an_encoded_slash_does_not_split_the_share_token() {
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text());
}
#[tokio::test]
async fn deep_xml_bodies_are_refused() {
let env = Env::new().await;
let (auth, _) = admin_dav(&env).await;
let body = format!("<d:propfind xmlns:d=\"DAV:\">{}", "<a>".repeat(21_000));
assert!(body.len() <= 65_536);
let r = dav(&env, "PROPFIND", "/dav", Some(&auth), body.as_bytes()).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
▾Mserver/tests/api_pim.rs
@@ -1596,3 +1596,48 @@ async fn bad_filters_are_refused_by_name() {
let r = req(&env, "REPORT", BOOK, &auth, &[], bad).await;
assert!(error_condition(&r).is(CARDDAV, "valid-filter"));
}
#[tokio::test]
async fn other_accounts_get_no_client_properties_or_loans_of_disabled_owners() {
let (env, admin, alice_auth, bob) = two_users().await;
let alice = login(&env, ALICE, PW).await;
let id = calendar_id(&alice).await;
let r = alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": BOB, "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let principal = "/pim/principals/alice/";
let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:x=\"urn:x\"><d:set><d:prop>\
<x:note>private</x:note></d:prop></d:set></d:propertyupdate>";
let r = req(&env, "PROPPATCH", principal, &alice_auth, &[], patch).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let body = propfind_body(&[("urn:x", "note")]);
let note = |auth: String| {
let (env, body) = (&env, &body);
async move {
req(env, "PROPFIND", principal, &auth, &[("depth", "0")], body)
.await
.text()
.contains("private")
}
};
assert!(note(alice_auth.clone()).await);
assert!(!note(bob.clone()).await);
let shared = format!("/pim/calendars/bob/shared-{id}/");
let status = || req(&env, "PROPFIND", &shared, &bob, &[("depth", "0")], "");
assert_eq!(status().await.status, StatusCode::MULTI_STATUS);
let uid = user_id(&admin, ALICE).await;
let r = admin
.put_json(
&format!("/api/admin/users/{uid}"),
&json!({"active": false}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(status().await.status, StatusCode::NOT_FOUND);
}
▾Mserver/tests/api_pim_schedule.rs
@@ -1043,3 +1043,83 @@ async fn invitations_go_to_the_chosen_calendar() {
r.text()
);
}
#[tokio::test]
async fn replies_need_a_listed_attendee() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
// carol brings in her own copy of alice's meeting, then answers it.
let copy = meeting("20260301T100000Z", &[&addr("carol")]);
let path = format!("{}meet.ics", cal("carol"));
pim.put_ok("carol", &path, &copy).await;
let answered = copy.replace("ATTENDEE;RSVP=TRUE:", "ATTENDEE;PARTSTAT=ACCEPTED:");
pim.put_ok("carol", &path, &answered).await;
assert!(
pim.get("carol", &path)
.await
.contains("ORGANIZER;SCHEDULE-STATUS=3.8")
);
assert!(pim.inbox("alice").await.is_empty());
let org = pim.get("alice", ALICE_EVENT).await;
assert!(!org.contains(&addr("carol")), "{org}");
}
#[tokio::test]
async fn move_does_not_overwrite_a_meeting() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let from = format!("{}p1.ics", cal("alice"));
pim.put_ok("alice", &from, plain).await;
let r = pim
.req("alice", "MOVE", &from, &[("destination", ALICE_EVENT)], "")
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert!(pim.get("alice", ALICE_EVENT).await.contains("UID:meet-1"));
}
#[tokio::test]
async fn imports_on_a_plain_loan_skip_meetings() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let alice = login(&pim.env, "alice", PW).await;
let listed = alice.get("/api/pim/collections").await.json();
let id = listed
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "calendar" && c["mode"].is_null())
.unwrap()["id"]
.as_i64()
.unwrap();
let r = alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": "carol", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let carol = login(&pim.env, "carol", PW).await;
let moved = meeting("20260302T100000Z", &[]);
let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let r = carol
.raw(
Method::POST,
&format!("/api/pim/collections/{id}/import"),
&[],
format!("{moved}{plain}").into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let result = r.json();
assert_eq!(
(result["created"].as_i64(), result["updated"].as_i64()),
(Some(1), Some(0))
);
assert_eq!(result["skipped"][0]["reason"], "need-privileges");
let org = pim.get("alice", ALICE_EVENT).await;
assert!(org.contains("DTSTART:20260301T100000Z"), "{org}");
}
▾Mserver/tests/api_pim_ui.rs
@@ -330,6 +330,21 @@ async fn object_detail_for_events_and_contacts() {
assert_eq!(d["attendees"][0]["is_owner"], true);
assert_eq!(d["can_edit"], true);
assert_eq!(d["can_reply"], false);
assert_eq!(d["start"], "2026-01-05T09:00:00Z");
let second = ui
.alice
.get(&format!(
"/api/pim/collections/{cal}/objects/m.ics?recurrence_id=2026-01-06T09:00:00Z"
))
.await
.json();
assert_eq!(
(&second["start"], &second["end"]),
(
&json!("2026-01-06T09:00:00Z"),
&json!("2026-01-06T10:00:00Z")
)
);
// Bob's copy: he may answer.
let bob = ui
.bob
▾Mweb/app.css
@@ -1667,6 +1667,10 @@ button:disabled {
min-width: 0;
}
.share-item-actions {
flex-wrap: wrap;
}
.shares-view .share-item-actions select {
flex: 1 1 auto;
min-width: 0;
▾Mweb/src/views/calendar.rs
@@ -266,8 +266,12 @@ fn day_rfc3339(day: i64) -> String {
}
fn span_of(i: &PimInstance) -> Span {
let s = js_sys::Date::new(&i.start.as_str().into());
let e = js_sys::Date::new(&i.end.as_str().into());
span_at(&i.start, &i.end, i.all_day)
}
fn span_at(start: &str, end: &str, all_day: bool) -> Span {
let s = js_sys::Date::new(&start.into());
let e = js_sys::Date::new(&end.into());
let first = js_day(&s);
let start_min = s.get_hours() * 60 + s.get_minutes();
let (last, end_min) = if e.get_time() > s.get_time() {
@@ -284,7 +288,7 @@ fn span_of(i: &PimInstance) -> Span {
Span {
first,
last: last.max(first),
all_day: i.all_day,
all_day,
start_min,
end_min,
}
@@ -592,22 +596,23 @@ pub fn CalendarMain(
})
};
// PageUp / PageDown turn the month, unless typing or a dialog is open.
// PageUp / PageDown turn the month while the grid has focus. Elsewhere
// they scroll the page to the day list.
owned_window_listener(leptos::ev::keydown, move |ev| {
let step = match ev.key().as_str() {
"PageUp" => -1,
"PageDown" => 1,
_ => return,
};
let busy = web_sys::window()
if ev.alt_key() || ev.ctrl_key() || ev.meta_key() || ev.shift_key() {
return;
}
let in_grid = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| {
d.query_selector("dialog[open], input:focus, textarea:focus")
.ok()
})
.flatten()
.and_then(|d| d.active_element())
.and_then(|e| e.closest(".cal-weeks").ok().flatten())
.is_some();
if let (CalView::Month(y, m), false) = (view.get_untracked(), busy) {
if let (CalView::Month(y, m), true) = (view.get_untracked(), in_grid) {
ev.prevent_default();
let (y, m) = add_months(y, m, step);
go(CalView::Month(y, m));
@@ -730,51 +735,18 @@ pub fn CalendarMain(
.into_any(),
};
// The times of the open event come from whichever list holds it.
let times = move |o: &Opened| -> Option<Span> {
let find = |list: &Option<Result<api::PimInstances, String>>| {
list.as_ref().and_then(|r| r.as_ref().ok()).and_then(|l| {
l.instances
.iter()
.find(|i| Opened::of(i) == *o)
.map(span_of)
})
};
month.with(find).or_else(|| agenda.with(find)).or_else(|| {
invitations.with(|i| {
i.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|l| {
l.iter().find(|v| {
v.collection_id == o.collection_id
&& v.name == o.name
&& v.recurrence_id == o.recurrence_id
})
})
.map(|v| span_of(&inv_instance(v)))
})
})
};
view! {
<div class="cal">
{toolbar}
{body}
</div>
{move || opened.get().map(|o| {
// Reactive: after a reload the lists arrive after the dialog opens.
let span = {
let o = o.clone();
Signal::derive(move || times(&o))
};
view! {
<EventDialog
target=o
span=span
collections=collections
on_replied=refresh
on_close=Callback::new(move |_| router::close_open())
/>
}
{move || opened.get().map(|o| view! {
<EventDialog
target=o
collections=collections
on_replied=refresh
on_close=Callback::new(move |_| router::close_open())
/>
})}
}
}
@@ -1273,7 +1245,6 @@ pub fn linkified(text: &str) -> impl IntoView + use<> {
#[component]
fn EventDialog(
target: Opened,
span: Signal<Option<Span>>,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_replied: Callback<()>,
on_close: Callback<()>,
@@ -1358,9 +1329,9 @@ fn EventDialog(
<p class="cal-flag">{i18n::t(k::PIM_CANCELLED)}</p>
})}
<dl class="cal-facts">
{move || span.get().map(|s| view! {
{d.start.as_deref().zip(d.end.as_deref()).map(|(s, e)| view! {
<dt>{i18n::t(k::PIM_WHEN)}</dt>
<dd>{fmt_span(&s)}</dd>
<dd>{fmt_span(&span_at(s, e, d.all_day))}</dd>
})}
{d.rrule.clone().map(|r| view! {
<dt>{i18n::t(k::PIM_REPEATS)}</dt>
▾Mweb/src/views/pim.rs
@@ -155,10 +155,17 @@ pub fn PimView(
let (collections, set_collections) = signal(Option::<Vec<PimCollectionInfo>>::None);
let (load_err, set_load_err) = signal(Option::<String>::None);
let hidden = RwSignal::new(load_hidden());
let reload_seq = StoredValue::new(0u32);
let reload = Callback::new(move |_| {
set_load_err.set(None);
let seq = reload_seq.get_value() + 1;
reload_seq.set_value(seq);
spawn_local(async move {
match api::pim_collections().await {
let r = api::pim_collections().await;
if reload_seq.try_get_value() != Some(seq) {
return;
}
match r {
Ok(v) => {
let _ = set_collections.try_set(Some(v));
}
@@ -886,11 +893,15 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
if busy.get() {
return;
}
let Ok(expires_at) = expiry_to_rfc3339(&choice.get(), &custom.get()) else {
show_error(toast, i18n::t(k::ERR_BAD_EXPIRES_AT));
return;
};
set_busy.set(true);
let pw = password.get().trim().to_string();
let body = CreatePimLink {
busy_only: busy_only.get(),
expires_at: expiry_to_rfc3339(&choice.get(), &custom.get()),
expires_at,
password: (!pw.is_empty()).then_some(pw),
};
spawn_local(async move {
▾Mweb/src/views/shares.rs
@@ -25,42 +25,31 @@ use crate::views::pim::{mode_label, webcal};
// Expiry helpers (compute the RFC 3339 expiry client-side)
// ---------------------------------------------------------------------------
/// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601).
fn ms_to_rfc3339(ms: f64) -> Option<String> {
if ms.is_nan() {
return None;
}
Some(
js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
.to_iso_string()
.into(),
)
}
/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
/// "never". Presets are computed from "now"; "custom" parses the
/// datetime-local value as the browser's local time.
pub(crate) fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
/// datetime-local value as the browser's local time. `Err` for a custom
/// choice without a valid date.
pub(crate) fn expiry_to_rfc3339(choice: &str, custom: &str) -> Result<Option<String>, ()> {
let now = js_sys::Date::now();
let h = 60.0 * 60.0 * 1000.0;
let target_ms: Option<f64> = match choice {
"never" => return None,
"1h" => Some(now + h),
"1d" => Some(now + 24.0 * h),
"1w" => Some(now + 7.0 * 24.0 * h),
"1mo" => Some(now + 30.0 * 24.0 * h),
"custom" => {
if custom.is_empty() {
None
} else {
// `Date.parse` treats a bare datetime as local time, so the
// result is already the correct UTC instant.
Some(js_sys::Date::parse(custom))
}
}
_ => return None,
let ms = match choice {
"1h" => now + h,
"1d" => now + 24.0 * h,
"1w" => now + 7.0 * 24.0 * h,
"1mo" => now + 30.0 * 24.0 * h,
// `Date.parse` treats a bare datetime as local time, so the result
// is already the correct UTC instant. An empty field gives NaN.
"custom" => js_sys::Date::parse(custom),
_ => return Ok(None),
};
target_ms.and_then(ms_to_rfc3339)
if ms.is_nan() {
return Err(());
}
Ok(Some(
js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
.to_iso_string()
.into(),
))
}
// ---------------------------------------------------------------------------
@@ -89,8 +78,11 @@ pub fn ShareDialog(
if busy.get() {
return;
}
let Ok(exp) = expiry_to_rfc3339(&choice.get(), &custom.get()) else {
show_error(toast, i18n::t(i18n::k::ERR_BAD_EXPIRES_AT));
return;
};
set_busy.set(true);
let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
let wr = writable.get();
let rid = root_id;
let p = path.clone();