CalDAV/CardDAV client fixes: discovery, dead properties, vCard 3.0, addresses

- GET/HEAD on /pim, principals, homes and collections answer 200, so
  GET-based discovery (libdav, pimsync) works
- PROPFIND on / redirects 307 to /pim/, OPTIONS on / carries the DAV
  header; python-caldav works with the bare server URL
- DAV header on every /pim response; Basic challenge with charset=UTF-8
- Client properties the server does not interpret are stored per
  collection, home and principal (new pim_props table in the unshipped
  v12); computed ones get 403 cannot-modify-protected-property. Fixes
  macOS default-alarm and me-card PROPPATCH and MKCALENDAR with
  calendar-free-busy-set
- calendar-user-address-set lists only the mailto address, preferred
- Address books announce vCard 3.0 only; address-data and GET default
  to 3.0 with Apple's group, company and pref forms, 4.0 on request
- Fake addresses percent-encode characters invalid in a local part
- A Basic user name with %40 (iOS 18.4+) is decoded once
- Docs: HTTPS for Apple, /.well-known needs the host root, Thunderbird
  email identity "None", vCard versions, client properties, addresses

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit39fbe17cb81640df70da3d68a94cd1bb3ced5182
Parent2082e5b
12 files changed, 1306 insertions(+), 191 deletions(-)
▾MREADME.md
@@ -225,6 +225,17 @@ Apple Calendar and Contacts, Thunderbird, and DAVx5 on Android.
| User name | Your account name |
| Password | An app password, created under Settings → Security |
- Apple Calendar and Contacts only connect over HTTPS, and iOS rejects a
self-signed certificate without SAN entries. Put the server behind a
reverse proxy with a real certificate.
- Apps look for `/.well-known/caldav` at the root of the host. Under a
sub-path such as `https://host/files/` that lookup fails, so give apps
the full URL there (`https://host/files/pim/`).
- Thunderbird: set the calendar's email identity to "None". Otherwise
Thunderbird names your real email as organizer. The server does not
know that address, so it invites no one. Thunderbird sends no email
either, because the server announces that it schedules.
| URL | Content |
|-----|---------|
| `/pim/principals/<name>/` | An account, room or resource |
@@ -246,6 +257,13 @@ 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.
**vCard versions**: address books announce vCard 3.0. Apple Contacts
reads groups and companies only in that form. A contact stored as vCard
4.0 is returned as 3.0, with groups as `X-ADDRESSBOOKSERVER-KIND` and
`X-ADDRESSBOOKSERVER-MEMBER`, unless the app asks for 4.0 (`Accept:
text/vcard; version=4.0`, or `version="4.0"` in a report). The stored
bytes do not change, and neither does the ETag.
**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. With the thumbnail cache (`--cache`) the
@@ -363,6 +381,11 @@ so nothing can reach anyone outside by mistake:
| Room | `<name>@rooms.filebrowser.invalid` |
| Resource | `<name>@resources.filebrowser.invalid` |
A name with characters an email address cannot hold keeps them
percent-encoded: account `marc@example.com` becomes
`marc%40example.com@filebrowser.invalid`. A dot is encoded too when it
would lead, trail or repeat.
An outside address in an invitation is kept, but marked as not delivered
(status 5.2). Apps find the people on the server through the system
address book or their attendee search.
▾Mpimdav/README.md
@@ -206,8 +206,9 @@ model, so component indices match the expansion.
RECURRENCE-ID and without RRULE, RDATE or EXDATE. Dates stay dates.
- One object may expand into at most 10,000 instances. Past that the
REPORT fails with `CALDAV:max-instances`.
- `address-data` can convert between vCard 3.0 and 4.0. calcard then
writes `CHARSET=UTF-8` on non-ASCII 3.0 values.
- `address-data` without a version means 3.0 (RFC 6352, 10.4). A 4.0
card is then converted, see "vCard versions" below. calcard writes
`CHARSET=UTF-8` on non-ASCII 3.0 values.
- CR is written as `&#13;` in XML, so returned data keeps its CRLF.
### `max-instances`
@@ -464,6 +465,69 @@ meeting.
address, and so invite the new principal. If-Match prevents that for
clients that send it.
## Client compatibility
What clients need beyond the core RFCs, mostly learned from other servers'
issue trackers and from running vdirsyncer, khard, pimsync and
python-caldav against the server.
### Discovery
- GET and HEAD on `/pim/`, principals, homes and collections answer 200
with a short text. RFC 6764 lets clients follow the `/.well-known`
redirect with GET, and libdav (pimsync) requires a 2xx there.
- PROPFIND on `/` answers 307 to `/pim/`, and OPTIONS on `/` carries the
DAV header. python-caldav, given only the server address, asks it for
`current-user-principal`. GET on `/` stays the web app.
- Every `/pim` response carries the `DAV` header, the 401 challenge
included. Apple Calendar looks for it on PROPFIND responses.
- The Basic challenge names `charset="UTF-8"` (RFC 7617), so clients send
non-ASCII passwords as UTF-8.
### Client properties
- A property the server does not interpret is stored as the client sent
it, as XML, on a collection, a home or a principal. macOS Calendar
PROPPATCHes `default-alarm-vevent-date` onto the calendar home and stops
syncing on a 403. macOS Contacts sets `me-card` on the address book
home. Older Apple clients send `calendar-free-busy-set` with
MKCALENDAR.
- A property the server computes is refused with 403, and the response
names `cannot-modify-protected-property`. The request stays atomic: the
other properties get 424.
- One value may be 64 KiB, and one resource may hold 100 such
properties. Past that the property gets 507.
- A borrower reads the owner's properties of a lent collection and cannot
change them. Admins change those of rooms and resources.
### vCard versions
- Address books announce vCard 3.0 only. A client told of 4.0 writes 4.0
groups, which Apple Contacts on the same account cannot read. sabre/dav
stopped announcing 4.0 for this reason.
- A 4.0 PUT is still stored as sent. GET returns 3.0 unless `Accept`
names `version=4.0`. The ETag stays that of the stored bytes, so
`If-Match` works with either form.
- Converting to 3.0 writes `KIND:group` and `MEMBER` as
`X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org`
as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. Converting to 4.0
maps them back. sabre/vobject does the same.
### Addresses and logins
- `calendar-user-address-set` lists only the mailto address, with
`preferred="1"`. Apple takes the first href in order unless one is
preferred, and a principal URL there would make the attendee not match
the user. Scheduling still accepts principal URLs and `urn:uuid:`
addresses.
- A principal name becomes the local part of its address with every
character except letters, digits, `-`, `_` and `.` percent-encoded.
`%` is valid in a local part, `@` is not. Dots are encoded too when one
would lead, trail or repeat. Decoding gives the name back, so
resolution uses the same mapping.
- A Basic user name with `%` that names no account is decoded once. iOS
18.4 and later send `@` as `%40`. The throttle counts one attempt.
## Where the RFCs are unclear or implementations differ
| Case | What we do | Why |
@@ -487,6 +551,8 @@ meeting.
| `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 |
| `address-data` or GET without a version | vCard 3.0 | RFC 6352, 10.4; Apple Contacts needs it |
| Unknown property in PROPPATCH or MKCALENDAR | Stored | RFC 4918 allows dead properties; Apple fails on a 403 |
| TZID property with escaped commas | Unescaped as TEXT | The property is TEXT; the TZID parameter holds the plain value, so Outlook's `Athens\, Bucharest` must match `"Athens, Bucharest"` |
| Import with X-WR-TIMEZONE | Dropped | It is not standard; floating times follow the collection instead |
| 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 |
▾Mpimdav/src/render.rs
@@ -122,9 +122,9 @@ pub fn address_request(e: &Element) -> Result<AddressData, Refused> {
{
return Err(unsupported());
}
// RFC 6352, 10.4: without a version, 3.0.
let version = match e.attributes.get("version").map(String::as_str) {
None => None,
Some("3.0") => Some(VCardVersion::V3_0),
None | Some("3.0") => Some(VCardVersion::V3_0),
Some("4.0") => Some(VCardVersion::V4_0),
Some(_) => return Err(unsupported()),
};
@@ -355,6 +355,11 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
if req.props.is_none() && Some(version) == card.version() {
return raw.to_string();
}
// calcard's 4.0 writer drops a `pref` type, so map before parsing.
let card = match version {
VCardVersion::V4_0 => VCard::parse(apple_forms(raw, version)).unwrap_or(card),
_ => card,
};
let entries = match &req.props {
None => card.entries,
Some(props) => card
@@ -373,5 +378,92 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
};
let mut out = String::new();
let _ = VCard { entries }.write_to(&mut out, version);
match version {
VCardVersion::V4_0 => out,
_ => apple_forms(&out, version),
}
}
/// The version a GET asks for in its `Accept` header: 4.0 when named,
/// else 3.0, which RFC 6352 makes the default and Apple Contacts needs.
pub fn accepted_version(accept: Option<&str>) -> VCardVersion {
match accept.is_some_and(|a| a.contains("version=4.0")) {
true => VCardVersion::V4_0,
false => VCardVersion::V3_0,
}
}
/// vCard 4.0 groups and companies in the vCard 3.0 extensions Apple
/// Contacts reads, and back: `KIND:group` and `MEMBER` as
/// `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org`
/// as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. sabre/vobject
/// converts the same way.
fn apple_forms(text: &str, version: VCardVersion) -> String {
let v3 = version == VCardVersion::V3_0;
let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut out = String::with_capacity(text.len());
for raw in crate::text::logical_lines(text) {
let line = crate::text::unfold(raw);
let start = crate::text::value_start(&line);
let head = &line[..start.saturating_sub(1)];
let head_name = head.split(';').next().unwrap_or_default();
let (group, name) = match head_name.rsplit_once('.') {
Some((g, n)) => (&head_name[..=g.len()], n.to_ascii_uppercase()),
None => ("", head_name.to_ascii_uppercase()),
};
let value = &line[start..];
let lower = value.trim().to_ascii_lowercase();
let renamed = match (v3, name.as_str(), lower.as_str()) {
(true, "KIND", "group") => Some(("X-ADDRESSBOOKSERVER-KIND", "group")),
(true, "KIND", "org") => Some(("X-ABSHOWAS", "COMPANY")),
(true, "KIND", "individual") => continue,
(true, "MEMBER", _) => Some(("X-ADDRESSBOOKSERVER-MEMBER", value)),
(false, "X-ADDRESSBOOKSERVER-KIND", "group") => Some(("KIND", "group")),
(false, "X-ABSHOWAS", "company") => Some(("KIND", "org")),
(false, "X-ADDRESSBOOKSERVER-MEMBER", _) => Some(("MEMBER", value)),
_ => None,
};
let params = pref_params(crate::text::param_parts(&line), v3);
let (name, value) = renamed.unwrap_or((&name, value));
let mut new = format!("{group}{name}");
for p in &params {
new.push(';');
new.push_str(p);
}
new.push(':');
new.push_str(value);
if new == line {
out.push_str(raw);
} else {
out.push_str(&crate::text::fold(&new, eol));
}
}
out
}
/// `PREF=1` as a `pref` type for vCard 3.0, and back for 4.0.
fn pref_params(params: Vec<&str>, v3: bool) -> Vec<String> {
let mut out = Vec::with_capacity(params.len());
let mut pref = false;
for p in params {
let (key, value) = p.split_once('=').unwrap_or((p, ""));
match (v3, key.to_ascii_uppercase().as_str()) {
(true, "PREF") => pref |= value.trim() == "1",
(false, "TYPE") => {
let (prefs, rest): (Vec<&str>, Vec<&str>) = value
.trim_matches('"')
.split(',')
.partition(|t| t.eq_ignore_ascii_case("pref"));
pref |= !prefs.is_empty();
if !rest.is_empty() {
out.push(format!("{key}={}", rest.join(",")));
}
}
_ => out.push(p.to_string()),
}
}
if pref {
out.push(if v3 { "TYPE=pref" } else { "PREF=1" }.to_string());
}
out
}
▾Mpimdav/tests/report.rs
@@ -202,6 +202,65 @@ fn address_data_converts_versions() {
assert!(address_data(card, &v4).contains("VERSION:4.0"));
}
#[test]
fn groups_and_companies_in_apple_forms() {
use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
let to = |v| AddressData {
props: None,
version: Some(v),
};
let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\n\
MEMBER:urn:uuid:m1\r\nEMAIL;PREF=1;TYPE=work:t@example.com\r\nEND:VCARD\r\n";
let v3 = address_data(group, &to(V3_0));
assert!(v3.contains("VERSION:3.0"), "{v3}");
assert!(v3.contains("X-ADDRESSBOOKSERVER-KIND:group\r\n"), "{v3}");
assert!(
v3.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:m1\r\n"),
"{v3}"
);
assert!(
!v3.contains("\r\nKIND") && !v3.contains("\r\nMEMBER"),
"{v3}"
);
assert!(v3.contains("TYPE=pref") && !v3.contains("PREF=1"), "{v3}");
// Back to 4.0, as a client asking for it gets an Apple-made card.
let back = address_data(&v3, &to(V4_0));
assert!(
back.to_ascii_lowercase().contains("\r\nkind:group\r\n"),
"{back}"
);
assert!(back.contains("MEMBER:urn:uuid:m1\r\n"), "{back}");
assert!(
back.contains("PREF=1") && !back.contains("X-ADDRESSBOOKSERVER"),
"{back}"
);
let org = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:ACME\r\nKIND:org\r\nEND:VCARD\r\n";
assert!(address_data(org, &to(V3_0)).contains("X-ABSHOWAS:COMPANY\r\n"));
let apple = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:o\r\nFN:ACME\r\nX-ABSHOWAS:COMPANY\r\n\
TEL;TYPE=\"CELL,pref\":1\r\nEND:VCARD\r\n";
let v4 = address_data(apple, &to(V4_0));
assert!(v4.to_ascii_lowercase().contains("\r\nkind:org\r\n"), "{v4}");
assert!(v4.contains("TYPE=CELL") && v4.contains("PREF=1"), "{v4}");
}
#[test]
fn vcard_three_is_the_default() {
use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
use pimdav::render::{accepted_version, address_request};
use pimdav::xml::{CARDDAV, el};
assert_eq!(
address_request(&el(CARDDAV, "address-data"))
.unwrap()
.version,
Some(V3_0)
);
assert_eq!(accepted_version(None), V3_0);
assert_eq!(accepted_version(Some("text/vcard")), V3_0);
assert_eq!(accepted_version(Some("text/vcard; version=4.0")), V4_0);
}
#[test]
fn card_filters() {
let card = VCard::parse(
▾Mserver/src/api/dav.rs
@@ -251,7 +251,12 @@ fn dav_target(
pub(crate) fn challenge() -> Response<Body> {
(
StatusCode::UNAUTHORIZED,
[(WWW_AUTHENTICATE, format!("Basic realm=\"{REALM}\""))],
// RFC 7617: without a charset, clients may send a non-ASCII password
// as Latin-1.
[(
WWW_AUTHENTICATE,
format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""),
)],
)
.into_response()
}
@@ -285,6 +290,18 @@ pub(crate) async fn authenticate(state: &AppState, headers: &HeaderMap) -> Optio
Err(e) => tracing::warn!(error = %e, "app password lookup failed"),
}
// iOS 18.4 and later send `@` in the user name as `%40`. Decoded only
// when no account has the name as sent, and before the one verify, so
// the throttle counts one attempt.
let name = match percent_encoding::percent_decode_str(&name).decode_utf8() {
Ok(decoded)
if decoded != name && matches!(state.db.find_user_by_name(&name).await, Ok(None)) =>
{
decoded.into_owned()
}
_ => name,
};
let id = auth::verify_cached(0, &name, &password, || {
let (state, name, password) = (state, name.clone(), password.clone());
async move {
▾Mserver/src/api/pim.rs
@@ -24,7 +24,9 @@ use axum::extract::State;
use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION};
use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
use axum::response::IntoResponse;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use percent_encoding::{
AsciiSet, CONTROLS, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode,
};
use pimdav::calcard::icalendar::ICalendar;
use pimdav::calcard::vcard::VCard;
use pimdav::principal::{self, Principal, Search, UserType};
@@ -42,8 +44,8 @@ use sha2::{Digest, Sha256};
use xmltree::Element;
use crate::db::{
PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimShareMode, PimWrite, Precondition,
User,
DeadProp, PimCollection, PimKind, PimObject, PimOp, PimPrincipal, PimShareMode, PimWrite,
Precondition, PropPlace, User,
};
use crate::error::{ApiError, AppState};
@@ -53,6 +55,11 @@ const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
/// Largest XML request body.
const MAX_XML_SIZE: usize = 1024 * 1024;
/// Largest client property the server stores without interpreting it, and
/// the most one resource may hold.
const MAX_DEAD_SIZE: usize = 64 * 1024;
const MAX_DEAD_PROPS: usize = 100;
/// The domain of the addresses users schedule with. `.invalid` is reserved
/// (RFC 2606), so nothing sent there can reach anyone.
pub(super) const MAIL_DOMAIN: &str = "filebrowser.invalid";
@@ -85,6 +92,14 @@ const SEGMENT: &AsciiSet = &CONTROLS
.add(b'{')
.add(b'}');
/// Characters a principal name keeps in the local part of its address. The
/// rest is percent-encoded: `%` is valid there, `@` and spaces are not
/// (RFC 5322, 3.2.3).
const LOCAL: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
/// The same without the dot, for names where a dot would lead, trail or
/// repeat.
const LOCAL_NO_DOT: &AsciiSet = &LOCAL.add(b'.');
type Reply = Result<Response<Body>, ApiError>;
/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
@@ -99,14 +114,22 @@ pub async fn well_known() -> Response<Body> {
.into_response()
}
/// The `DAV` header of every response here. Apple Calendar looks for it on
/// PROPFIND responses too, not only on OPTIONS.
pub(super) const COMPLIANCE: &str =
"1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, extended-mkcol";
/// `{PIM}` and everything under it.
pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
let Some((user_id, _)) = super::dav::authenticate(&state, req.headers()).await else {
return super::dav::challenge();
let mut r = match super::dav::authenticate(&state, req.headers()).await {
Some((user_id, _)) => serve(&state, user_id, req)
.await
.unwrap_or_else(IntoResponse::into_response),
None => super::dav::challenge(),
};
serve(&state, user_id, req)
.await
.unwrap_or_else(IntoResponse::into_response)
r.headers_mut()
.insert("dav", axum::http::HeaderValue::from_static(COMPLIANCE));
r
}
/// The signed-in account.
@@ -231,7 +254,10 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
"PROPFIND" => cx.propfind(&target, &parts.headers, body).await,
"PROPPATCH" => cx.proppatch(&target, body).await,
"MKCALENDAR" | "MKCOL" => cx.mkcol(&target, method.as_str(), body).await,
"GET" | "HEAD" => cx.get(&target, method == Method::HEAD).await,
"GET" | "HEAD" => {
cx.get(&target, &parts.headers, method == Method::HEAD)
.await
}
"PUT" => cx.put(&target, &parts.headers, body).await,
"DELETE" => cx.delete(&target, &parts.headers).await,
"REPORT" => cx.report(&target, body).await,
@@ -405,18 +431,7 @@ fn options(target: &Target) -> Response<Body> {
"OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT"
}
};
(
StatusCode::OK,
[
(
"dav",
"1, 3, access-control, calendar-access, calendar-auto-schedule, addressbook, \
extended-mkcol",
),
(ALLOW.as_str(), allow),
],
)
.into_response()
(StatusCode::OK, [(ALLOW.as_str(), allow)]).into_response()
}
async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
@@ -448,7 +463,17 @@ pub(super) fn mailto(name: &str, kind: UserType) -> String {
UserType::Room => format!("rooms.{MAIL_DOMAIN}"),
UserType::Resource => format!("resources.{MAIL_DOMAIN}"),
};
format!("{}@{domain}", seg(name))
format!("{}@{domain}", local_part(name))
}
/// A principal name as the local part of an address. Decoding the percent
/// escapes gives the name back.
pub(super) fn local_part(name: &str) -> String {
let set = match name.starts_with('.') || name.ends_with('.') || name.contains("..") {
true => LOCAL_NO_DOT,
false => LOCAL,
};
utf8_percent_encode(name, set).to_string()
}
/// A principal as PROPFIND and the searches describe it.
@@ -473,12 +498,12 @@ impl PrincipalView {
}
}
/// Only the mailto address: Apple takes the first href in order unless
/// one is `preferred`, and an attendee matched by its principal URL gets
/// no reply buttons. Scheduling still accepts the principal URL and the
/// `urn:uuid:` form.
fn addresses(&self) -> Vec<String> {
vec![
format!("mailto:{}", mailto(&self.path, self.kind)),
principal_href(&self.path),
format!("urn:uuid:{}", principal_uuid(self.id)),
]
vec![format!("mailto:{}", mailto(&self.path, self.kind))]
}
}
@@ -730,8 +755,9 @@ enum Res {
Root,
Principals,
Principal(PrincipalView),
/// With its owner's principal href and whether the account may add to it.
Home(String, Access),
/// With its owner's principal href, whether the account may add to it,
/// and where its client properties live.
Home(String, Access, PropPlace),
Collection(PimKind, Col),
/// With the href of the calendar that receives new invitations.
Inbox(Col, Option<String>),
@@ -790,7 +816,8 @@ impl Cx<'_> {
Target::Home(kind, _) => {
let s = self.space();
let access = if s.mine { Access::Own } else { Access::Read };
list.push((s.home(*kind), Res::Home(s.principal(), access)));
let place = PropPlace::Home(s.id, *kind);
list.push((s.home(*kind), Res::Home(s.principal(), access, place)));
if deep {
for col in self.collections(*kind).await? {
let href = s.collection(*kind, &col.c.slug);
@@ -846,13 +873,35 @@ impl Cx<'_> {
}
}
let responses: Vec<xml::Response> = list
.into_iter()
.map(|(href, res)| select(href, &request, self.props(&res)))
.collect();
let mut responses = Vec::with_capacity(list.len());
for (href, res) in list {
let mut all = self.props(&res);
all.extend(self.dead_props(&res).await?);
responses.push(select(href, &request, all));
}
Ok(multistatus(&responses, None))
}
/// The client properties stored for a resource.
async fn dead_props(&self, res: &Res) -> Result<Vec<Element>, ApiError> {
let place = match res {
Res::Principal(p) => PropPlace::Principal(p.id),
Res::Home(_, _, place) => *place,
Res::Collection(_, col) | Res::Inbox(col, _) if !generated(col.c.id) => {
PropPlace::Collection(col.c.id)
}
_ => return Ok(Vec::new()),
};
Ok(self
.state
.db
.pim_props(place)
.await?
.iter()
.filter_map(|p| Element::parse(p.xml.as_bytes()).ok())
.collect())
}
/// Every live property of a resource, with its value.
fn props(&self, res: &Res) -> Vec<Element> {
let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
@@ -888,7 +937,9 @@ impl Cx<'_> {
href_prop(DAV, "principal-URL", &href),
with_children(
el(CALDAV, "calendar-user-address-set"),
hrefs(addresses.iter().map(String::as_str)),
hrefs(addresses.iter().map(String::as_str))
.into_iter()
.map(|h| with_attr(h, "preferred", "1")),
),
with_children(
el(CALSERVER, "email-address-set"),
@@ -936,7 +987,7 @@ impl Cx<'_> {
));
}
}
Res::Home(owner, access) => out.extend([
Res::Home(owner, access, _) => out.extend([
resourcetype(&[(DAV, "collection")]),
href_prop(DAV, "owner", owner),
privileges(*access),
@@ -1010,19 +1061,20 @@ impl Cx<'_> {
)],
));
}
// 3.0 only: a client told of 4.0 writes 4.0 groups, which
// Apple Contacts on the same account cannot read. A 4.0
// PUT is still stored, and served as 4.0 on request.
PimKind::AddressBook => out.push(with_children(
el(CARDDAV, "supported-address-data"),
["3.0", "4.0"].map(|v| {
[with_attr(
with_attr(
with_attr(
el(CARDDAV, "address-data-type"),
"content-type",
"text/vcard",
),
"version",
v,
)
}),
el(CARDDAV, "address-data-type"),
"content-type",
"text/vcard",
),
"version",
"3.0",
)],
)),
}
}
@@ -1239,36 +1291,86 @@ fn content_type(kind: PimKind, component: &str) -> String {
impl Cx<'_> {
async fn proppatch(&self, target: &Target, body: Body) -> Reply {
let Target::Collection(kind, _, slug) = target else {
return Ok(status(StatusCode::FORBIDDEN));
};
let Some(Col {
c: mut col, access, ..
}) = self.collection(*kind, slug).await?
else {
return Ok(status(StatusCode::NOT_FOUND));
let (href, place, res, mut col) = match target {
Target::Collection(kind, _, slug) => {
let Some(col) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let href = self.space().collection(*kind, slug);
if col.access != Access::Own {
return Ok(denied(&href, "write-properties"));
}
let place = PropPlace::Collection(col.c.id);
let stored = (*kind, col.c.clone());
(href, place, self.res(*kind, col).await?, Some(stored))
}
Target::Home(kind, _) => {
let s = self.space();
if !self.may_edit(s) {
return Ok(denied(&s.home(*kind), "write-properties"));
}
let place = PropPlace::Home(s.id, *kind);
let res = Res::Home(s.principal(), Access::Own, place);
(s.home(*kind), place, res, None)
}
Target::Principal(_) => {
let s = self.space();
if !self.may_edit(s) {
return Ok(denied(&s.principal(), "write-properties"));
}
let view = PrincipalView {
id: s.id,
path: s.path.clone(),
display: s.display.clone(),
kind: s.kind,
me: s.mine,
};
let place = PropPlace::Principal(s.id);
(s.principal(), place, Res::Principal(view), None)
}
_ => return Ok(status(StatusCode::FORBIDDEN)),
};
let href = self.space().collection(*kind, slug);
if access != Access::Own {
return Ok(denied(&href, "write-properties"));
}
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let Ok(update) = xml::update(&body) else {
return Ok(status(StatusCode::BAD_REQUEST));
};
let (ok, results) = apply(*kind, &mut col, &update, false);
if ok {
self.state.db.pim_update_collection(&col).await?;
let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
let stored = self.state.db.pim_props(place).await?;
let patch = apply(
col.as_mut().map(|(k, c)| (*k, c)),
&update,
false,
&live,
&stored,
);
if patch.ok() {
let db = &self.state.db;
db.pim_patch(
place,
col.as_ref().map(|(_, c)| c),
&patch.set,
&patch.remove,
)
.await?;
}
let mut r = xml::Response::new(href);
for (code, prop) in results {
r.error = patch
.protected
.then(|| el(DAV, "cannot-modify-protected-property"));
for (code, prop) in patch.results {
r.push(code, prop);
}
Ok(multistatus(&[r], None))
}
/// The owner changes the properties of its principal and homes, admins
/// those of rooms and resources.
fn may_edit(&self, s: &Space) -> bool {
s.mine || (self.me.admin && s.kind != UserType::Individual)
}
async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply {
let Target::Collection(kind, _, slug) = target else {
return Ok(status(StatusCode::FORBIDDEN));
@@ -1313,13 +1415,22 @@ impl Cx<'_> {
},
..Default::default()
};
let (ok, results) = apply(*kind, &mut col, &update, true);
if !ok {
let res = Res::Collection(
*kind,
Col {
c: col.clone(),
access: Access::Own,
owner: space.principal(),
},
);
let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
let patch = apply(Some((*kind, &mut col)), &update, true, &live, &[]);
if !patch.ok() {
let root = match calendar {
true => Name::new(CALDAV, "mkcalendar-response"),
false => Name::new(DAV, "mkcol-response"),
};
let propstats = group(results);
let propstats = group(patch.results);
return Ok(xml_response(
StatusCode::FORBIDDEN,
xml::propstat_document(&root, &propstats),
@@ -1328,7 +1439,7 @@ impl Cx<'_> {
if !self
.state
.db
.pim_create_collection(self.me.pid, *kind, &col)
.pim_create_collection(self.me.pid, *kind, &col, &patch.set)
.await?
{
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
@@ -1345,110 +1456,237 @@ fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
r.propstats
}
/// Applies property changes to `col`. Returns whether all of them are
/// allowed, and each property with its status. Nothing may be stored unless
/// all are: RFC 4918 makes PROPPATCH atomic.
/// A property update: each property with its status, and the client
/// properties to store and remove.
struct Patch {
results: Vec<(u16, Element)>,
set: Vec<DeadProp>,
remove: Vec<(String, String)>,
/// A property the server computes was named.
protected: bool,
}
impl Patch {
fn ok(&self) -> bool {
self.results.iter().all(|(code, _)| *code == 200)
}
}
/// DAV properties the server computes on some resource, beyond the ones
/// `live` names for the resource at hand.
const PROTECTED: [&str; 20] = [
"acl",
"alternate-URI-set",
"creationdate",
"current-user-principal",
"current-user-privilege-set",
"getcontentlength",
"getcontenttype",
"getetag",
"getlastmodified",
"group",
"group-member-set",
"group-membership",
"lockdiscovery",
"owner",
"principal-URL",
"principal-collection-set",
"resourcetype",
"supported-report-set",
"supportedlock",
"sync-token",
];
/// Applies a PROPPATCH, MKCALENDAR or extended MKCOL body. A collection's
/// own properties go into `col`. What the server computes (`live`, or a
/// [`PROTECTED`] DAV property) is refused; anything else is stored as the
/// client sent it, as clients expect of properties such as Apple's
/// `default-alarm-vevent-date`. Nothing may be stored unless all of it is
/// allowed: RFC 4918 makes PROPPATCH atomic.
fn apply(
kind: PimKind,
col: &mut PimCollection,
mut col: Option<(PimKind, &mut PimCollection)>,
update: &Update,
creating: bool,
) -> (bool, Vec<(u16, Element)>) {
let cal = kind == PimKind::Calendar;
let mut results = Vec::new();
live: &[Name],
stored: &[DeadProp],
) -> Patch {
let mut patch = Patch {
results: Vec::new(),
set: Vec::new(),
remove: Vec::new(),
protected: false,
};
let is_protected =
|n: &Name| live.contains(n) || (n.ns == DAV && PROTECTED.contains(&n.local.as_str()));
for p in &update.set {
let name = Name::of(p);
let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
let ok = match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => {
col.displayname = value();
true
}
(CALDAV, "calendar-description") if cal => {
col.description = value();
true
let own = col
.as_mut()
.and_then(|(kind, c)| set_own(*kind, c, p, &name, creating));
let code = match own {
Some(true) => 200,
Some(false) => 403,
None if is_protected(&name) => {
patch.protected = true;
403
}
(CARDDAV, "addressbook-description") if !cal => {
col.description = value();
true
}
(APPLE, "calendar-color") if cal => {
col.color = value();
true
}
(APPLE, "calendar-order") if cal => {
col.sort_order = value();
true
}
(CALDAV, "calendar-timezone") if cal => {
let tz = value();
let valid = tz.as_deref().is_none_or(is_timezone);
if valid {
col.timezone = tz;
}
valid
}
(CALDAV, "schedule-calendar-transp") if cal => {
let transparent = xml::child(p, CALDAV, "transparent").is_some();
let valid = transparent || xml::child(p, CALDAV, "opaque").is_some();
if valid {
col.transparent = transparent;
None => {
let xml = xml::document(p);
if xml.len() > MAX_DEAD_SIZE {
507
} else {
patch.set.push(DeadProp {
ns: name.ns.clone(),
name: name.local.clone(),
xml,
});
200
}
valid
}
(DAV, "resourcetype") if creating => {
let wanted = match kind {
PimKind::Calendar => (CALDAV, "calendar"),
PimKind::AddressBook => (CARDDAV, "addressbook"),
};
xml::child(p, wanted.0, wanted.1).is_some()
};
patch.results.push((code, name.element()));
}
for name in &update.remove {
let own = col
.as_mut()
.and_then(|(kind, c)| remove_own(*kind, c, name));
let code = match own {
Some(()) => 200,
None if is_protected(name) => {
patch.protected = true;
403
}
(CALDAV, "supported-calendar-component-set") if creating && cal => {
let comps: Vec<_> = xml::elements(p)
.filter(|c| Name::of(c).is(CALDAV, "comp"))
.filter_map(|c| c.attributes.get("name"))
.map(|n| n.to_ascii_uppercase())
.collect();
let valid = !comps.is_empty()
&& comps
.iter()
.all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
if valid {
col.components = comps.join(",");
}
valid
None => {
patch.remove.push((name.ns.clone(), name.local.clone()));
200
}
_ => false,
};
results.push((if ok { 200 } else { 403 }, name.element()));
patch.results.push((code, name.element()));
}
for name in &update.remove {
if cal && name.is(CALDAV, "schedule-calendar-transp") {
col.transparent = false;
results.push((200, name.element()));
continue;
let mut names: Vec<(&str, &str)> = stored
.iter()
.map(|p| (p.ns.as_str(), p.name.as_str()))
.chain(patch.set.iter().map(|p| (p.ns.as_str(), p.name.as_str())))
.filter(|n| {
!patch
.remove
.iter()
.any(|(ns, l)| (ns.as_str(), l.as_str()) == *n)
})
.collect();
names.sort_unstable();
names.dedup();
if names.len() > MAX_DEAD_PROPS {
for (code, prop) in &mut patch.results {
let n = Name::of(prop);
if patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local) {
*code = 507;
}
}
let field = match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => Some(&mut col.displayname),
(CALDAV, "calendar-description") if cal => Some(&mut col.description),
(CARDDAV, "addressbook-description") if !cal => Some(&mut col.description),
(APPLE, "calendar-color") if cal => Some(&mut col.color),
(APPLE, "calendar-order") if cal => Some(&mut col.sort_order),
(CALDAV, "calendar-timezone") if cal => Some(&mut col.timezone),
_ => None,
};
let ok = field.map(|f| *f = None).is_some();
results.push((if ok { 200 } else { 403 }, name.element()));
}
let ok = results.iter().all(|(code, _)| *code == 200);
if !ok {
for (code, _) in &mut results {
if !patch.ok() {
for (code, _) in &mut patch.results {
if *code == 200 {
*code = 424;
}
}
}
(ok, results)
patch
}
/// Sets one of a collection's own properties. `None` if it is none of them,
/// `Some(valid)` otherwise.
fn set_own(
kind: PimKind,
col: &mut PimCollection,
p: &Element,
name: &Name,
creating: bool,
) -> Option<bool> {
let cal = kind == PimKind::Calendar;
let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
Some(match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => {
col.displayname = value();
true
}
(CALDAV, "calendar-description") if cal => {
col.description = value();
true
}
(CARDDAV, "addressbook-description") if !cal => {
col.description = value();
true
}
(APPLE, "calendar-color") if cal => {
col.color = value();
true
}
(APPLE, "calendar-order") if cal => {
col.sort_order = value();
true
}
(CALDAV, "calendar-timezone") if cal => {
let tz = value();
let valid = tz.as_deref().is_none_or(is_timezone);
if valid {
col.timezone = tz;
}
valid
}
(CALDAV, "schedule-calendar-transp") if cal => {
let transparent = xml::child(p, CALDAV, "transparent").is_some();
let valid = transparent || xml::child(p, CALDAV, "opaque").is_some();
if valid {
col.transparent = transparent;
}
valid
}
(DAV, "resourcetype") if creating => {
let wanted = match kind {
PimKind::Calendar => (CALDAV, "calendar"),
PimKind::AddressBook => (CARDDAV, "addressbook"),
};
xml::child(p, wanted.0, wanted.1).is_some()
}
(CALDAV, "supported-calendar-component-set") if creating && cal => {
let comps: Vec<_> = xml::elements(p)
.filter(|c| Name::of(c).is(CALDAV, "comp"))
.filter_map(|c| c.attributes.get("name"))
.map(|n| n.to_ascii_uppercase())
.collect();
let valid = !comps.is_empty()
&& comps
.iter()
.all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
if valid {
col.components = comps.join(",");
}
valid
}
_ => return None,
})
}
/// Removes one of a collection's own properties. `None` if it is none of
/// them.
fn remove_own(kind: PimKind, col: &mut PimCollection, name: &Name) -> Option<()> {
let cal = kind == PimKind::Calendar;
if cal && name.is(CALDAV, "schedule-calendar-transp") {
col.transparent = false;
return Some(());
}
let field = match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => &mut col.displayname,
(CALDAV, "calendar-description") if cal => &mut col.description,
(CARDDAV, "addressbook-description") if !cal => &mut col.description,
(APPLE, "calendar-color") if cal => &mut col.color,
(APPLE, "calendar-order") if cal => &mut col.sort_order,
(CALDAV, "calendar-timezone") if cal => &mut col.timezone,
_ => return None,
};
*field = None;
Some(())
}
/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
@@ -1466,17 +1704,25 @@ fn is_timezone(v: &str) -> bool {
// ---------------------------------------------------------------------------
impl Cx<'_> {
async fn get(&self, target: &Target, head: bool) -> Reply {
async fn get(&self, target: &Target, headers: &HeaderMap, head: bool) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
return self.get_collection(target, head).await;
};
let found = match self.collection(*kind, slug).await? {
Some(col) => self.member(&col.c, name).await?,
None => None,
};
let Some((o, data)) = found else {
let Some((o, mut data)) = found else {
return Ok(status(StatusCode::NOT_FOUND));
};
if *kind == PimKind::AddressBook {
let accept = headers.get("accept").and_then(|v| v.to_str().ok());
let req = render::AddressData {
props: None,
version: Some(render::accepted_version(accept)),
};
data = render::address_data(&String::from_utf8_lossy(&data), &req).into_bytes();
}
let body = if head {
Body::empty()
} else {
@@ -1495,6 +1741,27 @@ impl Cx<'_> {
Ok(r)
}
/// Clients that discover with GET, as RFC 6764 allows, expect a 2xx on
/// every collection on the way.
async fn get_collection(&self, target: &Target, head: bool) -> Reply {
if let Target::Collection(kind, _, slug) = target
&& !(*kind == PimKind::Calendar && slug == OUTBOX && self.space().mine)
&& self.collection(*kind, slug).await?.is_none()
{
return Ok(status(StatusCode::NOT_FOUND));
}
let body = match head {
true => "",
false => "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n",
};
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
body,
)
.into_response())
}
async fn put(&self, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
▾Mserver/src/api/pim_schedule.rs
@@ -22,8 +22,8 @@ use tokio::sync::Mutex;
use xmltree::Element;
use super::pim::{
INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, need_privilege, principal_name,
principal_uuid, seg,
INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, local_part, need_privilege,
principal_name, principal_uuid, seg,
};
use crate::db::{PimKind, PimObject, PimOp, PimPrincipal};
use crate::error::{ApiError, AppState};
@@ -142,13 +142,9 @@ impl Directory {
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 encoded = local_part(&gone.name);
let tombstone = format!("mailto:{encoded}-{}@deleted.{MAIL_DOMAIN}", 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? {
▾Mserver/src/api/spa.rs
@@ -22,6 +22,33 @@ pub(super) async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Re
if path.starts_with("/api/") {
return (StatusCode::NOT_FOUND, "unknown endpoint").into_response();
}
// CalDAV and CardDAV clients given only the server's address ask it for
// the principal (RFC 6764, 6).
if path == "/" {
match method.as_str() {
"PROPFIND" => {
return (
StatusCode::TEMPORARY_REDIRECT,
[(header::LOCATION, format!("{}/", api_types::PIM))],
)
.into_response();
}
"OPTIONS" => {
return (
StatusCode::OK,
[
(header::ALLOW, "OPTIONS, GET, HEAD, PROPFIND"),
(
header::HeaderName::from_static("dav"),
super::pim::COMPLIANCE,
),
],
)
.into_response();
}
_ => {}
}
}
if method != Method::GET && method != Method::HEAD {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
▾Mserver/src/db.rs
@@ -239,6 +239,40 @@ pub struct PimCollection {
pub seq: i64,
}
/// Where client properties the server does not interpret are kept.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PropPlace {
Principal(i64),
Home(i64, PimKind),
Collection(i64),
}
impl PropPlace {
fn key(self) -> String {
match self {
PropPlace::Principal(id) => format!("principal:{id}"),
PropPlace::Home(id, kind) => format!("home-{}:{id}", kind.as_str()),
PropPlace::Collection(id) => format!("collection:{id}"),
}
}
/// The principal and collection the rows cascade with.
fn owners(self) -> (Option<i64>, Option<i64>) {
match self {
PropPlace::Principal(id) | PropPlace::Home(id, _) => (Some(id), None),
PropPlace::Collection(id) => (None, Some(id)),
}
}
}
/// A client property: its name and the whole property element as XML.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeadProp {
pub ns: String,
pub name: String,
pub xml: String,
}
/// A calendar or address object, without its data.
#[derive(Debug, Clone, Default)]
pub struct PimObject {
@@ -480,6 +514,8 @@ impl Db {
// 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.
// `pim_props` holds the properties clients set that the server
// does not interpret, as XML, per collection, home or principal.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -556,7 +592,17 @@ impl Db {
password_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_pim_links_collection
ON pim_links(collection_id);",
ON pim_links(collection_id);
CREATE TABLE IF NOT EXISTS pim_props (
place TEXT NOT NULL,
principal_id INTEGER REFERENCES principals(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES pim_collections(id) ON DELETE CASCADE,
ns TEXT NOT NULL,
name TEXT NOT NULL,
xml TEXT NOT NULL,
PRIMARY KEY (place, ns, name),
CHECK ((principal_id IS NULL) != (collection_id IS NULL))
);",
)?;
}
conn.execute(
@@ -1528,9 +1574,11 @@ impl Db {
principal_id: i64,
kind: PimKind,
new: &PimCollection,
props: &[DeadProp],
) -> DbResult<bool> {
let c = self.0.lock().await;
let n = c.execute(
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let n = tx.execute(
"INSERT OR IGNORE INTO pim_collections (principal_id, kind, slug, displayname,
description, color, timezone, sort_order, components, transparent, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
@@ -1548,27 +1596,60 @@ impl Db {
now()
],
)?;
Ok(n > 0)
if n == 0 {
return Ok(false);
}
let place = PropPlace::Collection(tx.last_insert_rowid());
write_props(&tx, place, props, &[])?;
tx.commit()?;
Ok(true)
}
/// Writes the properties of `col` and counts it as a change.
pub async fn pim_update_collection(&self, col: &PimCollection) -> DbResult<()> {
/// Writes the properties of `col`, if given, and the client properties of
/// `place` in one transaction. A collection counts it as a change.
pub async fn pim_patch(
&self,
place: PropPlace,
col: Option<&PimCollection>,
set: &[DeadProp],
remove: &[(String, String)],
) -> DbResult<()> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
if let Some(col) = col {
tx.execute(
"UPDATE pim_collections SET displayname = ?2, description = ?3, color = ?4,
timezone = ?5, sort_order = ?6, transparent = ?7, seq = seq + 1
WHERE id = ?1",
params![
col.id,
col.displayname,
col.description,
col.color,
col.timezone,
col.sort_order,
col.transparent
],
)?;
}
write_props(&tx, place, set, remove)?;
tx.commit()?;
Ok(())
}
pub async fn pim_props(&self, place: PropPlace) -> DbResult<Vec<DeadProp>> {
let c = self.0.lock().await;
c.execute(
"UPDATE pim_collections SET displayname = ?2, description = ?3, color = ?4,
timezone = ?5, sort_order = ?6, transparent = ?7, seq = seq + 1
WHERE id = ?1",
params![
col.id,
col.displayname,
col.description,
col.color,
col.timezone,
col.sort_order,
col.transparent
],
let mut stmt = c.prepare_cached(
"SELECT ns, name, xml FROM pim_props WHERE place = ?1 ORDER BY rowid",
)?;
Ok(())
stmt.query_map([place.key()], |r| {
Ok(DeadProp {
ns: r.get(0)?,
name: r.get(1)?,
xml: r.get(2)?,
})
})?
.collect()
}
pub async fn pim_delete_collection(&self, id: i64) -> DbResult<()> {
@@ -2306,6 +2387,30 @@ const PIM_COLLECTION_COLS_C: &str = "c.id, c.slug, c.displayname, c.description,
const PIM_COLLECTION_COLS: &str = "id, slug, displayname, description, color, timezone,
sort_order, components, seq, transparent";
fn write_props(
tx: &rusqlite::Transaction,
place: PropPlace,
set: &[DeadProp],
remove: &[(String, String)],
) -> DbResult<()> {
let key = place.key();
let (principal_id, collection_id) = place.owners();
for (ns, name) in remove {
tx.execute(
"DELETE FROM pim_props WHERE place = ?1 AND ns = ?2 AND name = ?3",
params![key, ns, name],
)?;
}
for p in set {
tx.execute(
"INSERT OR REPLACE INTO pim_props (place, principal_id, collection_id, ns, name, xml)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![key, principal_id, collection_id, p.ns, p.name, p.xml],
)?;
}
Ok(())
}
fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
Ok(PimCollection {
id: r.get(0)?,
▾Mserver/tests/api_dav.rs
@@ -65,7 +65,7 @@ async fn unauthenticated_requests_get_a_basic_challenge() {
// Without the challenge a mount client never offers credentials.
assert_eq!(
r.header("www-authenticate").as_deref(),
Some("Basic realm=\"filebrowser-ng\"")
Some("Basic realm=\"filebrowser-ng\", charset=\"UTF-8\"")
);
}
▾Mserver/tests/api_pim.rs
@@ -173,9 +173,14 @@ async fn discovery() {
hrefs_of(&prop(&ms, p, CARDDAV, "addressbook-home-set").unwrap()),
["/pim/addressbooks/alice/"]
);
let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
assert_eq!(addresses[0], "mailto:alice@filebrowser.invalid");
assert!(addresses[2].starts_with("urn:uuid:"));
// Only the mailto address, preferred, so Apple picks it as the identity.
let set = prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap();
assert_eq!(hrefs_of(&set), ["mailto:alice@filebrowser.invalid"]);
let href = xml::child(&set, DAV, "href").unwrap();
assert_eq!(
href.attributes.get("preferred").map(String::as_str),
Some("1")
);
assert_eq!(
prop_text(&ms, p, DAV, "displayname").as_deref(),
Some(ALICE)
▾Aserver/tests/api_pim_clients.rs
@@ -0,0 +1,458 @@
//! What real clients need beyond the RFCs' core: discovery by GET and from
//! the server root, client properties, vCard 3.0 for Apple, and addresses
//! and logins for unusual account names.
mod common;
use axum::http::{Method, StatusCode};
use common::*;
use pimdav::xml::{self, CALDAV, CALSERVER, CARDDAV, DAV, Name};
use serde_json::json;
use xmltree::Element;
const PW: &str = "secret12345";
async fn req(
env: &Env,
verb: &str,
path: &str,
auth: &str,
extra: &[(&str, &str)],
body: &str,
) -> Resp {
let mut headers = vec![("authorization", auth)];
headers.extend_from_slice(extra);
Client::new(env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&headers,
body.as_bytes().to_vec(),
)
.await
}
async fn setup(names: &[&str]) -> (Env, Client) {
let env = Env::new().await;
let admin = env.admin().await;
for n in names {
create_user(&admin, n, PW, &[]).await;
}
(env, admin)
}
/// The 200 properties of the single response, and its `<d:error>`.
fn props(r: &Resp) -> (Vec<(u16, Element)>, Option<Name>) {
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
let resp = xml::elements(&root).next().unwrap();
let error = xml::child(resp, DAV, "error").and_then(|e| xml::elements(e).next().map(Name::of));
let props = xml::elements(resp)
.filter(|e| Name::of(e).is(DAV, "propstat"))
.flat_map(|ps| {
let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
.split(' ')
.nth(1)
.unwrap()
.parse()
.unwrap();
xml::elements(xml::child(ps, DAV, "prop").unwrap())
.map(move |p| (code, p.clone()))
.collect::<Vec<_>>()
})
.collect();
(props, error)
}
fn find<'a>(props: &'a [(u16, Element)], ns: &str, local: &str) -> Option<&'a (u16, Element)> {
props.iter().find(|(_, p)| Name::of(p).is(ns, local))
}
#[tokio::test]
async fn discovery_by_get_and_from_the_root() {
let (env, _) = setup(&["alice"]).await;
let auth = basic("alice", PW);
for path in [
"/pim/",
"/pim/principals/alice/",
"/pim/calendars/alice/",
"/pim/calendars/alice/default/",
"/pim/addressbooks/alice/system/",
] {
let r = req(&env, "GET", path, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK, "GET {path}");
let r = req(&env, "HEAD", path, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK, "HEAD {path}");
assert!(r.body.is_empty());
}
let r = req(&env, "GET", "/pim/calendars/alice/nope/", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Every response carries the DAV header, the challenge included.
let r = req(&env, "PROPFIND", "/pim/", &auth, &[], "").await;
assert!(r.header("dav").unwrap().contains("calendar-access"));
let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("dav").is_some());
assert!(
r.header("www-authenticate")
.unwrap()
.contains("charset=\"UTF-8\"")
);
// A client given only the server address finds the principal.
let r = req(&env, "PROPFIND", "/", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT);
assert_eq!(r.header("location").as_deref(), Some("/pim/"));
let r = req(&env, "OPTIONS", "/", "", &[], "").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("dav").unwrap().contains("addressbook"));
// The web app is still at the root.
let r = req(&env, "GET", "/", "", &[], "").await;
assert_eq!(r.status, StatusCode::OK);
}
#[tokio::test]
async fn client_properties_are_stored() {
let (env, _) = setup(&["alice", "bob"]).await;
let alice = basic("alice", PW);
let home = "/pim/calendars/alice/";
let book_home = "/pim/addressbooks/alice/";
// macOS Calendar and Contacts store their settings on the homes.
let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<c:default-alarm-vevent-date>BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n</c:default-alarm-vevent-date>\
</d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", home, &alice, &[], patch).await);
assert_eq!(ps[0].0, 200);
let me_card = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:cs=\"http://calendarserver.org/ns/\"><d:set><d:prop>\
<cs:me-card><d:href>/pim/addressbooks/alice/default/me.vcf</d:href></cs:me-card>\
</d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", book_home, &alice, &[], me_card).await);
assert_eq!(ps[0].0, 200);
let custom = "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:note xmlns:x=\"urn:x\">hi</x:note></d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(
&req(
&env,
"PROPPATCH",
"/pim/principals/alice/",
&alice,
&[],
custom,
)
.await,
);
assert_eq!(ps[0].0, 200);
let find_body = |ns: &str, l: &str| {
format!("<d:propfind xmlns:d=\"DAV:\"><d:prop><{l} xmlns=\"{ns}\"/></d:prop></d:propfind>")
};
let r = req(
&env,
"PROPFIND",
home,
&alice,
&[("depth", "0")],
&find_body(CALDAV, "default-alarm-vevent-date"),
)
.await;
let (ps, _) = props(&r);
let (code, p) = find(&ps, CALDAV, "default-alarm-vevent-date").unwrap();
assert_eq!(*code, 200);
// XML parsing made the raw CRLF of the request LF (XML 1.0, 2.11).
assert_eq!(
p.get_text().unwrap(),
"BEGIN:VALARM\nTRIGGER:-PT15M\nEND:VALARM\n"
);
let r = req(
&env,
"PROPFIND",
book_home,
&alice,
&[("depth", "0")],
&find_body(CALSERVER, "me-card"),
)
.await;
let (ps, _) = props(&r);
let href = xml::child(&find(&ps, CALSERVER, "me-card").unwrap().1, DAV, "href").map(xml::text);
assert_eq!(
href.as_deref(),
Some("/pim/addressbooks/alice/default/me.vcf")
);
let r = req(
&env,
"PROPFIND",
"/pim/principals/alice/",
&alice,
&[("depth", "0")],
"",
)
.await;
let (ps, _) = props(&r);
assert_eq!(xml::text(&find(&ps, "urn:x", "note").unwrap().1), "hi");
// A computed property is refused by name, and nothing else is stored.
let mixed = "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:a xmlns:x=\"urn:x\">1</x:a>\
<d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>";
let r = req(&env, "PROPPATCH", home, &alice, &[], mixed).await;
let (ps, error) = props(&r);
let codes: Vec<u16> = ps.iter().map(|(c, _)| *c).collect();
assert_eq!(codes, [424, 403]);
assert!(error.unwrap().is(DAV, "cannot-modify-protected-property"));
let r = req(
&env,
"PROPFIND",
home,
&alice,
&[("depth", "0")],
&find_body("urn:x", "a"),
)
.await;
assert_eq!(find(&props(&r).0, "urn:x", "a").unwrap().0, 404);
// Unknown properties no longer fail a collection's PROPPATCH or MKCALENDAR.
let cal = "/pim/calendars/alice/default/";
let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:i=\"http://apple.com/ns/ical/\"><d:set><d:prop>\
<i:calendar-color>#ff0000</i:calendar-color><x:foo xmlns:x=\"urn:x\">bar</x:foo></d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], patch).await);
assert!(ps.iter().all(|(c, _)| *c == 200), "{ps:?}");
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
let (ps, _) = props(&r);
assert_eq!(
xml::text(
&find(&ps, "http://apple.com/ns/ical/", "calendar-color")
.unwrap()
.1
),
"#ff0000"
);
assert_eq!(xml::text(&find(&ps, "urn:x", "foo").unwrap().1), "bar");
let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<d:displayname>Old iCal</d:displayname>\
<c:calendar-free-busy-set><d:href>/pim/calendars/alice/old/</d:href></c:calendar-free-busy-set>\
</d:prop></d:set></c:mkcalendar>";
let r = req(
&env,
"MKCALENDAR",
"/pim/calendars/alice/old/",
&alice,
&[],
mk,
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(
&env,
"PROPFIND",
"/pim/calendars/alice/old/",
&alice,
&[("depth", "0")],
"",
)
.await;
assert!(find(&props(&r).0, CALDAV, "calendar-free-busy-set").is_some());
// Removing works, and an oversized value is refused.
let remove = "<d:propertyupdate xmlns:d=\"DAV:\"><d:remove><d:prop><x:foo xmlns:x=\"urn:x\"/></d:prop></d:remove></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], remove).await);
assert_eq!(ps[0].0, 200);
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
assert!(find(&props(&r).0, "urn:x", "foo").is_none());
let big = format!(
"<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:big xmlns:x=\"urn:x\">{}</x:big></d:prop></d:set></d:propertyupdate>",
"a".repeat(70_000)
);
let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], &big).await);
assert_eq!(ps[0].0, 507);
// A borrower reads the owner's properties and cannot change them.
let cid = alice_calendar_id(&env).await;
let alice_client = login(&env, "alice", PW).await;
let r = alice_client
.post_json(
&format!("/api/pim/collections/{cid}/shares"),
&json!({"user": "bob", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let bob = basic("bob", PW);
let lent = format!("/pim/calendars/bob/shared-{cid}/");
let r = req(&env, "PROPFIND", &lent, &bob, &[("depth", "0")], "").await;
assert_eq!(
xml::text(
&find(&props(&r).0, "http://apple.com/ns/ical/", "calendar-color")
.unwrap()
.1
),
"#ff0000"
);
let r = req(&env, "PROPPATCH", &lent, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// And another account's home is not writable.
let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
async fn alice_calendar_id(env: &Env) -> i64 {
let alice = login(env, "alice", PW).await;
let r = alice.get("/api/pim/collections").await;
r.json()
.as_array()
.unwrap()
.iter()
.find(|c| {
c["kind"] == "calendar"
&& c["url"]
.as_str()
.is_some_and(|u| u.ends_with("/calendars/alice/default/"))
})
.unwrap()["id"]
.as_i64()
.unwrap()
}
#[tokio::test]
async fn vcard_three_for_apple() {
let (env, _) = setup(&["alice"]).await;
let auth = basic("alice", PW);
let book = "/pim/addressbooks/alice/default/";
let r = req(&env, "PROPFIND", book, &auth, &[("depth", "0")], "").await;
let (ps, _) = props(&r);
let data = &find(&ps, CARDDAV, "supported-address-data").unwrap().1;
let versions: Vec<_> = xml::elements(data)
.filter_map(|e| e.attributes.get("version"))
.collect();
assert_eq!(versions, ["3.0"]);
// A vCard 4.0 group, as DAVx5 writes it, is stored as sent.
let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g1\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nEND:VCARD\r\n";
let path = format!("{book}g1.vcf");
let r = req(&env, "PUT", &path, &auth, &[], group).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let etag = r.header("etag").unwrap();
// Without Accept, 3.0 with the forms Apple reads; the ETag stays.
let r = req(&env, "GET", &path, &auth, &[], "").await;
let text = r.text();
assert!(
text.contains("VERSION:3.0") && text.contains("X-ADDRESSBOOKSERVER-KIND:group"),
"{text}"
);
assert!(
text.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:c1"),
"{text}"
);
assert_eq!(r.header("etag").as_deref(), Some(etag.as_str()));
let r = req(
&env,
"GET",
&path,
&auth,
&[("accept", "text/vcard; version=4.0")],
"",
)
.await;
assert_eq!(r.text(), group);
let multiget = format!(
r#"<card:addressbook-multiget xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:address-data/></d:prop><d:href>{path}</d:href></card:addressbook-multiget>"#
);
let r = req(&env, "REPORT", book, &auth, &[], &multiget).await;
assert!(
r.text().contains("X-ADDRESSBOOKSERVER-KIND:group"),
"{}",
r.text()
);
let v4 = multiget.replace(
"<card:address-data/>",
"<card:address-data version=\"4.0\"/>",
);
let r = req(&env, "REPORT", book, &auth, &[], &v4).await;
assert!(
r.text().contains("MEMBER:urn:uuid:c1") && !r.text().contains("X-ADDRESSBOOK"),
"{}",
r.text()
);
}
#[tokio::test]
async fn addresses_and_logins_for_unusual_names() {
let (env, _) = setup(&["alice", "marc@example.com", "a..b"]).await;
let alice = basic("alice", PW);
let marc = basic("marc@example.com", PW);
let address = |env: &Env, auth: String, user: &'static str| {
let app = env.app.clone();
async move {
let r = Client::new(app)
.raw(
Method::from_bytes(b"PROPFIND").unwrap(),
&format!("/pim/principals/{user}/"),
&[("authorization", auth.as_str()), ("depth", "0")],
Vec::new(),
)
.await;
let (ps, _) = props(&r);
let set = &find(&ps, CALDAV, "calendar-user-address-set").unwrap().1;
xml::text(xml::elements(set).next().unwrap())
}
};
// One `@` and only characters valid in a local part.
let m = address(&env, marc.clone(), "marc@example.com").await;
assert_eq!(m, "mailto:marc%40example.com@filebrowser.invalid");
let dots = address(&env, basic("a..b", PW), "a..b").await;
assert_eq!(dots, "mailto:a%2E%2Eb@filebrowser.invalid");
// An invitation to that address reaches the account.
let ics = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:m1\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20261001T100000Z\r\nDTEND:20261001T110000Z\r\nSUMMARY:Meet\r\n\
ORGANIZER:mailto:alice@filebrowser.invalid\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@filebrowser.invalid\r\n\
ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:{m}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let r = req(
&env,
"PUT",
"/pim/calendars/alice/default/m1.ics",
&alice,
&[],
&ics,
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(
&env,
"PROPFIND",
"/pim/calendars/marc@example.com/inbox/",
&marc,
&[("depth", "1")],
"",
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let root = Element::parse(r.body.as_slice()).unwrap();
assert_eq!(xml::elements(&root).count(), 2, "{}", r.text());
// iOS sends `@` in the Basic user name as `%40`.
let r = req(
&env,
"PROPFIND",
"/pim/",
&basic("marc%40example.com", PW),
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let r = req(
&env,
"PROPFIND",
"/pim/",
&basic("marc%40example.com", "wrong"),
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
}