CalDAV/CardDAV feeds, import and export

- Public feed links per calendar or address book (`/feed/{token}.ics`,
  `.vcf`): own `pim_links` table, optional password over Basic with the
  share throttle, expiry, ETag/304, REFRESH-INTERVAL and NAME
- Detail levels: full feeds show private and confidential objects as busy
  time; busy-only feeds drop free and cancelled events, turn a dropped
  override into an EXDATE and hash UIDs; exports keep everything
- Import of .ics/.vcf from an upload or a root file: split by UID with
  overrides and referenced VTIMEZONEs, PUT checks per object, same UID
  replaces, content-derived UIDs, no scheduling messages
- Export as a download or a new file in a writable root
- pimdav `bundle` module works on the text, so kept lines stay byte-exact
- Docs: feeds with client support, import and export policies, limits

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit19aba6528b67daa168938ecc96b2af67a05b6f98
Parenta5a298a
14 files changed, 1847 insertions(+), 26 deletions(-)
▾MCargo.lock
@@ -2316,6 +2316,7 @@ dependencies = [
"chrono",
"chrono-tz",
"rrule",
"sha2 0.11.0",
"unicode-normalization",
"xmltree",
]
▾MREADME.md
@@ -33,7 +33,8 @@ external services.
[WebDAV](#webdav).
- **Calendars and contacts**: CalDAV and CardDAV for calendar and contact
apps. Sharing between users, meeting invitations between users, rooms
and resources. See [Calendars and contacts](#calendars-and-contacts).
and resources, public feed links, import and export. See
[Calendars and contacts](#calendars-and-contacts).
- **UI**: light, dark, or system theme. English, German, and French.
Optional single-click open.
- **Passkeys**: sign in with a fingerprint, a face, or a security key.
@@ -259,6 +260,75 @@ The web UI has no page for this yet. The JSON API is
"rw"}`, and `DELETE /api/pim/collections/<id>/shares/<user id>` to end a
loan.
### Public feeds
The owner of a calendar or address book can publish it as a link. Anyone
with the link can read it without an account.
| Link | Content |
|------|---------|
| `/feed/<token>.ics` | The whole calendar as one iCalendar file. Events you marked private or confidential show as "Busy" only. |
| `/feed/<token>.ics`, busy only | Only the times of events, each named "Busy". Descriptions, attendees, alarms, tasks and journals are left out, and so are free and cancelled events. Event IDs are replaced by hashes. |
| `/feed/<token>.vcf` | All contacts of an address book as one vCard file |
A link can have an expiry date and a password. It stops working when the
owner revokes it, when it expires, and when the collection or its owner is
deleted. A password cannot be changed. Revoke the link and create a new
one, which gets a new token.
Subscribing to a calendar link:
- Apple Calendar, Thunderbird, Evolution and GNOME Calendar subscribe to
the URL directly, as `https://…` or `webcal://…`.
- On Android, use ICSx⁵. DAVx5 itself does not subscribe to iCalendar
links.
- Google Calendar and Outlook.com fetch the file from their own servers.
The URL must be reachable from the internet. They cannot send a
password, so a protected link does not work there.
- The feed asks apps to refresh every hour. Apps may use their own
interval.
A password is sent with HTTP Basic. The user name is ignored, but some
apps refuse an empty one. Type any name there.
A `.vcf` link is for a download or a one-time import. No common contacts
app subscribes to a vCard link. To share contacts with another account
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.
### Import and export
`POST /api/pim/collections/<id>/import` imports an `.ics` file into a
calendar, or a `.vcf` file into an address book. The body is the file
itself, or JSON `{"root_id": <id>, "path": "<path>"}` for a file in one of
your folders. You need write access: your own collection, or a `rw` or
`rw+schedule` loan.
- The file is split into one entry per UID. Changed instances of a
repeating event stay with their event.
- Each entry gets the same checks as an upload from an app. An entry that
fails is skipped. The answer counts created, updated and skipped
entries, and names the first 100 skipped ones with the reason.
- An entry whose UID the collection already holds replaces that entry.
An entry without UID gets one derived from its content, so importing
the same file twice updates instead of duplicating.
- A meeting whose UID another of your calendars already holds is skipped.
RFC 6638 allows one copy per UID.
- An import never sends invitations or answers, also not for meetings
with attendees. A later change in an app schedules as usual.
- The file's calendar name and `X-WR-TIMEZONE` are dropped. Times without
a zone then follow the calendar's own time zone.
`GET /api/pim/collections/<id>/export` downloads a calendar or address
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.
### Invitations
Invitations work between accounts, rooms and resources of this server.
@@ -294,7 +364,8 @@ instances that collide are declined.
### Limits
- One calendar entry or contact can be 10 MiB. An XML request can be
1 MiB.
1 MiB. An imported file can be 20 MiB. Files that are not UTF-8 are
read as Latin-1.
- An inbox keeps its newest 100 messages. The meetings themselves stay in
the calendar.
- A repeating event returned as single instances can have at most 10,000
▾Mapi-types/src/lib.rs
@@ -76,6 +76,18 @@ pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
/// (`POST`) an own one; `DELETE` on `.../{user_id}` below it ends a loan.
pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
pub const SHARES_SUFFIX: &str = "/shares";
/// `{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`: the public feeds of an own
/// collection (`GET`, `POST`); `DELETE` on `.../{link_id}` below it.
pub const LINKS_SUFFIX: &str = "/links";
/// `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}`: an `.ics` or `.vcf` body, or
/// a JSON [`PimRootFile`] naming a file in a root.
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";
/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
/// `.vcf`. The extension is optional.
pub const FEED: &str = "/feed";
/// Pseudo root id every signed-in admin has on the files API: the whole
/// server root, read-only (the admin folder picker browses it). Real root
/// ids are positive database ids. Not listed in `/me`.
@@ -562,6 +574,58 @@ pub struct CreatePimShare {
pub mode: PimShareMode,
}
/// One entry of `GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimLinkInfo {
pub id: i64,
/// `{FEED}/{token}` with the extension.
pub path: String,
pub busy_only: bool,
pub created_at: String,
pub expires_at: Option<String>,
pub has_password: bool,
}
/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
#[derive(Serialize, Deserialize, Default)]
pub struct CreatePimLink {
/// Calendars only: events without their details.
#[serde(default)]
pub busy_only: bool,
/// Absolute expiry as RFC 3339; absent = never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// Asked for with HTTP Basic; the user name is ignored.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
/// A file in one of the signed-in user's roots.
#[derive(Serialize, Deserialize)]
pub struct PimRootFile {
pub root_id: i64,
pub path: String,
}
/// The answer to an import.
#[derive(Serialize, Deserialize, Debug)]
pub struct PimImportResult {
pub created: usize,
pub updated: usize,
/// All skipped objects, also those beyond `skipped`.
pub skipped_total: usize,
/// The first skipped objects.
pub skipped: Vec<PimSkipped>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PimSkipped {
pub uid: Option<String>,
/// The precondition a PUT of the object would fail, e.g.
/// `valid-calendar-object-resource`.
pub reason: String,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RoomKind {
▾Mpimdav/Cargo.toml
@@ -14,6 +14,8 @@ chrono-tz = "0.10"
# Wall-clock RRULE iteration only. Time zones, UNTIL, overrides, RDATE and
# EXDATE are handled in `expand`.
rrule = "0.14"
# Hides UIDs in busy-only feeds. Already in the tree through the server.
sha2 = "0.11"
# NFKD for the i;unicode-casemap collation (RFC 5051), the CardDAV default.
unicode-normalization = "0.1"
xmltree = "0.12"
▾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 |
| `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
leaves room. "We" means this crate and the server that uses it.
@@ -345,6 +346,55 @@ copy carries the status of its last reply on the ORGANIZER.
end has infinite instances, so it needs a limit. Exchange's booking
window works the same way.
## Feeds, export and import
`bundle` works on the text, not on parsed data. Every stored line reaches
the output unchanged. Only line endings become CRLF.
### One file from a collection
- A calendar becomes one VCALENDAR. Each VTIMEZONE appears once per TZID;
the first definition wins.
- The calendar carries NAME (RFC 7986) and X-WR-CALNAME with the display
name. Most clients read only X-WR-CALNAME.
- It also carries `REFRESH-INTERVAL;VALUE=DURATION:PT1H` (RFC 7986) and
`X-PUBLISHED-TTL:PT1H` (Outlook). Clients still pick their own interval.
An export carries them too; importers ignore them.
- `Detail` picks what a file shows. An export shows everything. A public
feed shows everything, except that an object with a CLASS:PRIVATE or
CLASS:CONFIDENTIAL component shows as busy time only, as Google and
Nextcloud do. The whole object is reduced, so its overrides keep
matching their master. A busy-only feed reduces every object.
- Busy time keeps VEVENTs only; tasks and journals go. An event keeps UID,
DTSTAMP, DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, EXRULE,
RECURRENCE-ID, SEQUENCE, TRANSP and STATUS, and gets `SUMMARY:Busy`.
DURATION and DTSTAMP stay: without them an event has no end or is
invalid.
- Transparent and cancelled events block no time, so busy time leaves
them out. A left-out override becomes an EXDATE of its master, without
a RANGE parameter, so its instance stays free.
- Busy time replaces each UID with a SHA-256 hash of it. UIDs from
Outlook and others hold host names or mail addresses. Master and
overrides share one UID, so they share the hash.
- An address book becomes all its vCards, one after the other.
### Objects from one file
- A calendar file becomes one object per UID. Overrides stay with their
master, also across several VCALENDARs in one file.
- Each object gets the VTIMEZONEs its TZID parameters name, and no others.
- The object keeps VERSION, PRODID and CALSCALE of the file. METHOD and
calendar properties such as X-WR-CALNAME and X-WR-TIMEZONE are dropped.
Floating times then follow the collection's `calendar-timezone`.
- A component without UID gets one from the caller. The server derives it
from the component's text, so a second import of the same file updates
instead of duplicating.
- A component that appears twice with the same text is kept once.
- A vCard without UID gets it before its END line. vCard 4.0 wants
VERSION right after BEGIN.
- The objects are not validated here. The server runs the same checks as
for a PUT and skips what fails.
## Where the RFCs are unclear or implementations differ
| Case | What we do | Why |
@@ -368,6 +418,8 @@ copy carries the status of its last reply on the ORGANIZER.
| `schedule-send*` privileges on a shared calendar | Listed there | RFC 6638 puts them on the outbox, which a sharee cannot see |
| PARTSTAT in scheduling free-busy | Own answer counts | Not defined by RFC 6638 |
| `/.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 |
## Not handled
▾Apimdav/src/bundle.rs
@@ -0,0 +1,438 @@
//! Whole collections as one file, and one file split into objects: public
//! feeds, export and import.
//!
//! Works on the text, not on parsed data, so a kept line reaches the output
//! unchanged. Only line endings are rewritten, to CRLF.
use std::collections::{HashMap, HashSet};
use sha2::{Digest, Sha256};
const PRODID: &str = "PRODID:-//filebrowser-ng//pimdav//EN";
/// The properties a busy-only event keeps.
const BUSY_PROPS: [&str; 13] = [
"UID",
"DTSTAMP",
"DTSTART",
"DTEND",
"DURATION",
"RRULE",
"RDATE",
"EXDATE",
"EXRULE",
"RECURRENCE-ID",
"SEQUENCE",
"TRANSP",
"STATUS",
];
/// How much of a calendar a file shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Detail {
/// Everything, for the owner or a borrower.
All,
/// Everything, except that an object with a private or confidential
/// component shows as busy time only, as Google and Nextcloud do.
Public,
/// Busy time only.
Busy,
}
/// One VCALENDAR with the components of every object in `objects`, each
/// VTIMEZONE once per TZID. Subscribers are asked to poll hourly.
pub fn calendar(objects: &[&str], name: Option<&str>, detail: Detail) -> String {
let mut zones = String::new();
let mut body = String::new();
let mut seen = HashSet::new();
for object in objects {
for cal in top_blocks(object, "VCALENDAR") {
let children = split_level(inner(&cal.lines)).1;
for z in children.iter().filter(|c| c.name == "VTIMEZONE") {
if z.prop("TZID").is_some_and(|id| seen.insert(id)) {
push_lines(&mut zones, &z.lines);
}
}
let busy = match detail {
Detail::All => false,
Detail::Busy => true,
Detail::Public => children.iter().any(is_private),
};
if busy {
let events: Vec<&Block> = children.iter().filter(|c| c.name == "VEVENT").collect();
push_busy(&mut body, &events);
} else {
for c in children.iter().filter(|c| c.name != "VTIMEZONE") {
push_lines(&mut body, &c.lines);
}
}
}
}
// RFC 7986's REFRESH-INTERVAL, and Outlook's own name for it.
let mut out = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{PRODID}\r\n\
REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n"
);
// RFC 7986's NAME, and the X-WR-CALNAME most clients read instead.
if let Some(name) = name {
let name = escape_text(name);
out.push_str(&format!("NAME:{name}\r\nX-WR-CALNAME:{name}\r\n"));
}
out + &zones + &body + "END:VCALENDAR\r\n"
}
/// Every vCard of `objects`, one after the other.
pub fn cards(objects: &[&str]) -> String {
let mut out = String::new();
for object in objects {
for card in top_blocks(object, "VCARD") {
push_lines(&mut out, &card.lines);
}
}
out
}
/// Splits an iCalendar file into calendar object resources: one per UID,
/// overrides with their master, each with the VTIMEZONEs it names. Keeps
/// VERSION, PRODID and CALSCALE of the file and drops the other calendar
/// properties, METHOD among them. A component without UID gets
/// `new_uid(its text)`.
pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<String> {
let mut header: Option<Vec<String>> = None;
let mut zones: HashMap<String, String> = HashMap::new();
// (uid, components, TZIDs they name), in file order.
let mut groups: Vec<(String, String, HashSet<String>)> = Vec::new();
for cal in top_blocks(text, "VCALENDAR") {
let (props, children) = split_level(inner(&cal.lines));
header.get_or_insert_with(|| {
props
.iter()
.filter(|l| ["VERSION", "PRODID", "CALSCALE"].contains(&name(l).as_str()))
.map(|l| l.to_string())
.collect()
});
for c in children {
if c.name == "VTIMEZONE" {
if let Some(id) = c.prop("TZID") {
zones.entry(id).or_insert_with(|| lines_text(&c.lines));
}
continue;
}
let tzids: HashSet<String> = c.lines.iter().filter_map(|l| tzid(l)).collect();
let (uid, text) = match c.prop("UID") {
Some(uid) => (uid, lines_text(&c.lines)),
None => {
let uid = new_uid(&lines_text(&c.lines));
let mut text = String::new();
push_lines(&mut text, &c.lines[..1]);
text.push_str(&format!("UID:{uid}\r\n"));
push_lines(&mut text, &c.lines[1..]);
(uid, text)
}
};
match groups.iter_mut().find(|g| g.0 == uid) {
// A file that holds the same component twice.
Some(g) if g.1.contains(&text) => {}
Some(g) => {
g.1.push_str(&text);
g.2.extend(tzids);
}
None => groups.push((uid, text, tzids)),
}
}
}
let mut head = String::from("BEGIN:VCALENDAR\r\n");
let header = header.unwrap_or_default();
if !header.iter().any(|l| name(l) == "VERSION") {
head.push_str("VERSION:2.0\r\n");
}
if !header.iter().any(|l| name(l) == "PRODID") {
head.push_str(PRODID);
head.push_str("\r\n");
}
for l in &header {
push_lines(&mut head, &[l.as_str()]);
}
groups
.into_iter()
.map(|(_, components, tzids)| {
let mut out = head.clone();
let mut tzids: Vec<_> = tzids.into_iter().collect();
tzids.sort();
for id in tzids {
if let Some(z) = zones.get(&id) {
out.push_str(z);
}
}
out + &components + "END:VCALENDAR\r\n"
})
.collect()
}
/// Splits a vCard file into one text per card. A card without UID gets
/// `new_uid(its text)`, inserted before its END line: vCard 4.0 wants
/// VERSION right after BEGIN.
pub fn split_cards(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<String> {
top_blocks(text, "VCARD")
.into_iter()
.map(|card| {
let text = lines_text(&card.lines);
if card.prop("UID").is_some() {
return text;
}
let (body, end) = match card.lines.split_last() {
Some((last, body)) if name(last) == "END" => (body, Some(*last)),
_ => (&card.lines[..], None),
};
let mut out = String::new();
push_lines(&mut out, body);
out.push_str(&format!("UID:{}\r\n", new_uid(&text)));
if let Some(end) = end {
push_lines(&mut out, &[end]);
}
out
})
.collect()
}
/// A component as its logical lines, BEGIN and END included.
struct Block<'a> {
/// Upper case.
name: String,
lines: Vec<&'a str>,
}
impl Block<'_> {
/// The value of the first own property called `prop`, not one of a
/// nested component, unfolded and unescaped as TEXT.
fn prop(&self, prop: &str) -> Option<String> {
split_level(inner(&self.lines))
.0
.into_iter()
.find(|l| name(l) == prop)
.map(|l| unescape_text(value(&unfold(l))))
}
}
/// `lines` without their BEGIN and END line.
fn inner<'a, 'b>(lines: &'b [&'a str]) -> &'b [&'a str] {
let start = usize::from(lines.first().is_some_and(|l| name(l) == "BEGIN"));
let end =
lines.len() - usize::from(lines.len() > start && name(lines[lines.len() - 1]) == "END");
&lines[start..end]
}
/// The top-level components called `outer`.
fn top_blocks<'a>(text: &'a str, outer: &str) -> Vec<Block<'a>> {
split_level(&logical_lines(text))
.1
.into_iter()
.filter(|b| b.name == outer)
.collect()
}
/// The properties of this level and its components. A component cut off by
/// the end of the text is kept, without END.
fn split_level<'a>(lines: &[&'a str]) -> (Vec<&'a str>, Vec<Block<'a>>) {
let mut props = Vec::new();
let mut blocks: Vec<Block<'a>> = Vec::new();
let mut depth = 0usize;
for &line in lines {
let n = name(line);
if depth == 0 {
if n == "BEGIN" {
blocks.push(Block {
name: value(&unfold(line)).trim().to_ascii_uppercase(),
lines: vec![line],
});
depth = 1;
} else if !n.is_empty() {
props.push(line);
}
continue;
}
blocks.last_mut().expect("depth > 0").lines.push(line);
match n.as_str() {
"BEGIN" => depth += 1,
"END" => depth -= 1,
_ => {}
}
}
(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') {
out.push_str(physical.trim_end_matches(['\r', '\n']));
out.push_str("\r\n");
}
}
}
fn lines_text(lines: &[&str]) -> String {
let mut out = String::new();
push_lines(&mut out, lines);
out
}
fn is_private(c: &Block) -> bool {
c.prop("CLASS").is_some_and(|v| {
v.eq_ignore_ascii_case("PRIVATE") || v.eq_ignore_ascii_case("CONFIDENTIAL")
})
}
/// Transparent and cancelled events block no time.
fn blocks_time(e: &Block) -> bool {
!(e.prop("TRANSP")
.is_some_and(|v| v.eq_ignore_ascii_case("TRANSPARENT"))
|| e.prop("STATUS")
.is_some_and(|v| v.eq_ignore_ascii_case("CANCELLED")))
}
/// The events of one object as busy time. Events that block no time are
/// left out; a left-out override becomes an EXDATE of its master, so its
/// instance stays free. The UID becomes a hash of it, because UIDs often
/// hold host names or mail addresses.
fn push_busy(out: &mut String, events: &[&Block]) {
let (kept, free): (Vec<&&Block>, Vec<&&Block>) = events.iter().partition(|e| blocks_time(e));
let exdates: Vec<String> = free
.iter()
.filter_map(|e| {
let (props, _) = split_level(inner(&e.lines));
props
.into_iter()
.find(|l| name(l) == "RECURRENCE-ID")
.map(exdate)
})
.collect();
for e in kept {
out.push_str("BEGIN:VEVENT\r\nSUMMARY:Busy\r\n");
let (props, _) = split_level(inner(&e.lines));
let master = !props.iter().any(|l| name(l) == "RECURRENCE-ID");
for line in props {
match name(line).as_str() {
"UID" => {
let uid = unescape_text(value(&unfold(line)));
let hash = Sha256::digest(uid.as_bytes());
let hex: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect();
out.push_str(&format!("UID:{hex}\r\n"));
}
n if BUSY_PROPS.contains(&n) => push_lines(out, &[line]),
_ => {}
}
}
if master {
for x in &exdates {
out.push_str(x);
}
}
out.push_str("END:VEVENT\r\n");
}
}
/// A RECURRENCE-ID line as the EXDATE of that instance, without RANGE.
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)
.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', "")
}
▾Mpimdav/src/lib.rs
@@ -1,6 +1,7 @@
//! CalDAV and CardDAV logic without I/O: the caller loads and stores data,
//! this crate computes on it.
pub mod bundle;
pub mod expand;
pub mod filter;
pub mod freebusy;
▾Apimdav/tests/bundle.rs
@@ -0,0 +1,161 @@
//! Feeds, export and import splitting.
use pimdav::bundle::{self, Detail};
use pimdav::object;
const BERLIN: &str = "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
fn uids() -> impl FnMut(&str) -> String {
let mut n = 0;
move |_| {
n += 1;
format!("new-{n}")
}
}
#[test]
fn feed_merges_objects_and_deduplicates_zones() {
let a = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n{BERLIN}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nSUMMARY:Long\r\n folded\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
// LF endings in storage become CRLF.
let b = format!(
"BEGIN:VCALENDAR\nVERSION:2.0\n{}BEGIN:VEVENT\nUID:b\nDTSTART;TZID=Europe/Berlin:20240102T100000\nATTENDEE:mailto:x@y\nEND:VEVENT\nBEGIN:VTODO\nUID:c\nEND:VTODO\nEND:VCALENDAR\n",
BERLIN.replace("\r\n", "\n")
);
let full = bundle::calendar(&[&a, &b], Some("Work; home"), Detail::All);
assert!(full.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"));
assert!(full.contains(r"X-WR-CALNAME:Work\; home"));
assert!(full.contains(r"NAME:Work\; home"));
assert!(full.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n"));
assert_eq!(full.matches("BEGIN:VTIMEZONE").count(), 1);
assert!(full.contains("SUMMARY:Long\r\n folded\r\n"));
assert!(full.contains("BEGIN:VTODO\r\nUID:c\r\n"));
assert!(!full.replace("\r\n", "").contains('\n'));
assert!(full.ends_with("END:VCALENDAR\r\n"));
let busy = bundle::calendar(&[&a, &b], None, Detail::Busy);
assert_eq!(busy.matches("SUMMARY:Busy").count(), 2);
assert!(!busy.contains("Long"));
assert!(!busy.contains("VALARM"));
assert!(!busy.contains("ATTENDEE"));
assert!(!busy.contains("VTODO"));
assert!(busy.contains("DTSTART;TZID=Europe/Berlin:20240102T100000\r\n"));
assert!(busy.contains("BEGIN:VTIMEZONE"));
}
fn object(components: &str) -> String {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{components}END:VCALENDAR\r\n")
}
#[test]
fn busy_time_hides_what_blocks_nothing() {
let series = object(
"BEGIN:VEVENT\r\nUID:series@corp.example\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nEND:VEVENT\r\n",
);
let free = object(
"BEGIN:VEVENT\r\nUID:free\r\nDTSTART:20240105T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n",
);
let busy = bundle::calendar(&[&series, &free], None, Detail::Busy);
// The master and the moved instance stay; the cancelled one is excluded.
assert_eq!(busy.matches("BEGIN:VEVENT").count(), 2, "{busy}");
assert!(busy.contains("EXDATE:20240102T100000Z\r\n"), "{busy}");
assert!(!busy.contains("RANGE"));
assert!(!busy.contains("corp.example") && !busy.contains("UID:free"));
let uids: Vec<&str> = busy.lines().filter(|l| l.starts_with("UID:")).collect();
assert_eq!(uids.len(), 2);
assert_eq!(uids[0], uids[1], "master and override keep one UID");
}
#[test]
fn public_feeds_reduce_private_objects() {
let open = object(
"BEGIN:VEVENT\r\nUID:open\r\nDTSTART:20240101T100000Z\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n",
);
let private = object(
"BEGIN:VEVENT\r\nUID:doc\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Doctor\r\nCLASS:PRIVATE\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:doc\r\nRECURRENCE-ID:20240108T120000Z\r\nDTSTART:20240108T130000Z\r\nSUMMARY:Doctor\r\nEND:VEVENT\r\n",
);
let task = object(
"BEGIN:VTODO\r\nUID:t\r\nSUMMARY:Secret task\r\nCLASS:CONFIDENTIAL\r\nEND:VTODO\r\n",
);
let public = bundle::calendar(&[&open, &private, &task], None, Detail::Public);
assert!(public.contains("SUMMARY:Standup"));
assert!(
!public.contains("Doctor") && !public.contains("Secret task"),
"{public}"
);
// The whole object is reduced, so its override still matches its master.
assert_eq!(public.matches("SUMMARY:Busy").count(), 2);
let all = bundle::calendar(&[&open, &private, &task], None, Detail::All);
assert!(all.contains("Doctor") && all.contains("Secret task"));
}
#[test]
fn import_splits_by_uid_with_overrides_and_zones() {
let file = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:src\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:Old\r\n{BERLIN}BEGIN:VTIMEZONE\r\nTZID:Unused\r\nEND:VTIMEZONE\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:plain\r\nDTSTART:20240105T100000Z\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID;TZID=Europe/Berlin:20240102T100000\r\nDTSTART;TZID=Europe/Berlin:20240102T120000\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nDTSTART:20240106T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let parts = bundle::split_calendar(&file, &mut uids());
assert_eq!(parts.len(), 3);
let master = &parts[0];
assert_eq!(master.matches("BEGIN:VEVENT").count(), 2);
assert!(master.contains("RECURRENCE-ID"));
assert!(master.contains("TZID:Europe/Berlin"));
assert!(!master.contains("Unused"));
assert!(!master.contains("METHOD"));
assert!(!master.contains("X-WR-CALNAME"));
assert!(master.contains("PRODID:src\r\n"));
assert!(!parts[1].contains("VTIMEZONE"));
assert!(parts[2].contains("BEGIN:VEVENT\r\nUID:new-1\r\n"));
for p in &parts {
assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}");
}
// With a UID from the content, as the server makes it, a file holding
// everything twice gives the same objects.
let mut by_content = |t: &str| format!("len-{}", t.len());
let once = bundle::split_calendar(&file, &mut by_content);
let twice = bundle::split_calendar(&format!("{file}{file}"), &mut by_content);
assert_eq!(twice, once);
}
#[test]
fn escaped_tzid_finds_its_zone() {
let file = "BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:(UTC+02:00) Athens\\, Bucharest\r\nEND:VTIMEZONE\r\n\
BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=\"(UTC+02:00) Athens, Bucharest\":20130412T150000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let parts = bundle::split_calendar(file, &mut uids());
assert!(parts[0].contains("BEGIN:VTIMEZONE"), "{}", parts[0]);
assert_eq!(
bundle::calendar(&[&parts[0]], None, Detail::All)
.matches("BEGIN:VTIMEZONE")
.count(),
1
);
}
#[test]
fn import_splits_cards() {
let file = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n\
BEGIN:VCARD\nVERSION:4.0\nFN:Two\nEND:VCARD\n";
let cards = bundle::split_cards(file, &mut uids());
assert_eq!(cards.len(), 2);
assert_eq!(
cards[0],
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n"
);
// Before END: vCard 4.0 wants VERSION right after BEGIN.
assert_eq!(
cards[1],
"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nUID:new-1\r\nEND:VCARD\r\n"
);
for c in &cards {
assert!(object::vcard(c.as_bytes()).unwrap().is_some());
}
assert_eq!(bundle::cards(&[&cards[0], &cards[1]]), cards.concat());
}
▾Mserver/src/api/files.rs
@@ -172,7 +172,7 @@ async fn list_inner(
/// `Content-Disposition` parameters for `name`: an ASCII `filename=` fallback
/// (non-ASCII and control bytes become `_`) plus the RFC 8187 `filename*=`
/// that every current browser reads. Never fails header validation.
fn disposition(kind: &str, name: &str) -> String {
pub(crate) fn disposition(kind: &str, name: &str) -> String {
let ascii: String = name
.chars()
.map(|c| match c {
@@ -1262,7 +1262,7 @@ pub(crate) fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, Api
})
}
fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
pub(crate) fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
let root = find_root(roots, root_id)?;
if !root.mode.is_writable() {
return Err(ApiError::localized(
▾Mserver/src/api/mod.rs
@@ -3,8 +3,9 @@ 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, FILES, FINISH_SUFFIX, PIM, PIM_COLLECTIONS, SEARCH,
SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
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,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -117,6 +118,11 @@ pub fn router(state: Arc<AppState>) -> Router {
let admin_room_id = format!("{ADMIN_ROOMS}/{{id}}");
let pim_shares = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}");
let pim_share = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}/{{user_id}}");
let pim_links = format!("{PIM_COLLECTIONS}/{{id}}{LINKS_SUFFIX}");
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 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
// the bare path nor `{*path}`.
@@ -182,6 +188,14 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(PIM_COLLECTIONS, get(pim_api::list))
.route(&pim_shares, get(pim_api::shares).post(pim_api::share))
.route(&pim_share, delete(pim_api::unshare))
.route(&pim_links, get(pim_api::links).post(pim_api::create_link))
.route(&pim_link, delete(pim_api::delete_link))
.route(&pim_import, post(pim_api::import))
.route(
&pim_export,
get(pim_api::export).post(pim_api::export_to_root),
)
.route(&feed, get(pim_api::feed))
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
// `any`, not a method filter: WebDAV's verbs (PROPFIND, MKCOL, MOVE, …)
▾Mserver/src/api/pim_api.rs
@@ -3,18 +3,45 @@
//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection
//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection
//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root
//!
//! Public: `GET {FEED}/{token}` — a collection as one file.
use std::collections::HashMap;
use std::sync::Arc;
use api_types::{CreatePimShare, OkResp, PimCollectionInfo, PimCollectionKind, PimShareInfo};
use api_types::{
CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo, PimCollectionKind,
PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode, PimSkipped,
};
use axum::Json;
use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use pimdav::bundle::{self, Detail};
use pimdav::object;
use sha2::{Digest, Sha256};
use crate::api::common::SessionUser;
use crate::api::pim::{INBOX, collection_href};
use crate::db::{PimCollection, PimKind};
use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
use crate::api::dav::challenge;
use crate::api::files::{disposition, find_root, require_rw_root};
use crate::api::pim::{INBOX, collection_href, etag_of};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::auth;
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp};
use crate::error::{ApiError, AppState};
use crate::fs;
/// The largest file an import reads.
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 {
match kind {
@@ -137,3 +164,437 @@ pub async fn unshare(
}
Ok(Json(OkResp {}))
}
/// A collection the signed-in user may read: its owner principal, kind, the
/// collection, and whether they may also write it. The inbox is not one.
async fn reachable(
state: &AppState,
auth: &SessionUser,
id: i64,
) -> Result<(i64, PimKind, PimCollection, bool), ApiError> {
let not_found = || ApiError::new(StatusCode::NOT_FOUND, "collection not found");
let pid = state.db.principal_of(auth.user.id).await?;
let (owner, kind, c) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(not_found)?;
if c.slug == INBOX {
return Err(not_found());
}
if owner == pid {
return Ok((owner, kind, c, true));
}
match state
.db
.pim_shared_collection(auth.user.id, kind, id)
.await?
{
Some((_, _, mode)) => Ok((owner, kind, c, mode != PimShareMode::Ro)),
None => Err(not_found()),
}
}
fn extension(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "ics",
PimKind::AddressBook => "vcf",
}
}
fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
PimLinkInfo {
id: link.id,
path: format!("{FEED}/{}.{}", link.token, extension(kind)),
busy_only: link.busy_only,
created_at: link.created_at.clone(),
expires_at: link.expires_at.clone(),
has_password: link.password_hash.is_some(),
}
}
/// GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
pub async fn links(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<PimLinkInfo>>, ApiError> {
let id = own(&state, &auth, id).await?;
let (_, kind, _) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let links = state.db.pim_links(id).await?;
Ok(Json(links.iter().map(|l| link_info(l, kind)).collect()))
}
/// POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
pub async fn create_link(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
Json(body): Json<CreatePimLink>,
) -> Result<Json<PimLinkInfo>, ApiError> {
let id = own(&state, &auth, id).await?;
let (_, kind, _) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
if body.busy_only && kind != PimKind::Calendar {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"busy_only needs a calendar",
));
}
// As for shares: an unparseable expiry would never expire.
if let Some(e) = &body.expires_at
&& chrono::DateTime::parse_from_rfc3339(e).is_err()
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"expires_at must be an RFC 3339 timestamp",
"err_bad_expires_at",
));
}
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
validate_password(pw)?;
Some(hash_password(pw).await?)
}
_ => None,
};
let link = state
.db
.pim_create_link(
id,
&auth::short_token(),
body.busy_only,
body.expires_at.as_deref(),
password_hash.as_deref(),
)
.await?;
Ok(Json(link_info(&link, kind)))
}
/// DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}
pub async fn delete_link(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath((id, link_id)): AxumPath<(i64, i64)>,
) -> Result<Json<OkResp>, ApiError> {
let id = own(&state, &auth, id).await?;
if !state.db.pim_delete_link(id, link_id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
}
Ok(Json(OkResp {}))
}
/// GET {FEED}/{token}
pub async fn feed(
State(state): State<Arc<AppState>>,
AxumPath(file): AxumPath<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let token = file
.strip_suffix(".ics")
.or_else(|| file.strip_suffix(".vcf"))
.unwrap_or(&file);
let Some(link) = state.db.pim_link_by_token(token).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
if link.is_expired() {
return Ok(StatusCode::GONE.into_response());
}
// Basic with the user name ignored, like a protected share mount.
if let Some(hash) = link.password_hash.clone() {
let Some((_, password)) = auth::basic_credentials(&headers) else {
return Ok(challenge());
};
let (pw, id, tok) = (password.clone(), link.id, link.token.clone());
// A negative realm: share ids are positive, and one share's password
// must never open a feed with the same id.
let ok = auth::verify_cached(-link.id, "", &password, move || async move {
auth::throttle(&tok).await;
let ok = auth::verify_password_async(&pw, &hash).await;
auth::record_login(&tok, ok);
ok.then_some(id)
})
.await;
if ok.is_none() {
return Ok(challenge());
}
}
let Some((_, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let etag = format!(
"\"feed-{}-{}{}\"",
col.id,
col.seq,
if link.busy_only { "-busy" } else { "" }
);
let unchanged = headers
.get(IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(',')
.map(|t| t.trim().trim_start_matches("W/"))
.any(|t| t == etag || t == "*")
});
if unchanged {
return Ok((StatusCode::NOT_MODIFIED, [(ETAG, etag)]).into_response());
}
let detail = match link.busy_only {
true => Detail::Busy,
false => Detail::Public,
};
let body = render(&state, kind, &col, detail).await?;
Ok((
[
(CONTENT_TYPE, mime(kind).to_string()),
(ETAG, etag),
(CACHE_CONTROL, "no-cache".to_string()),
],
body,
)
.into_response())
}
fn mime(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "text/calendar; charset=utf-8",
PimKind::AddressBook => "text/vcard; charset=utf-8",
}
}
async fn render(
state: &AppState,
kind: PimKind,
col: &PimCollection,
detail: Detail,
) -> Result<String, ApiError> {
let objects = state.db.pim_objects_with_data(col.id).await?;
let texts: Vec<String> = objects
.into_iter()
.map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
.collect();
let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
Ok(match kind {
PimKind::Calendar => bundle::calendar(&texts, Some(&name_of(col)), detail),
PimKind::AddressBook => bundle::cards(&texts),
})
}
/// GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}
pub async fn export(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Response, ApiError> {
let (_, kind, col, _) = reachable(&state, &auth, id).await?;
let body = render(&state, kind, &col, Detail::All).await?;
let file = format!(
"{}.{}",
name_of(&col).replace(['/', '\\'], "_"),
extension(kind)
);
Ok((
[
(CONTENT_TYPE, mime(kind).to_string()),
(CONTENT_DISPOSITION, disposition("attachment", &file)),
],
body,
)
.into_response())
}
/// POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}: a new file in a writable root.
pub async fn export_to_root(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
Json(target): Json<PimRootFile>,
) -> Result<Json<OkResp>, ApiError> {
let (_, kind, col, _) = reachable(&state, &auth, id).await?;
let root = require_rw_root(&auth.roots, target.root_id)?;
let body = render(&state, kind, &col, Detail::All).await?;
let (server_root, root_path) = (state.root.clone(), root.path.clone());
blocking(move || {
fs::create_file(&server_root, &root_path, &target.path)?;
fs::save_file(
&server_root,
&root_path,
&target.path,
body.as_bytes(),
None,
)
})
.await?;
Ok(Json(OkResp {}))
}
/// POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}
///
/// Each object goes through the checks of a PUT and is skipped where a PUT
/// would fail. An object whose UID the collection already has replaces it.
/// Nothing is sent to attendees or organizers.
pub async fn import(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
headers: HeaderMap,
body: Body,
) -> Result<Json<PimImportResult>, ApiError> {
let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
if !writable {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let too_large = || ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large");
let json = headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("application/json"));
let data = if json {
let raw = axum::body::to_bytes(body, 64 * 1024)
.await
.map_err(|_| too_large())?;
let file: PimRootFile = serde_json::from_slice(&raw)
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid JSON body"))?;
read_root_file(&state, &auth, file).await?
} else {
axum::body::to_bytes(body, MAX_IMPORT)
.await
.map_err(|_| too_large())?
.to_vec()
};
// Old phone exports are often Latin-1.
let text = String::from_utf8(data)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect());
// From the content, so importing the same file twice updates.
let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string();
let parts = match kind {
PimKind::Calendar => bundle::split_calendar(&text, &mut new_uid),
PimKind::AddressBook => bundle::split_cards(&text, &mut new_uid),
};
if parts.is_empty() {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"the file holds no calendar or address objects",
));
}
let _lock = pim_schedule::LOCK.lock().await;
let dir = Directory::load(&state).await?;
let owner = dir
.get(owner)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let supported: Vec<&str> = col.components.split(',').collect();
let now = chrono::Utc::now();
let mut result = PimImportResult {
created: 0,
updated: 0,
skipped_total: 0,
skipped: Vec::new(),
};
let mut skip = |uid: Option<String>, reason: &str| {
result.skipped_total += 1;
if result.skipped.len() < MAX_SKIPPED {
result.skipped.push(PimSkipped {
uid,
reason: reason.to_string(),
});
}
};
// Names given in this import, so a UID seen twice updates its first copy.
let mut names: HashMap<String, String> = HashMap::new();
let mut ops = Vec::new();
let (mut created, mut updated) = (0, 0);
for part in parts {
let checked = match kind {
PimKind::Calendar => object::calendar(part.as_bytes(), &supported)
.map(|o| (o.uid, o.component.to_string())),
PimKind::AddressBook => {
object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
}
};
let (uid, component) = match checked {
Ok(v) => v,
Err(invalid) => {
skip(None, &invalid.condition().name);
continue;
}
};
let data = match kind {
PimKind::Calendar => {
object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
}
PimKind::AddressBook => part.into_bytes(),
};
let existing = match names.get(&uid) {
Some(name) => Some(name.clone()),
None => state.db.pim_uid_holder(col.id, &uid, "").await?,
};
let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind));
let schedule_tag = match kind {
PimKind::Calendar => {
match pim_schedule::import_tag(&state, &dir, &owner, (col.id, &name), &data).await?
{
Ok(tag) => tag,
Err(condition) => {
skip(Some(uid), &condition.name);
continue;
}
}
}
PimKind::AddressBook => None,
};
match existing {
Some(_) => updated += 1,
None => created += 1,
}
names.insert(uid.clone(), name.clone());
ops.push(PimOp::Put {
collection_id: col.id,
obj: PimObject {
name,
uid,
component,
etag: etag_of(&data),
schedule_tag,
..Default::default()
},
data,
});
}
state.db.pim_apply(&ops).await?;
result.created = created;
result.updated = updated;
Ok(Json(result))
}
async fn read_root_file(
state: &AppState,
auth: &SessionUser,
file: PimRootFile,
) -> Result<Vec<u8>, ApiError> {
let root = find_root(&auth.roots, file.root_id)?;
let (server_root, root_path) = (state.root.clone(), root.path.clone());
blocking(move || {
let full = fs::resolve_path(&server_root, &root_path, &file.path)?;
let meta = std::fs::metadata(&full)?;
if meta.is_dir() {
return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
}
if meta.len() > MAX_IMPORT as u64 {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"file too large",
));
}
Ok(std::fs::read(&full)?)
})
.await
}
▾Mserver/src/api/pim_schedule.rs
@@ -240,6 +240,38 @@ pub(crate) async fn put(
}))
}
/// The Schedule-Tag an import stores with `body`, `None` for an object that
/// schedules nothing. An import sends no messages: the object is stored as
/// sent. `Err` names the precondition that refuses it.
pub(crate) async fn import_tag(
state: &AppState,
dir: &Directory,
owner: &PimPrincipal,
at: (i64, &str),
body: &[u8],
) -> Result<Result<Option<String>, Element>, ApiError> {
let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(body).as_ref()) else {
return Ok(Ok(None));
};
match itip::role(&cal, &dir.is(owner.id)) {
Err(refused) => Ok(Err(refused.condition())),
Ok(Role::None) => Ok(Ok(None)),
Ok(_) => Ok(match elsewhere(state, owner, &cal, at).await? {
Some(holder) => Err(holder),
None => Ok(Some(etag_of(body))),
}),
}
}
/// The resource name the server picks for an object it creates.
pub(crate) fn object_name(uid: &str, kind: PimKind) -> String {
let ext = match kind {
PimKind::Calendar => "ics",
PimKind::AddressBook => "vcf",
};
format!("{}.{ext}", &crate::hex(&Sha256::digest(uid))[..32])
}
fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
Stored {
data: body.to_vec(),
@@ -480,7 +512,7 @@ async fn deliver(
None => match state.db.pim_calendar_for(p.id, &component).await? {
Some(c) => (
c.id,
format!("{}.ics", &crate::hex(&Sha256::digest(&uid))[..32]),
object_name(&uid, PimKind::Calendar),
Some(etag.clone()),
),
None => return Ok(Some(REFUSED)),
▾Mserver/src/db.rs
@@ -143,12 +143,36 @@ pub struct ShareRow {
impl ShareRow {
pub fn is_expired(&self) -> bool {
match &self.expires_at {
Some(e) => chrono::DateTime::parse_from_rfc3339(e)
.map(|t| chrono::Utc::now() >= t.with_timezone(&chrono::Utc))
.unwrap_or(false),
None => false,
}
expired(self.expires_at.as_deref())
}
}
/// An unparseable stamp never expires; the API refuses one on creation.
fn expired(expires_at: Option<&str>) -> bool {
match expires_at {
Some(e) => chrono::DateTime::parse_from_rfc3339(e)
.map(|t| chrono::Utc::now() >= t.with_timezone(&chrono::Utc))
.unwrap_or(false),
None => false,
}
}
/// A public feed of one calendar or address book.
#[derive(Debug, Clone)]
pub struct PimLink {
pub id: i64,
pub token: String,
pub collection_id: i64,
/// Events reduced to their times.
pub busy_only: bool,
pub created_at: String,
pub expires_at: Option<String>,
pub password_hash: Option<String>,
}
impl PimLink {
pub fn is_expired(&self) -> bool {
expired(self.expires_at.as_deref())
}
}
@@ -444,6 +468,8 @@ impl Db {
// another account. `schedule_tag` is NULL for objects that
// schedule nothing. A `transparent` calendar adds no busy time
// to scheduling (RFC 6638 `schedule-calendar-transp`).
// `pim_links` are public feeds; not rows of `shares`, because
// every path-based share query would then have to skip them.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -508,7 +534,19 @@ impl Db {
mode TEXT NOT NULL CHECK (mode IN ('ro','rw','rw+schedule')),
PRIMARY KEY (collection_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);",
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);
CREATE TABLE IF NOT EXISTS pim_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
busy_only INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT,
password_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_pim_links_collection
ON pim_links(collection_id);",
)?;
}
conn.execute(
@@ -1172,8 +1210,8 @@ impl Db {
Ok(token)
}
/// Delete what has outlived its use. Returns how many shares and how
/// many unlock rows went.
/// Delete what has outlived its use. Returns how many shares and feed
/// links, and how many unlock rows went.
///
/// Housekeeping only: every read already refuses an expired share, so
/// nothing here is load-bearing and the interval does not matter.
@@ -1183,11 +1221,16 @@ impl Db {
// `expires_at` is stored exactly as the client sent it and may carry
// an offset or fractional seconds. An unparseable one yields NULL and
// so survives, which is what `ShareRow::is_expired` decided too.
let shares = c.execute(
"DELETE FROM shares
WHERE expires_at IS NOT NULL AND julianday(expires_at) <= julianday('now')",
[],
)?;
let mut shares = 0;
for table in ["shares", "pim_links"] {
shares += c.execute(
&format!(
"DELETE FROM {table}
WHERE expires_at IS NOT NULL AND julianday(expires_at) <= julianday('now')"
),
[],
)?;
}
// The cookie carrying an unlock is a session cookie, so it is already
// gone from every browser. Deleting a share takes its own with it.
let unlocks = c.execute(
@@ -1778,6 +1821,65 @@ impl Db {
.optional()
}
pub async fn pim_create_link(
&self,
collection_id: i64,
token: &str,
busy_only: bool,
expires_at: Option<&str>,
password_hash: Option<&str>,
) -> DbResult<PimLink> {
let c = self.0.lock().await;
let created_at = now();
c.execute(
"INSERT INTO pim_links
(token, collection_id, busy_only, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
token,
collection_id,
busy_only,
created_at,
expires_at,
password_hash
],
)?;
Ok(PimLink {
id: c.last_insert_rowid(),
token: token.to_string(),
collection_id,
busy_only,
created_at,
expires_at: expires_at.map(str::to_string),
password_hash: password_hash.map(str::to_string),
})
}
pub async fn pim_links(&self, collection_id: i64) -> DbResult<Vec<PimLink>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE collection_id = ?1 ORDER BY id"
))?;
stmt.query_map([collection_id], map_pim_link)?.collect()
}
pub async fn pim_link_by_token(&self, token: &str) -> DbResult<Option<PimLink>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE token = ?1"
))?;
stmt.query_row([token], map_pim_link).optional()
}
/// `false` means no link of that collection had the id.
pub async fn pim_delete_link(&self, collection_id: i64, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute(
"DELETE FROM pim_links WHERE id = ?1 AND collection_id = ?2",
params![id, collection_id],
)? > 0)
}
/// `(user id, name, mode)` of everyone a collection is lent to.
pub async fn pim_shares(
&self,
@@ -2137,6 +2239,21 @@ fn map_pim_object(r: &rusqlite::Row) -> DbResult<PimObject> {
})
}
const PIM_LINK_COLS: &str =
"id, token, collection_id, busy_only, created_at, expires_at, password_hash";
fn map_pim_link(r: &rusqlite::Row) -> DbResult<PimLink> {
Ok(PimLink {
id: r.get(0)?,
token: r.get(1)?,
collection_id: r.get(2)?,
busy_only: r.get(3)?,
created_at: r.get(4)?,
expires_at: r.get(5)?,
password_hash: r.get(6)?,
})
}
/// Column order matched by the two `shares` SELECTs above.
fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
Ok(ShareRow {
▾Aserver/tests/api_pim_io.rs
@@ -0,0 +1,407 @@
//! Public feeds, import and export of calendars and address books.
mod common;
use axum::http::{Method, StatusCode};
use common::*;
use serde_json::{Value, json};
const PW: &str = "secret12345";
const CAL: &str = "/pim/calendars/alice/default/";
struct Io {
env: Env,
alice: Client,
}
impl Io {
async fn new() -> Self {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "alice", PW, &[("docs", "rw"), ("src", "ro")]).await;
create_user(&admin, "bob", PW, &[]).await;
let alice = login(&env, "alice", PW).await;
Io { env, alice }
}
async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
let auth = basic(user, PW);
// Without Depth, PROPFIND lists only the collection itself.
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&[("authorization", &auth), ("depth", "1")],
body.as_bytes().to_vec(),
)
.await
}
async fn anon(&self, path: &str, headers: &[(&str, &str)]) -> Resp {
Client::new(self.env.app.clone())
.raw(Method::GET, path, headers, Vec::new())
.await
}
/// The id of alice's collection with this CalDAV/CardDAV 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()
}
async fn root_id(&self, index: usize) -> i64 {
self.alice.get("/api/auth/me").await.json()["roots"][index]["id"]
.as_i64()
.unwrap()
}
async fn link(&self, id: i64, body: Value) -> String {
let r = self
.alice
.post_json(&format!("/api/pim/collections/{id}/links"), &body)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json()["path"].as_str().unwrap().to_string()
}
async fn import(&self, id: i64, body: &str) -> Value {
let r = self
.alice
.raw(
Method::POST,
&format!("/api/pim/collections/{id}/import"),
&[("content-type", "text/calendar")],
body.as_bytes().to_vec(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json()
}
}
fn event(uid: &str, summary: &str, extra: &str) -> String {
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\nEND:VCALENDAR\r\n"
)
}
#[tokio::test]
async fn feeds() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let book = io.id("/pim/addressbooks/alice/default/").await;
for uid in ["a", "b"] {
let r = io
.dav(
"alice",
"PUT",
&format!("{CAL}{uid}.ics"),
&event(
uid,
"Secret plan",
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\n",
),
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
let r = io
.dav(
"alice",
"PUT",
&format!("{CAL}p.ics"),
&event("p", "Doctor", "CLASS:PRIVATE\r\n"),
)
.await;
assert_eq!(r.status, StatusCode::CREATED);
let full = io.link(cal, json!({})).await;
assert!(
full.starts_with("/feed/") && full.ends_with(".ics"),
"{full}"
);
let r = io.anon(&full, &[]).await;
assert_eq!(r.status, StatusCode::OK);
assert!(
r.header("content-type")
.unwrap()
.starts_with("text/calendar")
);
let text = r.text();
assert!(
text.contains("UID:a\r\n") && text.contains("UID:b\r\n"),
"{text}"
);
assert!(text.contains("X-WR-CALNAME:"));
assert!(text.contains("Secret plan"));
// A public link shows private events as busy time only.
assert!(
!text.contains("Doctor") && text.contains("SUMMARY:Busy"),
"{text}"
);
let etag = r.header("etag").unwrap();
let r = io.anon(&full, &[("if-none-match", &etag)]).await;
assert_eq!(r.status, StatusCode::NOT_MODIFIED);
// The extension is optional.
let bare = full.trim_end_matches(".ics");
assert_eq!(io.anon(bare, &[]).await.status, StatusCode::OK);
// A change gives a new ETag.
io.dav("alice", "PUT", &format!("{CAL}c.ics"), &event("c", "x", ""))
.await;
let r = io.anon(&full, &[("if-none-match", &etag)]).await;
assert_eq!(r.status, StatusCode::OK);
let busy = io.link(cal, json!({ "busy_only": true })).await;
let text = io.anon(&busy, &[]).await.text();
assert!(text.contains("SUMMARY:Busy"), "{text}");
assert!(
!text.contains("Secret plan") && !text.contains("VALARM"),
"{text}"
);
assert!(!text.contains("UID:a\r\n"), "UIDs are hashed: {text}");
// The owner's export keeps everything.
let export = io
.alice
.get(&format!("/api/pim/collections/{cal}/export"))
.await
.text();
assert!(export.contains("Doctor"));
let locked = io.link(cal, json!({ "password": "feedpass123" })).await;
let r = io.anon(&locked, &[]).await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("www-authenticate").is_some());
let wrong = basic("", "nope-nope");
for _ in 0..3 {
let r = io.anon(&locked, &[("authorization", &wrong)]).await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
}
let right = basic("anyone", "feedpass123");
assert_eq!(
io.anon(&locked, &[("authorization", &right)]).await.status,
StatusCode::OK
);
let expired = io
.link(cal, json!({ "expires_at": "2000-01-01T00:00:00Z" }))
.await;
assert_eq!(io.anon(&expired, &[]).await.status, StatusCode::GONE);
let cards = io.link(book, json!({})).await;
assert!(cards.ends_with(".vcf"));
let r = io.anon(&cards, &[]).await;
assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
let r = io
.alice
.post_json(
&format!("/api/pim/collections/{book}/links"),
&json!({ "busy_only": true }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Only the owner sees and manages the links.
let bob = login(&io.env, "bob", PW).await;
assert_eq!(
bob.get(&format!("/api/pim/collections/{cal}/links"))
.await
.status,
StatusCode::NOT_FOUND
);
let list = io
.alice
.get(&format!("/api/pim/collections/{cal}/links"))
.await
.json();
assert_eq!(list.as_array().unwrap().len(), 4);
let first = list[0]["id"].as_i64().unwrap();
let r = io
.alice
.delete(&format!("/api/pim/collections/{cal}/links/{first}"))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(io.anon(&full, &[]).await.status, StatusCode::NOT_FOUND);
// A deleted collection takes its links along.
let r = io
.dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
.await;
assert_eq!(r.status, StatusCode::CREATED);
let work = io.id("/pim/calendars/alice/work/").await;
let gone = io.link(work, json!({})).await;
assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::OK);
io.dav("alice", "DELETE", "/pim/calendars/alice/work/", "")
.await;
assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn import_splits_and_updates() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nMETHOD:PUBLISH\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20260105T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20260106T100000Z\r\nDTSTART:20260106T120000Z\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nDTSTART:20260107T100000Z\r\nSUMMARY:no uid\r\nEND:VEVENT\r\n\
BEGIN:VFREEBUSY\r\nUID:fb\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n";
let r = io.import(cal, file).await;
assert_eq!(r["created"], 2, "{r}");
assert_eq!(r["updated"], 0);
assert_eq!(r["skipped_total"], 1);
assert_eq!(r["skipped"][0]["reason"], "supported-calendar-component");
// The same file again updates, also the event that had no UID.
let r = io.import(cal, file).await;
assert_eq!(
(r["created"].as_i64(), r["updated"].as_i64()),
(Some(0), Some(2))
);
let r = io.dav("alice", "PROPFIND", CAL, "").await;
let listing = r.text();
assert_eq!(listing.matches(".ics</").count(), 2, "{listing}");
let export = io
.alice
.get(&format!("/api/pim/collections/{cal}/export"))
.await;
let text = export.text();
assert!(!text.contains("METHOD"));
assert!(text.contains("RECURRENCE-ID:20260106T100000Z"));
assert!(text.contains("DTSTAMP:"), "import adds DTSTAMP: {text}");
}
#[tokio::test]
async fn import_sends_nothing_and_keeps_uniqueness() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let meeting = event(
"meet",
"Meeting",
"ORGANIZER:mailto:alice@filebrowser.invalid\r\nATTENDEE:mailto:bob@filebrowser.invalid\r\n",
);
let r = io.import(cal, &meeting).await;
assert_eq!(r["created"], 1, "{r}");
// Stored as a scheduling object, with a Schedule-Tag like after a PUT.
let listing = io.dav("alice", "PROPFIND", CAL, "").await.text();
let href = listing
.split("<d:href>")
.filter_map(|s| s.split("</d:href>").next())
.find(|h| h.ends_with(".ics"))
.unwrap_or_else(|| panic!("{listing}"))
.to_string();
let r = io.dav("alice", "GET", &href, "").await;
assert!(r.header("schedule-tag").is_some());
// bob got neither a copy nor a message.
for path in ["/pim/calendars/bob/default/", "/pim/calendars/bob/inbox/"] {
let r = io.dav("bob", "PROPFIND", path, "").await;
assert!(!r.text().contains(".ics</"), "{path}: {}", r.text());
}
// One scheduling object per UID and owner (RFC 6638, 3.2.4.1).
io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
.await;
let work = io.id("/pim/calendars/alice/work/").await;
let r = io.import(work, &meeting).await;
assert_eq!(r["created"], 0);
assert_eq!(r["skipped"][0]["uid"], "meet");
assert_eq!(
r["skipped"][0]["reason"],
"unique-scheduling-object-resource"
);
// A read-only loan cannot be imported into.
let bob = login(&io.env, "bob", PW).await;
io.alice
.post_json(
&format!("/api/pim/collections/{cal}/shares"),
&json!({ "user": "bob", "mode": "ro" }),
)
.await;
let r = bob
.raw(
Method::POST,
&format!("/api/pim/collections/{cal}/import"),
&[("content-type", "text/calendar")],
event("x", "x", "").into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// But exported.
let r = bob.get(&format!("/api/pim/collections/{cal}/export")).await;
assert_eq!(r.status, StatusCode::OK);
}
#[tokio::test]
async fn root_files_and_round_trip() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let (docs, src) = (io.root_id(0).await, io.root_id(1).await);
std::fs::write(io.env.file("docs/in.ics"), event("r1", "From a file", "")).unwrap();
std::fs::write(io.env.file("src/ro.ics"), event("r2", "Read-only root", "")).unwrap();
for (root, path) in [(docs, "in.ics"), (src, "ro.ics")] {
let r = io
.alice
.post_json(
&format!("/api/pim/collections/{cal}/import"),
&json!({ "root_id": root, "path": path }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["created"], 1);
}
let r = io
.alice
.post_json(
&format!("/api/pim/collections/{cal}/import"),
&json!({ "root_id": docs, "path": "../../etc/passwd" }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let r = io
.alice
.get(&format!("/api/pim/collections/{cal}/export"))
.await;
assert_eq!(r.status, StatusCode::OK);
let disposition = r.header("content-disposition").unwrap();
assert!(disposition.starts_with("attachment;") && disposition.contains(".ics"));
let exported = r.text();
let url = format!("/api/pim/collections/{cal}/export");
let into = |root: i64| json!({ "root_id": root, "path": "out.ics" });
let r = io.alice.post_json(&url, &into(docs)).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
std::fs::read_to_string(io.env.file("docs/out.ics")).unwrap(),
exported
);
let r = io.alice.post_json(&url, &into(docs)).await;
assert_eq!(r.status, StatusCode::CONFLICT);
let r = io.alice.post_json(&url, &into(src)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// The export imports back as the same objects.
io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/copy/", "")
.await;
let copy = io.id("/pim/calendars/alice/copy/").await;
let r = io.import(copy, &exported).await;
assert_eq!(r["created"], 2, "{r}");
// vCards, one without UID.
let book = io.id("/pim/addressbooks/alice/default/").await;
let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:One\r\nEND:VCARD\r\n\
BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nEND:VCARD\r\n";
let r = io.import(book, cards).await;
assert_eq!(r["created"], 2, "{r}");
let r = io.import(book, cards).await;
assert_eq!(r["updated"], 2, "{r}");
}