CalDAV/CardDAV review fixes

- Scheduling delivery changes only the recipient's attendee copy of the
  sender's meeting; any other object with that UID stays untouched and
  the delivery gets SCHEDULE-STATUS 3.8. Replies reach only the
  organizer's own object. Covers REQUEST, CANCEL and room answers
- schedule-default-calendar-URL is settable on the inbox (schema v14,
  principals.default_calendar_id) and decides where invitations land
- MOVE between collections of different owners answers 403
- Deleting a calendar commits its cancellations and the delete together
- Generated collections ignore a sync limit; an initial sync no longer
  reports an object written during it as deleted
- vCard dates and colon-less lines no longer panic on non-ASCII bytes
- Collection list counts shares and links in one query; doc fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitfc5f7d12aaf1c7507366c2f10812eb51917f5ebe
Parentc477977
17 files changed, 538 insertions(+), 45 deletions(-)
▾MREADME.md
@@ -247,8 +247,9 @@ Apple Calendar and Contacts, Thunderbird, and DAVx5 on Android.
| `/pim/addressbooks/<name>/` | Your address books, the system address book, and address books lent to you |
Every account starts with a calendar named "Calendar" and an address book
named "Contacts". New invitations land in the oldest calendar that takes
events, so that calendar cannot be deleted.
named "Contacts". New invitations land in the calendar a client sets as
the default (`schedule-default-calendar-URL`), else in the oldest calendar
that takes events. That calendar cannot be deleted.
The **system address book** (`system`) lists every active account, room
and resource on the server. It is read-only and built by the server.
▾Mapi-types/src/lib.rs
@@ -170,7 +170,7 @@ pub const P_AROUND: &str = "around";
/// [`PIM_INSTANCES`]: the range, RFC 3339.
pub const P_FROM: &str = "from";
pub const P_TO: &str = "to";
/// [`PIM_INSTANCES`], [`PIM_PREVIEW`], object detail: the IANA zone that
/// [`PIM_INSTANCES`], object detail: the IANA zone that
/// all-day and floating times are read in. Default UTC.
pub const P_TZ: &str = "tz";
/// [`PIM_INSTANCES`], [`PIM_CONTACTS`]: comma-separated collection ids.
▾Mpimdav/README.md
@@ -236,6 +236,8 @@ RRULE is valid and common. The occurrence cap protects the CPU instead.
- Deleted members come back as 404 responses.
- With a limit, the oldest changes come first. The response adds a 507
for the collection and hands out the token of its last change.
- The generated collections ignore a limit and always answer in full.
They have no change log that a cut answer could resume from.
- The system address book has no change log. Its token is a hash of the
principal list, so it is known without building the cards. Only the
current token is valid. After any principal change, clients get
@@ -294,8 +296,14 @@ server. The caller maps addresses onto its principals through closures.
### Delivery into copies
- A new copy goes into the attendee's default calendar. That is their
oldest calendar that takes VEVENT.
- A new copy goes into the attendee's default calendar: the one set with
`schedule-default-calendar-URL` on the inbox, else the oldest calendar
that takes VEVENT. Only own calendars that take VEVENT can be set.
- A message changes only the recipient's attendee copy of a meeting the
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.
- An update keeps the attendee's alarms, TRANSP, PERCENT-COMPLETE and
COMPLETED.
- A CANCEL sets STATUS:CANCELLED on the copy. Nothing is deleted.
@@ -315,6 +323,7 @@ copy carries the status of its last reply on the ORGANIZER.
| 1.2 | Delivered to a local principal |
| 2.0 | The attendee replied. The reply's REQUEST-STATUS wins if present. |
| 3.7 | An address in our domains that names no one, or a disabled account |
| 3.8 | The recipient holds this UID in an object that is not its copy of the sender's meeting |
| 5.2 | An external address. There is no iMIP. |
| 5.3 | The recipient has no calendar for the component, for example a VTODO to a room |
@@ -580,6 +589,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 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/contact.rs
@@ -80,17 +80,19 @@ fn date(line: &str) -> Option<(Option<i32>, u32, u32)> {
}
let v = value(line).trim();
let v = v.split(['T', 't']).next()?;
// Byte offsets below: anything but ASCII digits would split a character.
let digits = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_digit());
let (year, md) = match v.strip_prefix("--") {
Some(md) => (None, md.replace('-', "")),
None => {
let d = v.replace('-', "");
if d.len() != 8 {
if !digits(&d, 8) {
return None;
}
(Some(d[..4].parse().ok()?), d[4..].to_string())
}
};
if md.len() != 4 {
if !digits(&md, 4) {
return None;
}
let (month, day) = (md[..2].parse().ok()?, md[2..].parse().ok()?);
▾Mpimdav/src/itip.rs
@@ -91,6 +91,14 @@ pub fn role(cal: &ICalendar, owner: Is) -> Result<Role, Refused> {
Ok(if attends { Role::Attendee } else { Role::None })
}
/// The ORGANIZER address of a scheduling object.
pub fn organizer(cal: &ICalendar) -> Option<&str> {
cal.components
.iter()
.filter(|c| is_scheduled(c))
.find_map(|c| address(c.property(&ICalendarProperty::Organizer)?))
}
/// An organizer's PUT (`new`) or DELETE (`None`) of a scheduling object.
/// `old` is the stored organizer object, if any. Returns what to store and
/// what to deliver.
▾Mpimdav/src/render.rs
@@ -408,7 +408,7 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
for raw in crate::text::logical_lines(text) {
let line = crate::text::unfold(raw);
let start = crate::text::value_start(&line);
let head = &line[..start.saturating_sub(1)];
let head = crate::text::head(&line);
let head_name = head.split(';').next().unwrap_or_default();
let (group, name) = match head_name.rsplit_once('.') {
Some((g, n)) => (&head_name[..=g.len()], n.to_ascii_uppercase()),
▾Mpimdav/src/text.rs
@@ -52,10 +52,16 @@ pub(crate) fn value(line: &str) -> &str {
&line[value_start(line)..]
}
/// The name and parameters of a line, without the `:` before the value.
pub(crate) fn head(line: &str) -> &str {
let start = value_start(line);
line[..start].strip_suffix(':').unwrap_or(line)
}
/// The raw parameters of a line, split at `;` outside quoted values. A
/// quoted TZID may hold `;` and `:`.
pub(crate) fn param_parts(line: &str) -> Vec<&str> {
let head = &line[..value_start(line).saturating_sub(1)];
let head = head(line);
let mut parts = Vec::new();
let (mut quoted, mut start) = (false, 0);
for (i, c) in head.char_indices() {
▾Mpimdav/src/view.rs
@@ -375,15 +375,15 @@ pub fn card(vcard: &str) -> Card {
/// and 4 use.
fn date(v: &str) -> Option<String> {
let v = v.trim().split(['T', 't']).next()?;
let digits = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_digit());
match v.strip_prefix("--") {
Some(md) => {
let md = md.replace('-', "");
(md.len() == 4).then(|| format!("--{}-{}", &md[..2], &md[2..]))
digits(&md, 4).then(|| format!("--{}-{}", &md[..2], &md[2..]))
}
None => {
let d = v.replace('-', "");
(d.len() == 8 && d.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..]))
digits(&d, 8).then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..]))
}
}
}
▾Mpimdav/tests/contact.rs
@@ -103,3 +103,19 @@ fn anniversaries() {
assert_eq!(dates.len(), 1);
assert!(dates[0].1.contains("💍 Anna Berg (2001)"));
}
#[test]
fn non_ascii_dates_are_ignored() {
// Byte offsets on these would split a character.
for bday in ["BDAY:1980年3\n", "BDAY:--0年\n", "BDAY:é1234567\n"] {
assert!(contact::dates(&card(bday), "k").is_empty(), "{bday}");
assert_eq!(pimdav::view::card(&card(bday)).birthday, None, "{bday}");
}
// A line without a value that ends in a multi-byte character.
let odd = card("X-NOTE;é\nBDAY:1980-03-15\n");
let v4 = pimdav::render::AddressData {
props: None,
version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
};
assert!(pimdav::render::address_data(&odd, &v4).contains("BEGIN:VCARD"));
}
▾Mserver/src/api/pim.rs
@@ -837,7 +837,11 @@ pub(super) async fn delete_own(
ops.extend(more);
}
}
// The cancellations commit with the delete, so no event goes without
// its attendees hearing of it.
ops.push(PimOp::DeleteCollection(col.id));
db.pim_apply(&ops).await?;
return Ok(Ok(()));
}
db.pim_delete_collection(col.id).await?;
Ok(Ok(()))
@@ -1462,19 +1466,45 @@ impl Cx<'_> {
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let Ok(update) = xml::update(&body) else {
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");
let mut default = None;
if matches!(res, Res::Inbox(..)) {
if let Some(i) = update.set.iter().position(|p| Name::of(p) == default_url) {
let p = update.set.remove(i);
let href = xml::child(&p, DAV, "href").map(xml::text);
default = Some(match href {
Some(h) => self.receiving_calendar(&h).await?.map(Some).ok_or(()),
None => Err(()),
});
} else if let Some(i) = update.remove.iter().position(|n| *n == default_url) {
update.remove.remove(i);
default = Some(Ok(None));
}
}
let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
let stored = self.state.db.pim_props(place).await?;
let patch = apply(
let mut patch = apply(
col.as_mut().map(|(k, c)| (*k, c)),
&update,
false,
&live,
&stored,
);
if patch.ok() {
let default_ok = !matches!(default, Some(Err(())));
if !default_ok {
for (code, _) in &mut patch.results {
if *code == 200 {
*code = 424;
}
}
}
let all_ok = patch.ok() && default_ok;
if all_ok {
let db = &self.state.db;
db.pim_patch(
place,
@@ -1483,17 +1513,61 @@ impl Cx<'_> {
&patch.remove,
)
.await?;
if let Some(Ok(id)) = default {
db.pim_set_default_calendar(self.space().id, id).await?;
}
}
let mut r = xml::Response::new(href);
r.error = patch
.protected
.then(|| el(DAV, "cannot-modify-protected-property"));
r.error = match (default_ok, patch.protected) {
(false, _) => Some(el(CALDAV, "valid-schedule-default-calendar-URL")),
(true, true) => Some(el(DAV, "cannot-modify-protected-property")),
(true, false) => None,
};
for (code, prop) in patch.results {
r.push(code, prop);
}
if let Some(d) = default {
let code = match (d, all_ok) {
(Err(()), _) => 403,
(Ok(_), true) => 200,
(Ok(_), false) => 424,
};
r.push(code, default_url.element());
}
Ok(multistatus(&[r], None))
}
/// The id of the own calendar at `href` that can receive invitations:
/// stored, not the inbox, taking events.
async fn receiving_calendar(&self, href: &str) -> Result<Option<i64>, ApiError> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => match href.parse::<axum::http::Uri>() {
Ok(u) => u.path().to_string(),
Err(_) => return Ok(None),
},
};
let space = self.space();
let slug = match path.strip_prefix(PIM).and_then(parse_target) {
Some(Target::Collection(PimKind::Calendar, owner, slug))
if owner.eq_ignore_ascii_case(&space.path) =>
{
slug
}
_ => return Ok(None),
};
Ok(self
.collection(PimKind::Calendar, &slug)
.await?
.filter(|c| {
c.access == Access::Own
&& !generated(c.c.id)
&& c.c.slug != INBOX
&& c.c.components.split(',').any(|x| x == "VEVENT")
})
.map(|c| c.c.id))
}
/// The owner changes the properties of its principal and homes, admins
/// those of rooms and resources.
fn may_edit(&self, s: &Space) -> bool {
@@ -2280,6 +2354,9 @@ impl Cx<'_> {
}
},
};
// A generated collection has no change log to resume a cut
// answer from. It is small, so it always answers in full.
let limit = limit.filter(|_| !generated(col.id));
// An initial sync reads every member at once, not one per change.
let mut members = match since {
None => Some(self.member_map(&col).await?),
@@ -2301,16 +2378,20 @@ impl Cx<'_> {
// A truncated answer hands out the token of its last change, so
// the next sync resumes after it.
let seq = match (truncated, changes.last()) {
(true, Some((_, s, _))) if !generated(col.id) => *s,
_ if generated(col.id) => col.seq,
(true, Some((_, s, _))) => *s,
(_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
};
let mut found = Vec::with_capacity(changes.len());
for (name, _, deleted) in changes {
let hit = match (deleted, &mut members) {
let hit = match (deleted, members.as_mut().and_then(|m| m.remove(&name))) {
(true, _) => None,
(false, Some(m)) => m.remove(&name),
(false, None) => self.state.db.pim_object(col.id, &name).await?,
(false, Some(hit)) => Some(hit),
// Written after the member map was read.
(false, None) if !generated(col.id) => {
self.state.db.pim_object(col.id, &name).await?
}
(false, None) => None,
};
found.push((name, hit));
}
@@ -2577,6 +2658,12 @@ impl Cx<'_> {
if to.access < Access::Write || to.c.slug == INBOX {
return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
}
// UIDs are unique per owner and a meeting stays in its organizer's
// calendars, so an object never changes owner. Clients fall back to
// PUT and DELETE, which schedule as usual.
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
@@ -107,6 +107,7 @@ pub async fn list(
.pim_calendar_for(pid, "VEVENT")
.await?
.map(|c| c.id);
let counts = state.db.pim_share_counts(pid).await?;
let mut out = Vec::new();
for kind in [PimKind::Calendar, PimKind::AddressBook] {
for c in state.db.pim_collections(pid, kind).await? {
@@ -114,10 +115,11 @@ pub async fn list(
continue;
}
let url = collection_href(&me.name, kind, &c.slug, None);
let (shares, links) = counts.get(&c.id).copied().unwrap_or_default();
out.push(PimCollectionInfo {
is_default: default == Some(c.id),
shares: state.db.pim_shares(c.id).await?.len(),
links: state.db.pim_links(c.id).await?.len(),
shares,
links,
..info(&c, kind, url, &me.name, None)
});
}
▾Mserver/src/api/pim_schedule.rs
@@ -43,6 +43,9 @@ const INVALID_USER: &str = "3.7";
const NO_ROUTE: &str = "5.2";
/// The recipient has no calendar for the component.
const REFUSED: &str = "5.3";
/// The recipient holds an object with this UID that is not its copy of the
/// sender's meeting (RFC 6638: no scheduling privileges).
const NO_AUTHORITY: &str = "3.8";
/// Who writes into a calendar, as far as scheduling cares.
pub(crate) struct Writer<'a> {
@@ -426,12 +429,10 @@ async fn answer_rooms(
let Some(calendar) = state.db.pim_calendar_for(room.id, &component).await? else {
continue;
};
let current = state
.db
.pim_find_uid(room.id, &uid)
.await?
.and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(&d).as_ref()).ok());
let Some(received) = itip::receive(current.as_ref(), m) else {
let Ok(copy) = copy_of(state, dir, room, organizer, &uid).await? else {
continue;
};
let Some(received) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) else {
continue;
};
let is_room = dir.is(room.id);
@@ -532,11 +533,10 @@ async fn deliver(
let Some((uid, component)) = identity(&m.cal) else {
return Ok(Some(REFUSED));
};
let copy = state.db.pim_find_uid(p.id, &uid).await?;
let current = copy
.as_ref()
.and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(d).as_ref()).ok());
if let Some(next) = itip::receive(current.as_ref(), m) {
let Ok(copy) = copy_of(state, dir, p, sender, &uid).await? else {
return Ok(Some(NO_AUTHORITY));
};
if let Some(next) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) {
let data = next.to_string().into_bytes();
let etag = etag_of(&data);
let (collection_id, name, schedule_tag) = match copy {
@@ -605,6 +605,13 @@ async fn reply_to(
let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
return Ok(NO_ROUTE);
};
// A UID alone proves nothing: only the organizer's own object takes it.
if !matches!(
itip::role(&before, &dir.is(organizer.id)),
Ok(Role::Organizer)
) {
return Ok(NO_AUTHORITY);
}
let mut after = before.clone();
let replier = dir.is(attendee.id);
if itip::apply_reply(&mut after, &m.cal, &replier) {
@@ -630,6 +637,33 @@ async fn reply_to(
Ok(DELIVERED)
}
/// Whether `copy` is `p`'s attendee copy of a meeting `organizer` runs. A
/// message may change only that: anyone can pick any UID.
fn attends(dir: &Directory, copy: &ICalendar, p: &PimPrincipal, organizer: &PimPrincipal) -> bool {
matches!(itip::role(copy, &dir.is(p.id)), Ok(Role::Attendee))
&& itip::organizer(copy).is_some_and(dir.is(organizer.id))
}
/// `p`'s copy of the meeting with `uid` and where it is stored. `Err` if
/// `p` holds that UID in an object the message may not touch.
async fn copy_of(
state: &AppState,
dir: &Directory,
p: &PimPrincipal,
organizer: &PimPrincipal,
uid: &str,
) -> Result<Result<Option<(i64, PimObject, ICalendar)>, ()>, ApiError> {
let Some((id, obj, data)) = state.db.pim_find_uid(p.id, uid).await? else {
return Ok(Ok(None));
};
Ok(
match ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
Ok(c) if attends(dir, &c, p, organizer) => Ok(Some((id, obj, c))),
_ => Err(()),
},
)
}
async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
match p.kind {
UserType::Individual => state.db.pim_ensure_defaults(p.id).await?,
▾Mserver/src/api/pim_views.rs
@@ -3,7 +3,6 @@
//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}` — one event or contact
//! - `GET {PIM_CONTACTS}` — contacts, searched
//! - `GET {PIM_INVITATIONS}`, `POST` a reply — unanswered invitations
//! - `GET {PIM_PREVIEW}` — what an `.ics`/`.vcf` file holds
//!
//! The UI never parses iCalendar or vCard: these endpoints do.
▾Mserver/src/db.rs
@@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 13;
const SCHEMA_VERSION: i64 = 14;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -179,8 +179,6 @@ impl PimLink {
}
}
/// A [`ShareRow`] together with the account that created it.
#[derive(Debug, Clone)]
/// A feed link with what the admin overview shows about it.
pub struct PimLinkWithOwner {
pub link: PimLink,
@@ -191,6 +189,8 @@ pub struct PimLinkWithOwner {
pub owner_active: bool,
}
/// A [`ShareRow`] together with the account that created it.
#[derive(Debug, Clone)]
pub struct ShareWithCreator {
pub share: ShareRow,
pub creator_name: String,
@@ -311,6 +311,7 @@ pub enum PimOp {
obj: PimObject,
data: Vec<u8>,
},
DeleteCollection(i64),
}
#[derive(Debug, PartialEq, Eq)]
@@ -623,6 +624,14 @@ impl Db {
"ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1",
)?;
}
if version < 14 {
// The calendar that receives invitations (RFC 6638,
// schedule-default-calendar-URL). NULL: the oldest one.
conn.execute_batch(
"ALTER TABLE principals ADD COLUMN default_calendar_id INTEGER
REFERENCES pim_collections(id) ON DELETE SET NULL",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -1505,8 +1514,8 @@ impl Db {
ensure_inbox(&c, principal_id)
}
/// The calendar that receives new invitations of `component`: the
/// oldest one that takes it.
/// The calendar that receives new invitations of `component`: the one
/// the principal chose, else the oldest one that takes it.
pub async fn pim_calendar_for(
&self,
principal_id: i64,
@@ -1517,12 +1526,27 @@ impl Db {
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE principal_id = ?1 AND kind = 'cal' AND slug != 'inbox'
AND ',' || components || ',' LIKE '%,' || ?2 || ',%'
ORDER BY id LIMIT 1"
ORDER BY id IS NOT (SELECT default_calendar_id FROM principals WHERE id = ?1), id
LIMIT 1"
))?;
stmt.query_row(params![principal_id, component], map_pim_collection)
.optional()
}
/// The calendar [`Db::pim_calendar_for`] prefers. `None`: the oldest.
pub async fn pim_set_default_calendar(
&self,
principal_id: i64,
collection_id: Option<i64>,
) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
"UPDATE principals SET default_calendar_id = ?2 WHERE id = ?1",
params![principal_id, collection_id],
)?;
Ok(())
}
/// The object with `uid` in any of the user's own calendars.
pub async fn pim_find_uid(
&self,
@@ -1564,6 +1588,9 @@ impl Db {
let tx = c.transaction()?;
apply_ops(&tx, ops)?;
tx.commit()?;
if ops.iter().any(|o| matches!(o, PimOp::DeleteCollection(_))) {
self.forget_defaults();
}
Ok(())
}
@@ -1945,6 +1972,27 @@ impl Db {
})
}
/// Per collection of `principal_id`: how many loans and feed links it has.
pub async fn pim_share_counts(
&self,
principal_id: i64,
) -> DbResult<std::collections::HashMap<i64, (usize, usize)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT c.id,
(SELECT COUNT(*) FROM pim_shares s WHERE s.collection_id = c.id),
(SELECT COUNT(*) FROM pim_links l WHERE l.collection_id = c.id)
FROM pim_collections c WHERE c.principal_id = ?1",
)?;
stmt.query_map([principal_id], |r| {
Ok((
r.get(0)?,
(r.get::<_, i64>(1)? as usize, r.get::<_, i64>(2)? as usize),
))
})?
.collect()
}
pub async fn pim_links(&self, collection_id: i64) -> DbResult<Vec<PimLink>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
@@ -2340,6 +2388,9 @@ fn apply_ops(tx: &rusqlite::Transaction, ops: &[PimOp]) -> DbResult<()> {
record_pim_change(tx, inbox, &name, true)?;
}
}
PimOp::DeleteCollection(id) => {
tx.execute("DELETE FROM pim_collections WHERE id = ?1", [id])?;
}
}
}
Ok(())
▾Mserver/tests/api_pim.rs
@@ -1244,7 +1244,8 @@ async fn lent_collections() {
let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Read-write: bob adds an event, alice sees it; bob moves one out.
// Read-write: bob adds an event, alice sees it. Moving it into his own
// calendar is refused: an object never changes owner.
let r = alice
.post_json(&shares, &json!({"user": BOB, "mode": "rw"}))
.await;
@@ -1267,9 +1268,9 @@ async fn lent_collections() {
"",
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(r.status, StatusCode::OK);
let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
▾Mserver/tests/api_pim_derived.rs
@@ -282,6 +282,31 @@ async fn birthday_calendar() {
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
// Without a change log a cut answer could not resume, so a limit is
// ignored: an initial sync lists every member, with no 507.
pim.put(
"alice",
&format!("{BOOK}ben.vcf"),
&contact("ben", "BDAY:1990-07-01\r\n"),
)
.await;
let limited = "<d:sync-collection xmlns:d=\"DAV:\"><d:sync-token/><d:sync-level>1</d:sync-level>\
<d:limit><d:nresults>1</d:nresults></d:limit><d:prop><d:getetag/></d:prop>\
</d:sync-collection>";
let r = pim.req("alice", "REPORT", birthdays, "1", limited).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
let hrefs: Vec<_> = xml::elements(&root)
.filter_map(|resp| xml::child(resp, DAV, "href").map(xml::text))
.collect();
assert_eq!(hrefs.len(), 2, "{}", r.text());
assert!(
hrefs
.iter()
.all(|h| h.starts_with(birthdays) && h != birthdays),
"{hrefs:?}"
);
// Read-only.
let r = pim
.req("alice", "PUT", &format!("{birthdays}x.ics"), "0", "x")
▾Mserver/tests/api_pim_schedule.rs
@@ -792,3 +792,254 @@ async fn one_resource_per_scheduled_uid() {
assert!(error_condition(&r).is(CALDAV, "unique-scheduling-object-resource"));
assert!(r.text().contains(ALICE_EVENT), "{}", r.text());
}
#[tokio::test]
async fn foreign_uids_are_not_overwritten() {
let pim = Pim::new().await;
pim.room().await;
// alice organizes meet-1, keeps a plain event and books the room.
invite(&pim, &[&addr("bob")]).await;
let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:plain-1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260310T100000Z\r\nSUMMARY:Mine\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let alice_plain = "/pim/calendars/alice/default/plain.ics";
pim.put_ok("alice", alice_plain, plain).await;
let slot = future(3, 9);
pim.put_ok(
"alice",
"/pim/calendars/alice/default/book.ics",
&booking("book-1", "alice", &slot, "", &[ROOM]),
)
.await;
let room_copy = pim.members("bob", "/pim/calendars/board/default/").await;
assert_eq!(room_copy.len(), 1, "{room_copy:?}");
let before = [
pim.get("alice", ALICE_EVENT).await,
pim.get("alice", alice_plain).await,
pim.get("bob", &room_copy[0]).await,
];
let alice_inbox = pim.inbox("alice").await.len();
// carol reuses those UIDs as organizer, inviting alice and the room.
let other = future(5, 14);
for (uid, to) in [
("meet-1", addr("alice")),
("plain-1", addr("alice")),
("book-1", ROOM.to_string()),
] {
let path = format!("/pim/calendars/carol/default/{uid}.ics");
pim.put_ok("carol", &path, &booking(uid, "carol", &other, "", &[&to]))
.await;
let sent = pim.get("carol", &path).await;
assert_eq!(
attendee_param(&sent, &to, "SCHEDULE-STATUS").as_deref(),
Some("3.8"),
"{sent}"
);
// Removing the attendee would cancel for them.
let r = pim.req("carol", "DELETE", &path, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
}
// carol brings in a copy that names alice as organizer of plain-1 and
// answers it: alice's plain event takes no reply.
let fake = plain.replace(
"SUMMARY:Mine\r\n",
&format!(
"ORGANIZER:{}\r\nATTENDEE:{}\r\n",
addr("alice"),
addr("carol")
),
);
let fake_path = "/pim/calendars/carol/default/fake.ics";
pim.put_ok("carol", fake_path, &fake).await;
let answered = fake.replace(
&format!("ATTENDEE:{}", addr("carol")),
&format!("ATTENDEE;PARTSTAT=ACCEPTED:{}", addr("carol")),
);
pim.put_ok("carol", fake_path, &answered).await;
let after = [
pim.get("alice", ALICE_EVENT).await,
pim.get("alice", alice_plain).await,
pim.get("bob", &room_copy[0]).await,
];
assert_eq!(before, after);
assert_eq!(pim.inbox("alice").await.len(), alice_inbox);
// The real organizer still reaches an attendee who deleted their copy.
let (bob_copy, _) = pim.copy("bob").await;
let r = pim
.req("bob", "DELETE", &bob_copy, &[("schedule-reply", "F")], "")
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let moved = pim
.get("alice", ALICE_EVENT)
.await
.replace("DTSTART:20260301T100000Z", "DTSTART:20260302T100000Z");
pim.put_ok("alice", ALICE_EVENT, &moved).await;
let (_, again) = pim.copy("bob").await;
assert!(
again.text().contains("20260302T100000Z"),
"{}",
again.text()
);
}
#[tokio::test]
async fn move_stays_with_one_owner() {
let pim = Pim::new().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": "bob", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
let r = pim
.req("bob", "MKCALENDAR", "/pim/calendars/bob/work/", &[], mk)
.await;
assert_eq!(r.status, StatusCode::CREATED);
let own = "/pim/calendars/bob/default/m.ics";
pim.put_ok(
"bob",
own,
&booking("m1", "bob", "20260401T100000Z", "", &[&addr("carol")]),
)
.await;
let to = |path: &str| format!("http://localhost{path}");
// Into alice's lent calendar: refused, the object stays.
let lent = format!("/pim/calendars/bob/shared-{id}/m.ics");
let r = pim
.req("bob", "MOVE", own, &[("destination", &to(&lent))], "")
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert_eq!(pim.members("bob", &cal("bob")).await.len(), 1);
// Between bob's own calendars as before.
let work = "/pim/calendars/bob/work/m.ics";
let r = pim
.req("bob", "MOVE", own, &[("destination", &to(work))], "")
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
#[tokio::test]
async fn deleting_a_calendar_cancels_its_meetings() {
let pim = Pim::new().await;
let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
let r = pim
.req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], mk)
.await;
assert_eq!(r.status, StatusCode::CREATED);
pim.put_ok(
"alice",
"/pim/calendars/alice/work/m.ics",
&booking("w1", "alice", "20260401T100000Z", "", &[&addr("bob")]),
)
.await;
let r = pim
.req("alice", "DELETE", "/pim/calendars/alice/work/", &[], "")
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
let r = pim
.req(
"alice",
"PROPFIND",
"/pim/calendars/alice/work/",
&[("depth", "0")],
"",
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let (_, copy) = pim.copy("bob").await;
assert!(copy.text().contains("STATUS:CANCELLED"), "{}", copy.text());
}
#[tokio::test]
async fn invitations_go_to_the_chosen_calendar() {
let pim = Pim::new().await;
let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
let r = pim
.req("bob", "MKCALENDAR", "/pim/calendars/bob/work/", &[], mk)
.await;
assert_eq!(r.status, StatusCode::CREATED);
let inbox = "/pim/calendars/bob/inbox/";
let set = |href: &str| {
format!(
"<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<c:schedule-default-calendar-URL><d:href>{href}</d:href></c:schedule-default-calendar-URL>\
</d:prop></d:set></d:propertyupdate>"
)
};
// Not one of bob's calendars: refused with the RFC 6638 precondition.
for bad in [
"/pim/calendars/alice/default/",
"/pim/calendars/bob/inbox/",
"/pim/calendars/bob/birthdays/",
] {
let r = pim.req("bob", "PROPPATCH", inbox, &[], &set(bad)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
assert!(
r.text().contains("valid-schedule-default-calendar-URL"),
"{bad}: {}",
r.text()
);
}
let r = pim
.req(
"bob",
"PROPPATCH",
inbox,
&[],
&set("/pim/calendars/bob/work/"),
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
assert!(r.text().contains("200"), "{}", r.text());
let props = "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>\
<c:schedule-default-calendar-URL/></d:prop></d:propfind>";
let r = pim
.req("bob", "PROPFIND", inbox, &[("depth", "0")], props)
.await;
assert!(
r.text().contains("/pim/calendars/bob/work/"),
"{}",
r.text()
);
invite(&pim, &[&addr("bob")]).await;
assert_eq!(
pim.members("bob", "/pim/calendars/bob/work/").await.len(),
1
);
assert!(pim.members("bob", &cal("bob")).await.is_empty());
// The chosen calendar is the one that must stay.
let r = pim
.req("bob", "DELETE", "/pim/calendars/bob/work/", &[], "")
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(CALDAV, "default-calendar-needed"));
// Removing the property goes back to the oldest calendar.
let remove = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:remove><d:prop>\
<c:schedule-default-calendar-URL/></d:prop></d:remove></d:propertyupdate>";
let r = pim.req("bob", "PROPPATCH", inbox, &[], remove).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let r = pim
.req("bob", "PROPFIND", inbox, &[("depth", "0")], props)
.await;
assert!(
r.text().contains("/pim/calendars/bob/default/"),
"{}",
r.text()
);
}