CalDAV/CardDAV: contact photos, birthday calendar, deleted-principal cleanup

- Contact photos: GET /api/pim/collections/{id}/objects/{name}/photo serves
  an inline PHOTO as a WebP from the thumbnail cache, keyed by ETag; never
  the stored bytes, never a URL photo; 404 without a cache
- Birthday calendar: generated per principal at `birthdays` from BDAY,
  ANNIVERSARY, X-ANNIVERSARY and Apple X-ABDATE of its own address books;
  yearly all-day transparent events, Feb 29 on the last day of February,
  stable UIDs, hash sync token like the system address book, read-only
- Deleting an account, room or resource rewrites other principals'
  objects in the same transaction: tombstone address in
  deleted.filebrowser.invalid, SCHEDULE-STATUS 3.7, organized copies
  cancelled; textual and byte-minimal
- Admin view and revoke of feed links at /api/admin/pim-links
- Line helpers move to pimdav's `text` module; quoted TZIDs with `;` or
  `:` now find their VTIMEZONE on import and in busy feeds
- Docs: pimdav README and the README section

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitaf8101e452def3a5493f9c864556f93a6adf4d5f
Parent19aba65
20 files changed, 1532 insertions(+), 244 deletions(-)
▾MREADME.md
@@ -228,7 +228,7 @@ Apple Calendar and Contacts, Thunderbird, and DAVx5 on Android.
| URL | Content |
|-----|---------|
| `/pim/principals/<name>/` | An account, room or resource |
| `/pim/calendars/<name>/` | Your calendars, the scheduling inbox, and calendars lent to you |
| `/pim/calendars/<name>/` | Your calendars, the birthday calendar, the scheduling inbox, and calendars lent to you |
| `/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
@@ -238,6 +238,20 @@ events, so 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.
The **birthday calendar** (`birthdays`) shows the birthdays and
anniversaries of the contacts in your own address books, as yearly
all-day events. Lent address books and the system address book do not
count. It is read-only and built by the server, marked as free time, and
not part of your free-busy time. A birthday is named "🎂 Name", an
anniversary "💍 Name", with the year in brackets when it is known. A
February 29 shows on February 28 in other years.
**Contact photos**: `GET /api/pim/collections/<id>/objects/<name>/photo`
returns the photo of a contact you can read as a WebP of at most 256
pixels, like a file thumbnail. It needs the thumbnail cache (`--cache`).
Only a photo stored inside the contact counts. A photo given as a web
address is never fetched.
### Sharing
You can lend a calendar or address book to another account. It then shows
@@ -298,7 +312,9 @@ and keep them in sync, lend the address book instead (see Sharing).
The JSON API: `GET` and `POST /api/pim/collections/<id>/links`, with
`{"busy_only": true, "expires_at": "<RFC 3339>", "password": "<password>"}`
(all optional), and `DELETE /api/pim/collections/<id>/links/<link id>`.
The answer holds the link's path.
The answer holds the link's path. Admins see every link with its owner at
`GET /api/admin/pim-links`, and revoke one with `DELETE
/api/admin/pim-links/<id>`.
### Import and export
@@ -327,7 +343,8 @@ your folders. You need write access: your own collection, or a `rw` or
book as one file, with every event in full, private ones included. `POST` with
`{"root_id": <id>, "path": "<path>"}` saves it as a new file into a
writable folder. An existing file is not overwritten. Lent collections can
be exported too. The system address book cannot.
be exported too. The system address book and the birthday calendar
cannot.
### Invitations
@@ -361,6 +378,16 @@ invitations itself. An instance that overlaps an existing booking is
declined. Everything else is accepted. For a repeating meeting, only the
instances that collide are declined.
### Deleting an account, room or resource
The server removes the deleted one from everyone else's events in the same
step. Its address becomes `<name>-<number>@deleted.filebrowser.invalid`,
which reaches no one, and its attendee entries show status 3.7. The
display name stays. Copies of meetings it organized are marked cancelled.
Apps pick up the change at their next sync. A new account with the same
name is a different person to the server: it gets nothing that was meant
for the old one.
### Limits
- One calendar entry or contact can be 10 MiB. An XML request can be
@@ -378,8 +405,10 @@ instances that collide are declined.
instances are accepted without a check.
- One lock serializes all writes of calendar entries and contacts on the
server. That is fine for a small server.
- The system address book has no change history. After any change to
accounts or rooms, apps download it again in full.
- The system address book and the birthday calendar have no change
history. After any change to accounts or rooms, or to a birthday, apps
download them again in full. The birthday calendar is rebuilt from all
your contacts on each request.
Not supported:
▾Mapi-types/src/lib.rs
@@ -71,6 +71,9 @@ pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
/// Admin management of rooms and resources: `{ADMIN_ROOMS}` and
/// `{ADMIN_ROOMS}/{id}`.
pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
/// Admin view of every public calendar and address book feed:
/// `{ADMIN_PIM_LINKS}` and `{ADMIN_PIM_LINKS}/{id}`.
pub const ADMIN_PIM_LINKS: &str = "/api/admin/pim-links";
/// The signed-in user's calendars and address books, own and lent to them
/// (`GET`). `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends
/// (`POST`) an own one; `DELETE` on `.../{user_id}` below it ends a loan.
@@ -85,6 +88,10 @@ pub const IMPORT_SUFFIX: &str = "/import";
/// `{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: `GET` downloads the collection as
/// one file; `POST` with a [`PimRootFile`] saves it into a root.
pub const EXPORT_SUFFIX: &str = "/export";
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`: a
/// contact's photo as a WebP thumbnail.
pub const OBJECTS_SUFFIX: &str = "/objects";
pub const PHOTO_SUFFIX: &str = "/photo";
/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
/// `.vcf`. The extension is optional.
pub const FEED: &str = "/feed";
@@ -586,6 +593,20 @@ pub struct PimLinkInfo {
pub has_password: bool,
}
/// One feed with its collection and owner: GET `{ADMIN_PIM_LINKS}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AdminPimLink {
#[serde(flatten)]
pub link: PimLinkInfo,
pub collection_id: i64,
pub collection_name: String,
pub kind: PimCollectionKind,
pub owner_id: i64,
pub owner_name: String,
/// Whether the owner can still sign in. A disabled owner's feeds stay live.
pub owner_active: bool,
}
/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
#[derive(Serialize, Deserialize, Default)]
pub struct CreatePimLink {
▾Mpimdav/README.md
@@ -26,6 +26,7 @@ The crate uses two libraries:
| `freebusy` | Busy time, free-busy replies |
| `itip` | Implicit scheduling as iTIP messages |
| `principal` | Principal search, system address book cards |
| `contact` | Contact photos, birthday events |
| `bundle` | Whole collections as one file, and one file split into objects |
The sections below describe how the crate reads the RFCs where the text
@@ -395,6 +396,72 @@ the output unchanged. Only line endings become CRLF.
- The objects are not validated here. The server runs the same checks as
for a PUT and skips what fails.
## Contacts
`contact` derives two things from a vCard. The server stores neither.
### Photos
- Only an inline PHOTO counts: vCard 3 `ENCODING=b`, or a vCard 4 `data:`
URI. A photo given as a URL is never fetched: a contact could then make
the server request any address.
- The server never serves the stored bytes. They come from a client and
could be HTML or SVG with script. The thumbnail cache re-encodes them
as WebP, keyed by the object's ETag, so an edited contact gets a new
image. Without a cache there are no photos.
### Birthdays and anniversaries
- BDAY, and ANNIVERSARY (vCard 4) or X-ANNIVERSARY (vCard 3, Evolution
and KDE). Apple writes an anniversary as `itemN.X-ABDATE`, labelled by
an `itemN.X-ABLabel` that contains "Anniversary"; that counts too. Only
the first date of each kind counts.
- The date forms are `19800315`, `1980-03-15`, `--0315` and `--03-15`,
with or without a time. calcard reads `--03-15` as a month alone, so
the dates are read from the text. Apple's `X-APPLE-OMIT-YEAR` marks a
placeholder year. A TEXT value and an impossible date are skipped.
- Each date becomes a yearly, all-day, transparent event with a fixed
DTSTAMP, so its ETag changes only with the contact.
- The UID is a hash of the contact's collection, its resource name and the
kind of date. It stays while the contact stays.
- A February 29 recurs with `BYMONTH=2;BYMONTHDAY=-1`: the last day of
February in other years. Without a year, the series starts in 1972, or
in 1970 for other dates.
- The summary is `🎂 Name` or `💍 Name`, with the birth year in brackets
when known. It carries no age: one recurring event cannot hold a value
that changes each year. The signs avoid words, so the server needs no
language.
The server builds the birthday calendar of a principal from its own
address books only, on each request. Like the system address book, it has
no change log: its sync token is a hash of its members' ETags, and only
the current token is valid. It is read-only, not shareable, has no feed
links, and is left out of free-busy and of the choice of the calendar that
receives invitations.
## Forgetting a deleted principal
`itip::forget` rewrites the objects of other principals before an account,
room or resource is deleted. Without it, a later principal of the same name
would get the same `mailto:` address and take the old one's place in every
meeting.
- Every ORGANIZER and ATTENDEE that names it gets the tombstone address
`mailto:<name>-<id>@deleted.filebrowser.invalid`. Its parameters stay,
CN among them.
- Such an ATTENDEE gets SCHEDULE-STATUS 3.7. A later message to it is not
delivered, with status 3.7 as for any unknown address in our domains.
- A component the deleted principal organized gets STATUS:CANCELLED: an
existing STATUS line changes, otherwise one follows the BEGIN line.
- Only the changed lines change. A rewritten line is folded at 75 octets.
- The server runs it over the calendar objects of all other principals
that mention the name or the `urn:uuid:`, inboxes excluded, and commits
the result with the delete in one transaction. The changed objects count
as changes, so clients sync them.
- A client that has not synced yet can still PUT its old copy, with the old
address, and so invite the new principal. If-Match prevents that for
clients that send it.
## Where the RFCs are unclear or implementations differ
| Case | What we do | Why |
@@ -420,6 +487,8 @@ the output unchanged. Only line endings become CRLF.
| `/.well-known` redirect | 307 | A 301 drops the REPORT body; RFC 6764 allows 307 |
| 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 |
| 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 |
## Not handled
▾Mpimdav/src/bundle.rs
@@ -8,6 +8,10 @@ use std::collections::{HashMap, HashSet};
use sha2::{Digest, Sha256};
use crate::text::{
escape_text, logical_lines, name, param, param_parts, unescape_text, unfold, value, value_start,
};
const PRODID: &str = "PRODID:-//filebrowser-ng//pimdav//EN";
/// The properties a busy-only event keeps.
@@ -118,7 +122,11 @@ pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Ve
}
continue;
}
let tzids: HashSet<String> = c.lines.iter().filter_map(|l| tzid(l)).collect();
let tzids: HashSet<String> = c
.lines
.iter()
.filter_map(|l| param(&unfold(l), "TZID"))
.collect();
let (uid, text) = match c.prop("UID") {
Some(uid) => (uid, lines_text(&c.lines)),
None => {
@@ -261,69 +269,6 @@ fn split_level<'a>(lines: &[&'a str]) -> (Vec<&'a str>, Vec<Block<'a>>) {
(props, blocks)
}
/// Physical lines joined with their folded continuation lines.
fn logical_lines(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut pos = 0;
for line in text.split_inclusive('\n') {
if pos > start && !line.starts_with([' ', '\t']) {
out.push(&text[start..pos]);
start = pos;
}
pos += line.len();
}
if pos > start {
out.push(&text[start..pos]);
}
out
}
fn unfold(line: &str) -> String {
line.replace("\r\n ", "")
.replace("\r\n\t", "")
.replace("\n ", "")
.replace("\n\t", "")
.trim_end_matches(['\r', '\n'])
.to_string()
}
/// The property name, upper case.
fn name(line: &str) -> String {
let end = line.find([':', ';', '\r', '\n']).unwrap_or(line.len());
line[..end].trim().to_ascii_uppercase()
}
/// Where the value of an unfolded line starts: after the first colon
/// outside a quoted parameter value.
fn value_start(line: &str) -> usize {
let mut quoted = false;
for (i, c) in line.char_indices() {
match c {
'"' => quoted = !quoted,
':' if !quoted => return i + 1,
_ => {}
}
}
line.len()
}
fn value(line: &str) -> &str {
&line[value_start(line)..]
}
/// The TZID parameter of a line.
fn tzid(line: &str) -> Option<String> {
let line = unfold(line);
let head = &line[..value_start(&line).saturating_sub(1)];
head.split(';').skip(1).find_map(|p| {
let (k, v) = p.split_once('=')?;
k.trim()
.eq_ignore_ascii_case("TZID")
.then(|| v.trim().trim_matches('"').to_string())
})
}
fn push_lines(out: &mut String, lines: &[&str]) {
for line in lines {
for physical in line.split_inclusive('\n') {
@@ -398,41 +343,10 @@ fn push_busy(out: &mut String, events: &[&Block]) {
fn exdate(rid: &str) -> String {
let line = unfold(rid);
let start = value_start(&line);
let params: String = line[..start.saturating_sub(1)]
.split(';')
.skip(1)
let params: String = param_parts(&line)
.into_iter()
.filter(|p| !p.trim().to_ascii_uppercase().starts_with("RANGE="))
.map(|p| format!(";{p}"))
.collect();
format!("EXDATE{params}:{}\r\n", &line[start..])
}
/// RFC 5545, 3.3.11. A TZID property is TEXT, so `Athens\, Bucharest`
/// there names the TZID parameter `"Athens, Bucharest"`.
fn unescape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
match (c, chars.clone().next()) {
('\\', Some(n @ ('\\' | ';' | ',' | 'n' | 'N'))) => {
out.push(if n.eq_ignore_ascii_case(&'n') {
'\n'
} else {
n
});
chars.next();
}
_ => out.push(c),
}
}
out
}
/// RFC 5545, 3.3.11.
fn escape_text(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,")
.replace('\n', "\\n")
.replace('\r', "")
}
▾Apimdav/src/contact.rs
@@ -0,0 +1,129 @@
//! What the server derives from contacts: their photos, and their birthdays
//! and anniversaries as calendar events.
use calcard::vcard::{VCard, VCardProperty, VCardValue};
use chrono::NaiveDate;
use sha2::{Digest, Sha256};
use crate::text::{escape_text, fold, logical_lines, name, param, unescape_text, unfold, value};
/// The image of a contact's PHOTO: vCard 3 `ENCODING=b` or a vCard 4 `data:`
/// URI. A photo given as a URL is `None`: fetching it would let any contact
/// make the server request an address of its choice.
pub fn photo(vcard: &str) -> Option<Vec<u8>> {
let card = VCard::parse(vcard).ok()?;
card.properties(&VCardProperty::Photo)
.find_map(|e| match e.values.first()? {
VCardValue::Binary(d) => Some(d.data.clone()),
_ => None,
})
}
/// A contact's birthday and anniversary as `(uid, calendar object)` pairs:
/// all-day, yearly, transparent. `key` names the contact for good, so its
/// events keep their UIDs.
///
/// The summary is `🎂 name`, or `💍 name` for an anniversary, with the year in
/// brackets when it is known. The year stays constant, since one recurring
/// event cannot carry a changing age.
pub fn dates(vcard: &str, key: &str) -> Vec<(String, String)> {
let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect();
let prop = |l: &str| {
let n = name(l);
n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string())
};
let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string());
let full_name = lines
.iter()
.find(|l| prop(l) == "FN")
.map(|l| unescape_text(value(l)))
.unwrap_or_default();
// Apple writes an anniversary as X-ABDATE, labelled by a sibling line of
// the same group.
let apple_anniversary = |l: &str| {
let g = group(l);
g.is_some()
&& lines.iter().any(|o| {
group(o) == g
&& prop(o) == "X-ABLABEL"
&& value(o).to_ascii_lowercase().contains("anniversary")
})
};
let first = |wanted: &dyn Fn(&str) -> bool| {
lines
.iter()
.filter(|l| wanted(l))
.find_map(|l| date(l).map(|d| (d, l.as_str())))
};
let birthday = first(&|l| prop(l) == "BDAY");
let anniversary = first(&|l| {
let p = prop(l);
p == "ANNIVERSARY" || p == "X-ANNIVERSARY" || (p == "X-ABDATE" && apple_anniversary(l))
});
[("BDAY", "🎂", birthday), ("ANNIVERSARY", "💍", anniversary)]
.into_iter()
.filter_map(|(what, sign, found)| {
let ((year, month, day), _) = found?;
let hash = Sha256::digest(format!("{key}\0{what}"));
let uid: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect();
Some((uid.clone(), event(&uid, sign, &full_name, year, month, day)))
})
.collect()
}
/// `(year, month, day)` of a date property: `19800315`, `1980-03-15`,
/// `--0315` or `--03-15`, with or without a time. Apple's
/// `X-APPLE-OMIT-YEAR` marks a placeholder year.
fn date(line: &str) -> Option<(Option<i32>, u32, u32)> {
if param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("text")) {
return None;
}
let v = value(line).trim();
let v = v.split(['T', 't']).next()?;
let (year, md) = match v.strip_prefix("--") {
Some(md) => (None, md.replace('-', "")),
None => {
let d = v.replace('-', "");
if d.len() != 8 {
return None;
}
(Some(d[..4].parse().ok()?), d[4..].to_string())
}
};
if md.len() != 4 {
return None;
}
let (month, day) = (md[..2].parse().ok()?, md[2..].parse().ok()?);
// 2000 is a leap year, so February 29 passes.
NaiveDate::from_ymd_opt(2000, month, day)?;
let year = year.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none());
if let Some(y) = year {
NaiveDate::from_ymd_opt(y, month, day)?;
}
Some((year, month, day))
}
fn event(uid: &str, sign: &str, name: &str, year: Option<i32>, month: u32, day: u32) -> String {
let leap_day = (month, day) == (2, 29);
// Without a year the series starts in a year the date exists in.
let start = year.unwrap_or(if leap_day { 1972 } else { 1970 });
// In other years a February 29 falls on the last day of February.
let rule = if leap_day {
"FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1"
} else {
"FREQ=YEARLY"
};
let summary = match year {
Some(y) => format!("SUMMARY:{sign} {} ({y})", escape_text(name)),
None => format!("SUMMARY:{sign} {}", escape_text(name)),
};
let summary = fold(&summary, "\r\n");
// A fixed DTSTAMP keeps the ETag stable while the contact is unchanged.
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//filebrowser-ng//pimdav//EN\r\n\
BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:19700101T000000Z\r\n\
DTSTART;VALUE=DATE:{start:04}{month:02}{day:02}\r\nDURATION:P1D\r\n\
RRULE:{rule}\r\n{summary}TRANSP:TRANSPARENT\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n"
)
}
▾Mpimdav/src/itip.rs
@@ -18,6 +18,7 @@ use xmltree::Element;
use crate::expand::expand;
use crate::filter::TimeRange;
use crate::freebusy::Period;
use crate::text::{fold, logical_lines, name, param_parts, unfold, value};
use crate::xml::{CALDAV, el};
use crate::zone::{Zone, Zones};
@@ -1289,3 +1290,77 @@ fn text(s: &str) -> ICalendarParameterValue {
fn partstat(p: ICalendarParticipationStatus) -> ICalendarParameterValue {
ICalendarParameterValue::Partstat(p)
}
/// Makes an object forget a principal that is about to be deleted, so that
/// a later principal of the same name is not taken for it. Every ORGANIZER
/// and ATTENDEE that `gone` names gets the address `tombstone`, and such an
/// ATTENDEE gets SCHEDULE-STATUS 3.7. A component the gone principal
/// organized is cancelled. All other lines keep their bytes. `None` when
/// nothing names the principal.
pub fn forget(text: &str, gone: Is, tombstone: &str) -> Option<String> {
let mut out: Vec<String> = Vec::new();
// Per open component: name, index of its BEGIN line in `out`, index of
// its STATUS line, whether the gone principal organized it.
let mut open: Vec<(String, usize, Option<usize>, bool)> = Vec::new();
let mut changed = false;
for raw in logical_lines(text) {
let eol = match raw.ends_with('\n') {
true if raw.ends_with("\r\n") => "\r\n",
true => "\n",
false => "\r\n",
};
let line = unfold(raw);
let n = name(&line);
match n.as_str() {
"BEGIN" => open.push((
value(&line).trim().to_ascii_uppercase(),
out.len(),
None,
false,
)),
"END" => {
if let Some((comp, begin, status, true)) = open.pop()
&& ["VEVENT", "VTODO", "VJOURNAL"].contains(&comp.as_str())
{
let cancelled = format!("STATUS:CANCELLED{eol}");
match status {
Some(i) => out[i] = cancelled,
None => out.insert(begin + 1, cancelled),
}
}
}
"STATUS" => {
if let Some(top) = open.last_mut() {
top.2 = Some(out.len());
}
}
"ORGANIZER" | "ATTENDEE" if gone(value(&line).trim()) => {
let attendee = n == "ATTENDEE";
let mut new = n.clone();
for p in param_parts(&line) {
if !(attendee
&& p.trim()
.to_ascii_uppercase()
.starts_with("SCHEDULE-STATUS="))
{
new.push(';');
new.push_str(p);
}
}
if attendee {
new.push_str(";SCHEDULE-STATUS=3.7");
} else if let Some(top) = open.last_mut() {
top.3 = true;
}
new.push(':');
new.push_str(tombstone);
out.push(fold(&new, eol));
changed = true;
continue;
}
_ => {}
}
out.push(raw.to_string());
}
changed.then(|| out.concat())
}
▾Mpimdav/src/lib.rs
@@ -2,6 +2,7 @@
//! this crate computes on it.
pub mod bundle;
pub mod contact;
pub mod expand;
pub mod filter;
pub mod freebusy;
@@ -10,6 +11,7 @@ pub mod object;
pub mod principal;
pub mod render;
pub mod report;
mod text;
pub mod xml;
pub mod zone;
▾Apimdav/src/text.rs
@@ -0,0 +1,130 @@
//! 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.
pub(crate) fn logical_lines(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut pos = 0;
for line in text.split_inclusive('\n') {
if pos > start && !line.starts_with([' ', '\t']) {
out.push(&text[start..pos]);
start = pos;
}
pos += line.len();
}
if pos > start {
out.push(&text[start..pos]);
}
out
}
pub(crate) fn unfold(line: &str) -> String {
line.replace("\r\n ", "")
.replace("\r\n\t", "")
.replace("\n ", "")
.replace("\n\t", "")
.trim_end_matches(['\r', '\n'])
.to_string()
}
/// The property name, upper case.
pub(crate) fn name(line: &str) -> String {
let end = line.find([':', ';', '\r', '\n']).unwrap_or(line.len());
line[..end].trim().to_ascii_uppercase()
}
/// Where the value of an unfolded line starts: after the first colon
/// outside a quoted parameter value.
pub(crate) fn value_start(line: &str) -> usize {
let mut quoted = false;
for (i, c) in line.char_indices() {
match c {
'"' => quoted = !quoted,
':' if !quoted => return i + 1,
_ => {}
}
}
line.len()
}
pub(crate) fn value(line: &str) -> &str {
&line[value_start(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 mut parts = Vec::new();
let (mut quoted, mut start) = (false, 0);
for (i, c) in head.char_indices() {
match c {
'"' => quoted = !quoted,
';' if !quoted => {
parts.push(&head[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&head[start..]);
parts.remove(0);
parts
}
/// The value of parameter `key` of an unfolded line, without quotes.
pub(crate) fn param(line: &str, key: &str) -> Option<String> {
param_parts(line).into_iter().find_map(|p| {
let (k, v) = p.split_once('=')?;
k.trim()
.eq_ignore_ascii_case(key)
.then(|| v.trim().trim_matches('"').to_string())
})
}
/// An unfolded line folded at 75 octets (RFC 5545, 3.1), ending in `eol`.
pub(crate) fn fold(line: &str, eol: &str) -> String {
let mut out = String::with_capacity(line.len() + eol.len() * (line.len() / 74 + 1));
let mut width = 0;
for c in line.chars() {
if width + c.len_utf8() > 75 {
out.push_str(eol);
out.push(' ');
width = 1;
}
out.push(c);
width += c.len_utf8();
}
out + eol
}
/// RFC 5545, 3.3.11. A TZID property is TEXT, so `Athens\, Bucharest`
/// there names the TZID parameter `"Athens, Bucharest"`.
pub(crate) fn unescape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
match (c, chars.clone().next()) {
('\\', Some(n @ ('\\' | ';' | ',' | 'n' | 'N'))) => {
out.push(if n.eq_ignore_ascii_case(&'n') {
'\n'
} else {
n
});
chars.next();
}
_ => out.push(c),
}
}
out
}
/// RFC 5545, 3.3.11.
pub(crate) fn escape_text(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,")
.replace('\n', "\\n")
.replace('\r', "")
}
▾Mpimdav/tests/bundle.rs
@@ -159,3 +159,14 @@ fn import_splits_cards() {
}
assert_eq!(bundle::cards(&[&cards[0], &cards[1]]), cards.concat());
}
#[test]
fn quoted_tzid_keeps_its_zone() {
let zone = "BEGIN:VTIMEZONE\r\nTZID:Work\\; late: shift\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
let file = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{zone}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=\"Work; late: shift\":20240101T100000\r\nRECURRENCE-ID;TZID=\"Work; late: shift\":20240101T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let objects = bundle::split_calendar(&file, &mut uids());
assert_eq!(objects.len(), 1);
assert!(objects[0].contains("TZID:Work\\; late: shift"));
}
▾Apimdav/tests/contact.rs
@@ -0,0 +1,105 @@
//! Photos and birthday events derived from contacts.
use chrono::{DateTime, Utc};
use pimdav::calcard::icalendar::ICalendar;
use pimdav::contact;
use pimdav::expand::expand;
use pimdav::zone::Zone;
fn card(lines: &str) -> String {
format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Anna Berg\r\n{}END:VCARD\r\n",
lines.replace('\n', "\r\n")
)
}
fn utc(s: &str) -> DateTime<Utc> {
format!("{s}T00:00:00Z").parse().unwrap()
}
/// The start dates of an event's instances in a range.
fn days(ics: &str, from: &str, to: &str) -> Vec<String> {
let cal = ICalendar::parse(ics).unwrap();
expand(&cal, utc(from)..utc(to), Zone::Utc)
.instances
.iter()
.map(|i| i.start.format("%Y-%m-%d").to_string())
.collect()
}
#[test]
fn photo_forms() {
let v3 = card("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\n");
assert_eq!(
contact::photo(&v3),
Some(vec![0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])
);
let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:c\r\nFN:X\r\nPHOTO:data:image/png;base64,iVBORw0K\r\nEND:VCARD\r\n";
assert_eq!(contact::photo(v4).unwrap()[..4], [0x89, b'P', b'N', b'G']);
assert_eq!(
contact::photo(&card("PHOTO;VALUE=uri:http://example.com/a.jpg\n")),
None
);
assert_eq!(contact::photo(&card("")), None);
}
#[test]
fn birthday_forms() {
let full = contact::dates(&card("BDAY:1980-03-15\n"), "k");
assert_eq!(full.len(), 1);
let (uid, ics) = &full[0];
assert!(ics.contains("SUMMARY:🎂 Anna Berg (1980)\r\n"));
assert!(ics.contains("TRANSP:TRANSPARENT\r\n"));
assert_eq!(
days(ics, "2025-01-01", "2027-01-01"),
["2025-03-15", "2026-03-15"]
);
// Stable per key and kind, different for another key.
assert_eq!(contact::dates(&card("BDAY:19800315\n"), "k")[0].0, *uid);
assert_ne!(contact::dates(&card("BDAY:19800315\n"), "other")[0].0, *uid);
for yearless in [
"BDAY:--0315\n",
"BDAY:--03-15\n",
"BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\n",
] {
let (_, ics) = &contact::dates(&card(yearless), "k")[0];
assert!(ics.contains("SUMMARY:🎂 Anna Berg\r\n"), "{yearless}");
assert_eq!(
days(ics, "2025-01-01", "2026-01-01"),
["2025-03-15"],
"{yearless}"
);
}
assert!(contact::dates(&card("BDAY;VALUE=text:circa 1800\n"), "k").is_empty());
assert!(contact::dates(&card("BDAY:1981-02-29\n"), "k").is_empty());
}
#[test]
fn leap_day_birthday() {
let (_, ics) = &contact::dates(&card("BDAY:1980-02-29\n"), "k")[0];
assert_eq!(
days(ics, "2027-01-01", "2029-01-01"),
["2027-02-28", "2028-02-29"]
);
}
#[test]
fn anniversaries() {
let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:c\r\nFN:Anna\r\nBDAY:--0315\r\nANNIVERSARY:20010601\r\nEND:VCARD\r\n";
let both = contact::dates(v4, "k");
assert_eq!(both.len(), 2);
assert!(both[1].1.contains("SUMMARY:💍 Anna (2001)\r\n"));
assert_ne!(both[0].0, both[1].0);
let evolution = contact::dates(&card("X-ANNIVERSARY:2001-06-01\n"), "k");
assert!(evolution[0].1.contains("💍"));
let apple = card(
"item1.X-ABDATE:2001-06-01\nitem1.X-ABLabel:_$!<Anniversary>!$_\nitem2.X-ABDATE:2010-01-01\nitem2.X-ABLabel:Other\n",
);
let dates = contact::dates(&apple, "k");
assert_eq!(dates.len(), 1);
assert!(dates[0].1.contains("💍 Anna Berg (2001)"));
}
▾Mpimdav/tests/itip.rs
@@ -438,3 +438,41 @@ fn rooms_answer_from_their_bookings() {
));
assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
}
#[test]
fn forget_a_deleted_principal() {
let gone = is(BOB);
let tomb = "mailto:bob-7@deleted.filebrowser.invalid";
// In alice's meeting bob becomes a tombstone that reaches no one.
let org = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:m1\r\nSUMMARY:Sync\r\nORGANIZER:{ALICE}\r\nATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}\r\nATTENDEE:{CAROL}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let out = itip::forget(&org, &gone, tomb).unwrap();
// The longer line is folded at 75 octets.
assert_eq!(
out.replace("\r\n ", ""),
org.replace(
&format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}"),
&format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=3.7:{tomb}")
)
);
// A copy of bob's meeting is cancelled, the override too; LF stays LF.
let copy = format!(
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:m2\nORGANIZER;CN=Bob:{}\nATTENDEE:{ALICE}\nEND:VEVENT\nBEGIN:VEVENT\nUID:m2\nRECURRENCE-ID:20260105T100000Z\nSTATUS:CONFIRMED\nORGANIZER:{BOB}\nEND:VEVENT\nEND:VCALENDAR\n",
BOB.to_uppercase()
);
let out = itip::forget(&copy, &gone, tomb).unwrap();
assert_eq!(
out,
format!(
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSTATUS:CANCELLED\nUID:m2\nORGANIZER;CN=Bob:{tomb}\nATTENDEE:{ALICE}\nEND:VEVENT\nBEGIN:VEVENT\nUID:m2\nRECURRENCE-ID:20260105T100000Z\nSTATUS:CANCELLED\nORGANIZER:{tomb}\nEND:VEVENT\nEND:VCALENDAR\n"
)
);
assert_eq!(
itip::forget(&org, &is(CAROL.trim_end_matches('m')), tomb),
None
);
}
▾Mserver/src/api/admin.rs
@@ -4,8 +4,8 @@
use std::sync::Arc;
use api_types::{
AdminShare, AdminUser, CreateRoom, CreateUser, Mode, OkResp, RoomInfo, RoomKind, Root,
Settings, UpdateRoom, UpdateUser,
AdminPimLink, AdminShare, AdminUser, CreateRoom, CreateUser, Mode, OkResp, RoomInfo, RoomKind,
Root, Settings, UpdateRoom, UpdateUser,
};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
@@ -17,6 +17,7 @@ use crate::api::common::{
};
use crate::api::pim::principal_href;
use crate::api::shares;
use crate::api::{pim_api, pim_schedule};
use crate::db::{PimPrincipal, RootRow, UserType};
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -221,7 +222,13 @@ pub async fn delete_user(
));
}
crate::auth::forget_verified();
if !state.db.delete_user(id).await? {
let _lock = pim_schedule::LOCK.lock().await;
let pid = state.db.principal_of(id).await?;
let ops = match state.db.pim_principal_by_id(pid).await? {
Some(p) => pim_schedule::forget(&state, &p).await?,
None => Vec::new(),
};
if !state.db.delete_user(id, &ops).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
@@ -273,6 +280,40 @@ pub async fn delete_share(
Ok(Json(OkResp {}))
}
/// GET {ADMIN_PIM_LINKS} — every public calendar and address book feed.
/// Like the share list, it carries the full tokens.
pub async fn list_pim_links(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
) -> Result<Json<Vec<AdminPimLink>>, ApiError> {
let rows = state.db.all_pim_links().await?;
Ok(Json(
rows.into_iter()
.map(|r| AdminPimLink {
link: pim_api::link_info(&r.link, r.kind),
collection_id: r.link.collection_id,
collection_name: r.collection_name,
kind: pim_api::wire_kind(r.kind),
owner_id: r.owner_id,
owner_name: r.owner_name,
owner_active: r.owner_active,
})
.collect(),
))
}
/// DELETE {ADMIN_PIM_LINKS}/{id} — revoke a feed whoever made it.
pub async fn delete_pim_link(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.admin_delete_pim_link(id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
}
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
// Rooms and resources
// ---------------------------------------------------------------------------
@@ -362,7 +403,17 @@ pub async fn delete_room(
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.delete_room(id).await? {
let _lock = pim_schedule::LOCK.lock().await;
let Some(room) = state
.db
.pim_principal_by_id(id)
.await?
.filter(|p| p.user_id.is_none())
else {
return Err(room_not_found());
};
let ops = pim_schedule::forget(&state, &room).await?;
if !state.db.delete_room(id, &ops).await? {
return Err(room_not_found());
}
Ok(Json(OkResp {}))
▾Mserver/src/api/mod.rs
@@ -1,11 +1,12 @@
use std::sync::Arc;
use api_types::{
ADMIN_ROOMS, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER,
AUTH_PASSWORD, AUTH_SETUP, DAV, DAV_SHARE, EXPORT_SUFFIX, FEED, FILES, FINISH_SUFFIX,
IMPORT_SUFFIX, LINKS_SUFFIX, PIM, PIM_COLLECTIONS, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
ADMIN_PIM_LINKS, ADMIN_ROOMS, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS,
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, DAV, DAV_SHARE, EXPORT_SUFFIX, FEED, FILES,
FINISH_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, PHOTO_SUFFIX, PIM, PIM_COLLECTIONS,
SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV,
WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -122,6 +123,8 @@ pub fn router(state: Arc<AppState>) -> Router {
let pim_link = format!("{PIM_COLLECTIONS}/{{id}}{LINKS_SUFFIX}/{{link_id}}");
let pim_import = format!("{PIM_COLLECTIONS}/{{id}}{IMPORT_SUFFIX}");
let pim_export = format!("{PIM_COLLECTIONS}/{{id}}{EXPORT_SUFFIX}");
let pim_photo = format!("{PIM_COLLECTIONS}/{{id}}{OBJECTS_SUFFIX}/{{name}}{PHOTO_SUFFIX}");
let admin_pim_link = format!("{ADMIN_PIM_LINKS}/{{id}}");
let feed = format!("{FEED}/{{file}}");
// A wildcard needs something to capture, so `/dav/` gets its own pattern:
// mount clients ask for it with the trailing slash, which matches neither
@@ -195,6 +198,9 @@ pub fn router(state: Arc<AppState>) -> Router {
&pim_export,
get(pim_api::export).post(pim_api::export_to_root),
)
.route(&pim_photo, get(pim_api::photo))
.route(ADMIN_PIM_LINKS, get(admin::list_pim_links))
.route(&admin_pim_link, delete(admin::delete_pim_link))
.route(&feed, get(pim_api::feed))
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
▾Mserver/src/api/pim.rs
@@ -10,7 +10,8 @@
//! A home also shows the collections lent to its account, as
//! `shared-{collection id}`, and the address book home shows the generated
//! system address book as `system`. The calendar home holds the scheduling
//! `inbox` and `outbox`. A room's home holds its bookings.
//! `inbox` and `outbox`, and the generated `birthdays` calendar. A room's home
//! holds its bookings.
//!
//! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto
//! the store and assembles the responses.
@@ -34,7 +35,7 @@ use pimdav::xml::{
with_children, with_text,
};
use pimdav::zone::{self, Zone};
use pimdav::{filter, freebusy, object};
use pimdav::{contact, filter, freebusy, object};
use super::pim_schedule::{self, Directory, Stored, Writer};
use sha2::{Digest, Sha256};
@@ -56,9 +57,11 @@ const MAX_XML_SIZE: usize = 1024 * 1024;
/// (RFC 2606), so nothing sent there can reach anyone.
pub(super) const MAIL_DOMAIN: &str = "filebrowser.invalid";
/// The id of the system address book, which no stored collection has.
/// The ids of the generated collections, which no stored one has.
const DIRECTORY: i64 = 0;
const BIRTHDAYS: i64 = -1;
const DIRECTORY_SLUG: &str = "system";
const BIRTHDAYS_SLUG: &str = "birthdays";
/// The slug prefix of a collection lent to the account.
const SHARED_PREFIX: &str = "shared-";
/// The scheduling inbox is a stored calendar collection under this slug.
@@ -483,10 +486,41 @@ impl PrincipalView {
// Collections and members
// ---------------------------------------------------------------------------
/// Whether a collection is generated rather than stored.
fn generated(id: i64) -> bool {
id <= DIRECTORY
}
/// A generated collection. Its members' ETags stand in for a change counter:
/// any change to them changes the CTag and the sync token. Only the current
/// token is valid, so a client resyncs after each change.
fn generated_collection(
id: i64,
slug: &str,
name: &str,
components: &str,
members: &[(PimObject, Vec<u8>)],
) -> PimCollection {
let digest = Sha256::digest(
members
.iter()
.map(|(o, _)| o.etag.as_str())
.collect::<String>(),
);
PimCollection {
id,
slug: slug.to_string(),
displayname: Some(name.to_string()),
components: components.to_string(),
seq: i64::from_be_bytes(digest[..8].try_into().expect("8 bytes")) & i64::MAX,
..Default::default()
}
}
type Members = Vec<(PimObject, Vec<u8>)>;
/// The generated system address book: one card per visible principal.
async fn directory(
state: &AppState,
) -> Result<(PimCollection, Vec<(PimObject, Vec<u8>)>), ApiError> {
async fn directory(state: &AppState) -> Result<(PimCollection, Members), ApiError> {
let mut members = Vec::new();
for p in state.db.pim_principals().await? {
let uuid = principal_uuid(p.id);
@@ -499,35 +533,53 @@ async fn directory(
kind: p.kind,
};
let data = principal::card(&uid, &view, &mailto(&p.name, p.kind)).into_bytes();
let obj = PimObject {
name: format!("{uuid}.vcf"),
uid,
component: "VCARD".to_string(),
etag: etag_of(&data),
size: data.len() as i64,
..Default::default()
};
members.push((obj, data));
members.push((
generated_object(format!("{uuid}.vcf"), uid, "VCARD", &data),
data,
));
}
// The members' ETags stand in for a change counter: any added, removed or
// renamed principal changes the CTag and the sync token.
let digest = Sha256::digest(
members
.iter()
.map(|(o, _)| o.etag.as_str())
.collect::<String>(),
);
let seq = i64::from_be_bytes(digest[..8].try_into().expect("8 bytes")) & i64::MAX;
let col = PimCollection {
id: DIRECTORY,
slug: DIRECTORY_SLUG.to_string(),
displayname: Some("Directory".to_string()),
seq,
..Default::default()
};
let col = generated_collection(DIRECTORY, DIRECTORY_SLUG, "Directory", "", &members);
Ok((col, members))
}
/// The generated birthday calendar of a principal: the birthdays and
/// anniversaries in its own address books, not lent ones.
// ponytail: rebuilt from every contact on each request. Store the events if
// large address books make it slow.
async fn birthdays(state: &AppState, principal: i64) -> Result<(PimCollection, Members), ApiError> {
let mut members = Vec::new();
for book in state
.db
.pim_collections(principal, PimKind::AddressBook)
.await?
{
for (o, data) in state.db.pim_objects_with_data(book.id).await? {
let key = format!("{}/{}", book.id, o.name);
for (uid, ics) in contact::dates(&String::from_utf8_lossy(&data), &key) {
let data = ics.into_bytes();
members.push((
generated_object(format!("{uid}.ics"), uid, "VEVENT", &data),
data,
));
}
}
}
let mut col = generated_collection(BIRTHDAYS, BIRTHDAYS_SLUG, "Birthdays", "VEVENT", &members);
col.transparent = true;
Ok((col, members))
}
fn generated_object(name: String, uid: String, component: &str, data: &[u8]) -> PimObject {
PimObject {
name,
uid,
component: component.to_string(),
etag: etag_of(data),
size: data.len() as i64,
..Default::default()
}
}
/// The request context: who asks, and in whose URL space.
struct Cx<'a> {
state: &'a AppState,
@@ -569,9 +621,14 @@ impl Cx<'_> {
owner: space.principal(),
}));
}
if kind == PimKind::AddressBook && slug == DIRECTORY_SLUG {
let generated = match (kind, slug) {
(PimKind::AddressBook, DIRECTORY_SLUG) => Some(directory(self.state).await?.0),
(PimKind::Calendar, BIRTHDAYS_SLUG) => Some(birthdays(self.state, space.id).await?.0),
_ => None,
};
if let Some(c) = generated {
return Ok(Some(Col {
c: directory(self.state).await?.0,
c,
access: Access::Read,
owner: space.principal(),
}));
@@ -611,13 +668,15 @@ impl Cx<'_> {
})
.collect();
if space.mine {
if kind == PimKind::AddressBook {
out.push(Col {
c: directory(self.state).await?.0,
access: Access::Read,
owner: space.principal(),
});
}
let generated = match kind {
PimKind::AddressBook => directory(self.state).await?.0,
PimKind::Calendar => birthdays(self.state, space.id).await?.0,
};
out.push(Col {
c: generated,
access: Access::Read,
owner: space.principal(),
});
for (c, owner, mode) in db.pim_shared_collections(self.me.id, kind).await? {
out.push(lent(c, &owner, mode));
}
@@ -625,11 +684,12 @@ impl Cx<'_> {
Ok(out)
}
async fn members(&self, c: &PimCollection) -> Result<Vec<(PimObject, Vec<u8>)>, ApiError> {
if c.id == DIRECTORY {
return Ok(directory(self.state).await?.1);
async fn members(&self, c: &PimCollection) -> Result<Members, ApiError> {
match c.id {
DIRECTORY => Ok(directory(self.state).await?.1),
BIRTHDAYS => Ok(birthdays(self.state, self.space().id).await?.1),
id => Ok(self.state.db.pim_objects_with_data(id).await?),
}
Ok(self.state.db.pim_objects_with_data(c.id).await?)
}
async fn member(
@@ -637,8 +697,8 @@ impl Cx<'_> {
c: &PimCollection,
name: &str,
) -> Result<Option<(PimObject, Vec<u8>)>, ApiError> {
if c.id == DIRECTORY {
let all = directory(self.state).await?.1;
if generated(c.id) {
let all = self.members(c).await?;
return Ok(all.into_iter().find(|(o, _)| o.name == name));
}
Ok(self.state.db.pim_object(c.id, name).await?)
@@ -756,7 +816,7 @@ impl Cx<'_> {
};
let objects = match (deep, col.c.id) {
(false, _) => Vec::new(),
(true, DIRECTORY) => self
(true, id) if generated(id) => self
.members(&col.c)
.await?
.into_iter()
@@ -1226,7 +1286,7 @@ impl Cx<'_> {
}
// Names the home shows for lent and generated collections.
if slug.starts_with(SHARED_PREFIX)
|| [DIRECTORY_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
|| [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&slug.as_str())
{
return Ok(status(StatusCode::FORBIDDEN));
}
@@ -1806,14 +1866,12 @@ impl Cx<'_> {
let since = match token.is_empty() {
true => None,
false => match parse_sync_token(&token) {
// The system address book has no change log: only
// its current token is valid.
Some((DIRECTORY, seq)) if col.id == DIRECTORY && seq == col.seq => {
// A generated collection has no change log: only its
// current token is valid.
Some((id, seq)) if id == col.id && generated(id) && seq == col.seq => {
Some(seq)
}
Some((id, seq))
if id == col.id && col.id != DIRECTORY && seq <= col.seq =>
{
Some((id, seq)) if id == col.id && !generated(id) && seq <= col.seq => {
Some(seq)
}
_ => {
@@ -1821,7 +1879,7 @@ impl Cx<'_> {
}
},
};
let mut changes = if col.id == DIRECTORY {
let mut changes = if generated(col.id) {
match since {
Some(_) => Vec::new(),
None => self
@@ -1841,8 +1899,8 @@ 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 col.id != DIRECTORY => *s,
_ if col.id == DIRECTORY => col.seq,
(true, Some((_, s, _))) if !generated(col.id) => *s,
_ if generated(col.id) => col.seq,
(_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
};
let mut responses = Vec::new();
@@ -2011,7 +2069,8 @@ fn too_many() -> Response<Body> {
/// `(collection id, seq)` of a token [`sync_token`] made.
fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
let (id, seq) = token.strip_prefix("urn:fbng:sync:")?.split_once('-')?;
// The birthday calendar's id is negative.
let (id, seq) = token.strip_prefix("urn:fbng:sync:")?.rsplit_once('-')?;
Some((id.parse().ok()?, seq.parse().ok()?))
}
▾Mserver/src/api/pim_api.rs
@@ -8,6 +8,8 @@
//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root
//!
//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo
//!
//! Public: `GET {FEED}/{token}` — a collection as one file.
use std::collections::HashMap;
@@ -24,7 +26,7 @@ use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG,
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use pimdav::bundle::{self, Detail};
use pimdav::object;
use pimdav::{contact, object};
use sha2::{Digest, Sha256};
use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
@@ -43,7 +45,7 @@ const MAX_IMPORT: usize = 20 * 1024 * 1024;
/// How many skipped objects an import names.
const MAX_SKIPPED: usize = 100;
fn wire_kind(kind: PimKind) -> PimCollectionKind {
pub(super) fn wire_kind(kind: PimKind) -> PimCollectionKind {
match kind {
PimKind::Calendar => PimCollectionKind::Calendar,
PimKind::AddressBook => PimCollectionKind::Addressbook,
@@ -195,6 +197,41 @@ async fn reachable(
}
}
/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}
///
/// Always a WebP made by the thumbnail cache, never the stored bytes: those
/// come from a client and could be HTML or SVG with script. So without a
/// cache there are no photos, as there are no thumbnails.
pub async fn photo(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath((id, name)): AxumPath<(i64, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let no_photo = || ApiError::new(StatusCode::NOT_FOUND, "no photo");
let Some(thumbs) = state.thumbs.as_ref() else {
return Err(no_photo());
};
let (_, kind, _, _) = reachable(&state, &auth, id).await?;
if kind != PimKind::AddressBook {
return Err(no_photo());
}
let (obj, data) = state.db.pim_object(id, &name).await?.ok_or_else(no_photo)?;
let cached = [
(ETAG, obj.etag.clone()),
(CACHE_CONTROL, "private, no-cache".to_string()),
];
if headers.get(IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(obj.etag.as_str()) {
return Ok((StatusCode::NOT_MODIFIED, cached).into_response());
}
let image = contact::photo(&String::from_utf8_lossy(&data)).ok_or_else(no_photo)?;
let bytes = thumbs
.of_bytes(&format!("pim-photo {}", obj.etag), image)
.await
.ok_or_else(no_photo)?;
Ok((cached, [(CONTENT_TYPE, "image/webp")], bytes).into_response())
}
fn extension(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "ics",
@@ -202,7 +239,7 @@ fn extension(kind: PimKind) -> &'static str {
}
}
fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
pub(super) fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
PimLinkInfo {
id: link.id,
path: format!("{FEED}/{}.{}", link.token, extension(kind)),
▾Mserver/src/api/pim_schedule.rs
@@ -107,6 +107,8 @@ impl Directory {
Some("") => UserType::Individual,
Some("rooms.") => UserType::Room,
Some("resources.") => UserType::Resource,
// The tombstone of a deleted principal.
Some("deleted.") => return Recipient::Unknown,
_ => return Recipient::External,
};
let name = percent_decode_str(local).decode_utf8_lossy();
@@ -132,6 +134,40 @@ 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> {
let dir = Directory(vec![gone.clone()]);
let is_gone = dir.is(gone.id);
let tombstone = format!(
"mailto:{}-{}@deleted.{MAIL_DOMAIN}",
seg(&gone.name),
gone.id
);
let uuid = principal_uuid(gone.id);
let encoded = seg(&gone.name);
let needles = [gone.name.as_str(), encoded.as_str(), uuid.as_str()];
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 {
continue;
};
let data = new.into_bytes();
ops.push(PimOp::Put {
collection_id,
obj: PimObject {
etag: etag_of(&data),
..obj
},
data,
});
}
Ok(ops)
}
/// What a PUT of a calendar object stores, and what else it writes.
pub(crate) struct Stored {
pub data: Vec<u8>,
▾Mserver/src/db.rs
@@ -178,6 +178,16 @@ 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,
pub collection_name: String,
pub kind: PimKind,
pub owner_id: i64,
pub owner_name: String,
pub owner_active: bool,
}
pub struct ShareWithCreator {
pub share: ShareRow,
pub creator_name: String,
@@ -1150,9 +1160,15 @@ impl Db {
stmt.query_row([id], |r| r.get(0)).optional()
}
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute("DELETE FROM users WHERE id = ?1", [id])? > 0)
/// Deletes an account after `ops`, in one transaction. The ops make other
/// principals' objects forget it (`pim_schedule::forget`).
pub async fn delete_user(&self, id: i64, ops: &[PimOp]) -> DbResult<bool> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
apply_ops(&tx, ops)?;
let deleted = tx.execute("DELETE FROM users WHERE id = ?1", [id])? > 0;
tx.commit()?;
Ok(deleted)
}
// ---------- shares ----------
@@ -1469,56 +1485,7 @@ impl Db {
pub async fn pim_apply(&self, ops: &[PimOp]) -> DbResult<()> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
for op in ops {
match op {
PimOp::Put {
collection_id,
obj,
data,
} => {
put_object(&tx, *collection_id, obj, data)?;
}
PimOp::Delete {
collection_id,
name,
} => {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
)?;
record_pim_change(&tx, *collection_id, name, true)?;
}
PimOp::Inbox {
principal_id,
obj,
data,
} => {
let inbox: i64 = tx.query_row(
"SELECT id FROM pim_collections
WHERE principal_id = ?1 AND kind = 'cal' AND slug = 'inbox'",
[principal_id],
|r| r.get(0),
)?;
put_object(&tx, inbox, obj, data)?;
// ponytail: a fixed cap. Clients that never empty the inbox
// would fill it forever; an age limit may suit better.
let old: Vec<String> = tx
.prepare_cached(
"SELECT name FROM pim_objects WHERE collection_id = ?1
ORDER BY id DESC LIMIT -1 OFFSET ?2",
)?
.query_map(params![inbox, INBOX_KEEP], |r| r.get(0))?
.collect::<DbResult<_>>()?;
for name in old {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![inbox, name],
)?;
record_pim_change(&tx, inbox, &name, true)?;
}
}
}
}
apply_ops(&tx, ops)?;
tx.commit()?;
Ok(())
}
@@ -1970,12 +1937,86 @@ impl Db {
)? > 0)
}
pub async fn delete_room(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute(
/// Deletes a room or resource after `ops`, as [`Self::delete_user`] does.
pub async fn delete_room(&self, id: i64, ops: &[PimOp]) -> DbResult<bool> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
apply_ops(&tx, ops)?;
let deleted = tx.execute(
"DELETE FROM principals WHERE id = ?1 AND user_id IS NULL",
[id],
)? > 0)
)? > 0;
tx.commit()?;
Ok(deleted)
}
/// A principal by id, a disabled account's too.
pub async fn pim_principal_by_id(&self, id: i64) -> DbResult<Option<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.id = ?1"
))?;
stmt.query_row([id], map_principal).optional()
}
/// `(collection id, object, data)` of the calendar objects of other
/// principals whose text holds one of `needles`, compared without ASCII
/// case. The inbox is left out: its messages are only a record.
pub async fn pim_objects_mentioning(
&self,
principal_id: i64,
needles: &[&str],
) -> DbResult<Vec<(i64, PimObject, Vec<u8>)>> {
let c = self.0.lock().await;
let any: Vec<String> = (0..needles.len())
.map(|i| format!("instr(lower(CAST(o.data AS TEXT)), ?{}) > 0", i + 2))
.collect();
let mut stmt = c.prepare(&format!(
"SELECT o.collection_id, {PIM_OBJECT_COLS_O}, o.data
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.principal_id != ?1 AND c.kind = 'cal' AND c.slug != 'inbox'
AND ({})",
any.join(" OR ")
))?;
let lower: Vec<String> = needles.iter().map(|n| n.to_ascii_lowercase()).collect();
let mut params: Vec<&dyn rusqlite::ToSql> = vec![&principal_id];
params.extend(lower.iter().map(|n| n as &dyn rusqlite::ToSql));
stmt.query_map(params.as_slice(), |r| {
Ok((r.get(0)?, map_pim_object_at(r, 1)?, r.get(8)?))
})?
.collect()
}
/// Every public feed link with its collection and owner, for the admin.
pub async fn all_pim_links(&self) -> DbResult<Vec<PimLinkWithOwner>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT l.id, l.token, l.collection_id, l.busy_only, l.created_at, l.expires_at,
l.password_hash, coalesce(c.displayname, c.slug), c.kind, u.id, u.name,
u.active != 0
FROM pim_links l
JOIN pim_collections c ON c.id = l.collection_id
JOIN principals p ON p.id = c.principal_id
JOIN users u ON u.id = p.user_id
ORDER BY u.name COLLATE NOCASE, l.id DESC",
)?;
stmt.query_map([], |r| {
Ok(PimLinkWithOwner {
link: map_pim_link(r)?,
collection_name: r.get(7)?,
kind: PimKind::parse(&r.get::<_, String>(8)?),
owner_id: r.get(9)?,
owner_name: r.get(10)?,
owner_active: r.get(11)?,
})
})?
.collect()
}
/// Revokes a feed link whoever made it.
pub async fn admin_delete_pim_link(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute("DELETE FROM pim_links WHERE id = ?1", [id])? > 0)
}
/// Whether users may create writable (read-write) shares. Off by default;
@@ -2105,6 +2146,61 @@ fn ensure_inbox(c: &Connection, principal_id: i64) -> DbResult<()> {
Ok(())
}
/// The writes of [`PimOp`]s, inside the caller's transaction.
fn apply_ops(tx: &rusqlite::Transaction, ops: &[PimOp]) -> DbResult<()> {
for op in ops {
match op {
PimOp::Put {
collection_id,
obj,
data,
} => {
put_object(tx, *collection_id, obj, data)?;
}
PimOp::Delete {
collection_id,
name,
} => {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
)?;
record_pim_change(tx, *collection_id, name, true)?;
}
PimOp::Inbox {
principal_id,
obj,
data,
} => {
let inbox: i64 = tx.query_row(
"SELECT id FROM pim_collections
WHERE principal_id = ?1 AND kind = 'cal' AND slug = 'inbox'",
[principal_id],
|r| r.get(0),
)?;
put_object(tx, inbox, obj, data)?;
// ponytail: a fixed cap. Clients that never empty the inbox
// would fill it forever; an age limit may suit better.
let old: Vec<String> = tx
.prepare_cached(
"SELECT name FROM pim_objects WHERE collection_id = ?1
ORDER BY id DESC LIMIT -1 OFFSET ?2",
)?
.query_map(params![inbox, INBOX_KEEP], |r| r.get(0))?
.collect::<DbResult<_>>()?;
for name in old {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![inbox, name],
)?;
record_pim_change(tx, inbox, &name, true)?;
}
}
}
}
Ok(())
}
/// Stores an object under `obj.name`, replacing one of that name.
fn put_object(
tx: &rusqlite::Transaction,
@@ -2226,16 +2322,23 @@ fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
}
const PIM_OBJECT_COLS: &str = "name, uid, component, etag, length(data), modified_at, schedule_tag";
const PIM_OBJECT_COLS_O: &str =
"o.name, o.uid, o.component, o.etag, length(o.data), o.modified_at, o.schedule_tag";
fn map_pim_object(r: &rusqlite::Row) -> DbResult<PimObject> {
map_pim_object_at(r, 0)
}
/// [`PIM_OBJECT_COLS`] starting at column `at`.
fn map_pim_object_at(r: &rusqlite::Row, at: usize) -> DbResult<PimObject> {
Ok(PimObject {
name: r.get(0)?,
uid: r.get(1)?,
component: r.get(2)?,
etag: r.get(3)?,
size: r.get(4)?,
modified_at: r.get(5)?,
schedule_tag: r.get(6)?,
name: r.get(at)?,
uid: r.get(at + 1)?,
component: r.get(at + 2)?,
etag: r.get(at + 3)?,
size: r.get(at + 4)?,
modified_at: r.get(at + 5)?,
schedule_tag: r.get(at + 6)?,
})
}
@@ -2571,7 +2674,7 @@ mod tests {
db.pim_set_share(admins[0].id, bob.id, PimShareMode::Ro)
.await
.unwrap();
assert!(db.delete_user(bob.id).await.unwrap());
assert!(db.delete_user(bob.id, &[]).await.unwrap());
assert!(db.pim_principal("bob").await.unwrap().is_none());
assert!(!db.name_taken("bob").await.unwrap());
assert!(db.pim_collection_by_id(bobs[0].id).await.unwrap().is_none());
@@ -2784,9 +2887,9 @@ mod tests {
edit(&db, bob.id, None, Some(false), None).await;
// Deletion.
assert!(db.delete_user(bob.id).await.unwrap());
assert!(db.delete_user(bob.id, &[]).await.unwrap());
assert!(db.find_user_by_id(bob.id).await.unwrap().is_none());
assert!(!db.delete_user(bob.id).await.unwrap());
assert!(!db.delete_user(bob.id, &[]).await.unwrap());
assert_eq!(db.user_count().await.unwrap(), 2);
}
▾Mserver/src/thumb.rs
@@ -3,6 +3,7 @@
//! Images decode in-process. Videos go through `ffmpeg`, which is optional.
//! Nothing here writes to the database: the cache is disposable.
use std::io::{BufRead, Cursor, Seek};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
@@ -136,6 +137,22 @@ impl Thumbs {
made
}
/// The thumbnail of an image held in memory, such as a contact photo.
/// `key` names the source and changes whenever it does.
pub async fn of_bytes(&self, key: &str, data: Vec<u8>) -> Option<Vec<u8>> {
let path = self.entry_path(Path::new(key), 0, 0);
if let Some(hit) = read_hit(&path).await {
return hit;
}
let _permit = self.limit.acquire().await.ok()?;
let made = tokio::task::spawn_blocking(move || bytes_thumb(&data))
.await
.ok()
.flatten();
store(&path, made.as_deref().unwrap_or(&[])).await;
made
}
/// `<cache>/<first 2 hex>/<rest>.webp`.
///
/// The fan-out keeps one folder from collecting every thumbnail on the
@@ -214,15 +231,27 @@ async fn store(path: &Path, bytes: &[u8]) {
/// Decode, downscale and encode one image. Blocking; run it off the reactor.
fn image_thumb(src: &Path) -> Option<Vec<u8>> {
let reader = ImageReader::open(src).ok()?.with_guessed_format().ok()?;
thumb_of(|| ImageReader::open(src).ok()?.with_guessed_format().ok())
}
fn bytes_thumb(data: &[u8]) -> Option<Vec<u8>> {
thumb_of(|| {
ImageReader::new(Cursor::new(data))
.with_guessed_format()
.ok()
})
}
/// `open` gives a fresh reader of the same image each time.
fn thumb_of<R: BufRead + Seek>(open: impl Fn() -> Option<ImageReader<R>>) -> Option<Vec<u8>> {
// Dimensions come from the header, so an oversized file is refused before
// anything is allocated for it.
let (w, h) = reader.into_dimensions().ok()?;
let (w, h) = open()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let mut reader = ImageReader::open(src).ok()?.with_guessed_format().ok()?;
let mut reader = open()?;
let mut limits = Limits::default();
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
▾Mserver/tests/api_pim.rs
@@ -213,8 +213,9 @@ async fn homes_list_the_default_collections() {
let (env, auth) = setup().await;
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
let ms = parse_multistatus(&r);
// The home, the calendar, and the scheduling inbox and outbox.
assert_eq!(ms.len(), 4, "{}", r.text());
// The home, the calendar, the birthday calendar, and the scheduling
// inbox and outbox.
assert_eq!(ms.len(), 5, "{}", r.text());
for (href, kind) in [(INBOX, "schedule-inbox"), (OUTBOX, "schedule-outbox")] {
let rt = prop(&ms, href, DAV, "resourcetype").unwrap();
assert!(xml::child(&rt, CALDAV, kind).is_some(), "{href}");
▾Aserver/tests/api_pim_derived.rs
@@ -0,0 +1,443 @@
//! What the server derives on its own: contact photos, the birthday
//! calendar, the cleanup after a deleted principal, and the admin view of
//! public feeds.
mod common;
use axum::http::{Method, StatusCode};
use base64::Engine;
use common::*;
use pimdav::xml::{self, DAV};
use serde_json::json;
use xmltree::Element;
const PW: &str = "secret12345";
const BOOK: &str = "/pim/addressbooks/alice/default/";
struct Pim {
env: Env,
admin: Client,
alice: Client,
}
impl Pim {
async fn new(env: Env) -> Self {
let admin = env.admin().await;
for u in ["alice", "bob", "carol"] {
create_user(&admin, u, PW, &[]).await;
}
let alice = login(&env, "alice", PW).await;
Pim { env, admin, alice }
}
async fn req(&self, user: &str, verb: &str, path: &str, depth: &str, body: &str) -> Resp {
let auth = basic(user, PW);
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&[("authorization", &auth), ("depth", depth)],
body.as_bytes().to_vec(),
)
.await
}
async fn put(&self, user: &str, path: &str, body: &str) {
let r = self.req(user, "PUT", path, "0", body).await;
assert!(
r.status.is_success(),
"PUT {path}: {} {}",
r.status,
r.text()
);
}
/// The hrefs PROPFIND lists below a collection.
async fn members(&self, user: &str, collection: &str) -> Vec<String> {
let r = self.req(user, "PROPFIND", collection, "1", "").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| xml::text(xml::child(resp, DAV, "href").unwrap()))
.filter(|h| h != collection)
.collect()
}
async fn sync_token(&self, user: &str, collection: &str) -> String {
let body = "<d:propfind xmlns:d=\"DAV:\"><d:prop><d:sync-token/></d:prop></d:propfind>";
let r = self.req(user, "PROPFIND", collection, "0", body).await;
let root = Element::parse(r.body.as_slice()).unwrap();
let resp = xml::elements(&root).next().unwrap();
let stat = xml::child(resp, DAV, "propstat").unwrap();
let prop = xml::child(stat, DAV, "prop").unwrap();
xml::text(xml::child(prop, DAV, "sync-token").unwrap())
}
/// The id of alice's collection with this URL.
async fn id(&self, url: &str) -> i64 {
let list = self.alice.get("/api/pim/collections").await.json();
list.as_array()
.unwrap()
.iter()
.find(|c| c["url"] == url)
.unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"]
.as_i64()
.unwrap()
}
}
fn unfold(s: &str) -> String {
s.replace("\r\n ", "")
}
fn contact(uid: &str, extra: &str) -> String {
format!("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:Anna Berg\r\n{extra}END:VCARD\r\n")
}
/// A contact whose PHOTO is a small PNG, inline as vCard 3 does it.
fn contact_with_photo(uid: &str) -> String {
let mut png = Vec::new();
image::RgbImage::from_pixel(8, 8, image::Rgb([200, 30, 30]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
contact(uid, &format!("PHOTO;ENCODING=b;TYPE=PNG:{b64}\r\n"))
}
#[tokio::test]
async fn contact_photos() {
let pim = Pim::new(Env::with_thumbs().await).await;
pim.put(
"alice",
&format!("{BOOK}anna.vcf"),
&contact_with_photo("anna"),
)
.await;
pim.put(
"alice",
&format!("{BOOK}url.vcf"),
&contact("url", "PHOTO;VALUE=uri:http://127.0.0.1/a.png\r\n"),
)
.await;
pim.put("alice", &format!("{BOOK}none.vcf"), &contact("none", ""))
.await;
let id = pim.id(BOOK).await;
let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo");
let r = pim.alice.get(&photo("anna.vcf")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.header("content-type").as_deref(), Some("image/webp"));
assert_eq!(&r.body[..4], b"RIFF");
let cached = walk(pim.env.cache.as_ref().unwrap().path());
assert_eq!(cached, 1, "one thumbnail in the cache");
let etag = r.header("etag").unwrap();
let again = pim
.alice
.raw(
Method::GET,
&photo("anna.vcf"),
&[("if-none-match", &etag)],
Vec::new(),
)
.await;
assert_eq!(again.status, StatusCode::NOT_MODIFIED);
// No inline image, no object, or no access: 404.
for name in ["url.vcf", "none.vcf", "missing.vcf"] {
assert_eq!(
pim.alice.get(&photo(name)).await.status,
StatusCode::NOT_FOUND
);
}
let bob = login(&pim.env, "bob", PW).await;
assert_eq!(
bob.get(&photo("anna.vcf")).await.status,
StatusCode::NOT_FOUND
);
let r = pim
.alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": "bob", "mode": "ro"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(bob.get(&photo("anna.vcf")).await.status, StatusCode::OK);
// A calendar holds no photos.
let cal = pim.id("/pim/calendars/alice/default/").await;
let r = pim
.alice
.get(&format!("/api/pim/collections/{cal}/objects/x.ics/photo"))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
/// Files below `dir`.
fn walk(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.map(|p| if p.is_dir() { walk(&p) } else { 1 })
.sum()
}
#[tokio::test]
async fn no_photos_without_a_cache() {
let pim = Pim::new(Env::new().await).await;
pim.put(
"alice",
&format!("{BOOK}anna.vcf"),
&contact_with_photo("anna"),
)
.await;
let id = pim.id(BOOK).await;
let r = pim
.alice
.get(&format!("/api/pim/collections/{id}/objects/anna.vcf/photo"))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn birthday_calendar() {
let pim = Pim::new(Env::new().await).await;
let birthdays = "/pim/calendars/alice/birthdays/";
assert!(
pim.members("alice", "/pim/calendars/alice/")
.await
.contains(&birthdays.to_string())
);
assert!(pim.members("alice", birthdays).await.is_empty());
pim.put(
"alice",
&format!("{BOOK}anna.vcf"),
&contact("anna", "BDAY:1980-03-15\r\n"),
)
.await;
// Only the own address books count, not the system one or lent ones.
let members = pim.members("alice", birthdays).await;
assert_eq!(members.len(), 1, "{members:?}");
let r = pim.req("alice", "GET", &members[0], "0", "").await;
assert_eq!(r.status, StatusCode::OK);
let ics = unfold(&r.text());
assert!(ics.contains("SUMMARY:🎂 Anna Berg (1980)"), "{ics}");
assert!(ics.contains("RRULE:FREQ=YEARLY"));
assert!(
pim.members("bob", "/pim/calendars/bob/birthdays/")
.await
.is_empty()
);
// A changed birthday changes the token; the old one is no longer valid.
let before = pim.sync_token("alice", birthdays).await;
pim.put(
"alice",
&format!("{BOOK}anna.vcf"),
&contact("anna", "BDAY:1980-03-16\r\n"),
)
.await;
let after = pim.sync_token("alice", birthdays).await;
assert_ne!(before, after);
let sync = |token: &str| {
format!(
"<d:sync-collection xmlns:d=\"DAV:\"><d:sync-token>{token}</d:sync-token>\
<d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>"
)
};
let r = pim
.req("alice", "REPORT", birthdays, "1", &sync(&before))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(r.text().contains("valid-sync-token"));
let r = pim
.req("alice", "REPORT", birthdays, "1", &sync(&after))
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
// Read-only.
let r = pim
.req("alice", "PUT", &format!("{birthdays}x.ics"), "0", "x")
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(r.text().contains("need-privileges"));
let r = pim.req("alice", "DELETE", &members[0], "0", "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let r = pim.req("alice", "DELETE", birthdays, "0", "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Birthdays are transparent: no busy time.
let fb = "<c:free-busy-query xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
<c:time-range start=\"20260101T000000Z\" end=\"20270101T000000Z\"/></c:free-busy-query>";
let r = pim.req("alice", "REPORT", birthdays, "1", fb).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(
!r.text().lines().any(|l| l.starts_with("FREEBUSY")),
"{}",
r.text()
);
}
fn meeting(uid: &str, organizer: &str, attendees: &[&str], start: &str) -> String {
let attendees: String = attendees
.iter()
.map(|a| {
let cn = a.trim_start_matches("mailto:").split('@').next().unwrap();
format!("ATTENDEE;CN=\"{cn}\";PARTSTAT=NEEDS-ACTION:{a}\r\n")
})
.collect();
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\
ORGANIZER:{organizer}\r\nATTENDEE;PARTSTAT=ACCEPTED:{organizer}\r\n{attendees}\
END:VEVENT\r\nEND:VCALENDAR\r\n"
)
}
fn addr(user: &str) -> String {
format!("mailto:{user}@filebrowser.invalid")
}
#[tokio::test]
async fn deleted_principals_are_forgotten() {
let pim = Pim::new(Env::new().await).await;
let r = pim
.admin
.post_json(
"/api/admin/rooms",
&json!({"name": "atrium", "kind": "room"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let room = r.json()["id"].as_i64().unwrap();
let atrium = "mailto:atrium@rooms.filebrowser.invalid";
let own = "/pim/calendars/alice/default/own.ics";
pim.put(
"alice",
own,
&meeting(
"own",
&addr("alice"),
&[&addr("bob"), atrium],
"20260310T100000Z",
),
)
.await;
pim.put(
"bob",
"/pim/calendars/bob/default/theirs.ics",
&meeting(
"theirs",
&addr("bob"),
&[&addr("alice")],
"20260311T100000Z",
),
)
.await;
let alice_cal = "/pim/calendars/alice/default/";
let copies = pim.members("alice", alice_cal).await;
assert_eq!(copies.len(), 2, "{copies:?}");
let token = pim.sync_token("alice", alice_cal).await;
let bob_id = user_id(&pim.admin, "bob").await;
let r = pim
.admin
.delete(&format!("/api/admin/users/{bob_id}"))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let r = pim.admin.delete(&format!("/api/admin/rooms/{room}")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_ne!(pim.sync_token("alice", alice_cal).await, token);
let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
assert!(!org.contains(&addr("bob")), "{org}");
assert!(!org.contains(atrium), "{org}");
let tombs: Vec<&str> = org
.lines()
.filter(|l| l.contains("@deleted.filebrowser.invalid"))
.collect();
assert_eq!(tombs.len(), 2, "{org}");
assert!(
tombs.iter().all(|l| l.contains("SCHEDULE-STATUS=3.7")),
"{org}"
);
// The display name stays.
assert!(
tombs.iter().any(|l| l.replace('"', "").contains("CN=bob;")),
"{org}"
);
let theirs = copies.iter().find(|h| !h.ends_with("own.ics")).unwrap();
let copy = unfold(&pim.req("alice", "GET", theirs, "0", "").await.text());
assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
assert!(copy.contains("ORGANIZER:mailto:bob-"), "{copy}");
// A new bob is not the old one: alice's next update reaches no one.
create_user(&pim.admin, "bob", PW, &[]).await;
pim.put(
"alice",
own,
&unfold(&pim.req("alice", "GET", own, "0", "").await.text())
.replace("20260310T100000Z", "20260312T100000Z"),
)
.await;
assert!(
pim.members("bob", "/pim/calendars/bob/default/")
.await
.is_empty()
);
assert!(
pim.members("bob", "/pim/calendars/bob/inbox/")
.await
.is_empty()
);
let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
assert!(!org.contains(&addr("bob")), "{org}");
}
#[tokio::test]
async fn admin_sees_and_revokes_feeds() {
let pim = Pim::new(Env::new().await).await;
let id = pim.id("/pim/calendars/alice/default/").await;
let r = pim
.alice
.post_json(
&format!("/api/pim/collections/{id}/links"),
&json!({"busy_only": true}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let path = r.json()["path"].as_str().unwrap().to_string();
let list = pim.admin.get("/api/admin/pim-links").await;
assert_eq!(list.status, StatusCode::OK);
let links = list.json();
let link = &links.as_array().unwrap()[0];
assert_eq!(link["owner_name"], "alice");
assert_eq!(link["owner_active"], true);
assert_eq!(link["kind"], "calendar");
assert_eq!(link["collection_id"], id);
assert_eq!(link["busy_only"], true);
assert_eq!(link["path"], path.as_str());
assert_eq!(
pim.alice.get("/api/admin/pim-links").await.status,
StatusCode::FORBIDDEN
);
let anon = Client::new(pim.env.app.clone());
assert_eq!(anon.get(&path).await.status, StatusCode::OK);
let link_id = link["id"].as_i64().unwrap();
let r = pim
.admin
.delete(&format!("/api/admin/pim-links/{link_id}"))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(anon.get(&path).await.status, StatusCode::NOT_FOUND);
let r = pim
.admin
.delete(&format!("/api/admin/pim-links/{link_id}"))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}