CalDAV/CardDAV core: storage, discovery, collections, objects
- /pim tree: principals, calendar and address book homes, collections, objects; /.well-known/caldav and /carddav redirect there - SQLite schema v12: collections, objects, per-member change log with tombstones; seq feeds CTag and sync token - PROPFIND (depth 0/1, allprop/propname/prop), PROPPATCH (atomic), MKCALENDAR and extended MKCOL with initial properties - GET/HEAD/PUT/DELETE with If-Match/If-None-Match and the CalDAV and CardDAV preconditions, checked inside the write transaction - Default calendar and address book per user; Basic auth reuses the WebDAV path (password, app passwords, session cookie) - pimdav: xml (request parsing, multistatus/error writer) and object (iCalendar/vCard PUT validation) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MCargo.lock
@@ -2316,6 +2316,7 @@ dependencies = [
"chrono",
"chrono-tz",
"rrule",
"xmltree",
]
[[package]]
@@ -2907,6 +2908,8 @@ dependencies = [
"infer",
"mime_guess",
"multer",
"percent-encoding",
"pimdav",
"rusqlite",
"rust-embed",
"serde",
Mapi-types/src/lib.rs
@@ -54,6 +54,14 @@ pub const DAV: &str = "/dav";
/// segment is a root's display name, which a reserved word could collide with.
pub const DAV_SHARE: &str = "/dav-share";
/// CalDAV and CardDAV: principals, calendars and address books.
///
/// Not under [`DAV`] for the same reason as [`DAV_SHARE`].
pub const PIM: &str = "/pim";
/// RFC 6764 discovery. Both redirect to [`PIM`].
pub const WELL_KNOWN_CALDAV: &str = "/.well-known/caldav";
pub const WELL_KNOWN_CARDDAV: &str = "/.well-known/carddav";
/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
pub const ADMIN_USERS: &str = "/api/admin/users";
/// Admin view of every share on the server: `{ADMIN_SHARES}` and
Mpimdav/Cargo.toml
@@ -14,3 +14,4 @@ chrono-tz = "0.10"
# Wall-clock RRULE iteration only. Time zones, UNTIL, overrides, RDATE and
# EXDATE are handled in `expand`.
rrule = "0.14"
xmltree = "0.12"
Mpimdav/src/lib.rs
@@ -2,6 +2,8 @@
//! this crate computes on it.
pub mod expand;
pub mod object;
pub mod xml;
pub mod zone;
pub use calcard;
Apimdav/src/object.rs
@@ -0,0 +1,106 @@
//! Validation of the calendar and address objects clients PUT.
use calcard::icalendar::{ICalendarComponentType, ICalendarProperty};
use calcard::{Entry, Parser};
use xmltree::Element;
use crate::xml::{CALDAV, CARDDAV, el};
/// Why a PUT body is refused, as the precondition the RFCs name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Invalid {
/// Not parseable as iCalendar.
CalendarData,
/// Parseable, but not one CalDAV object: several UIDs, mixed component
/// types, a METHOD, or no component at all.
CalendarResource,
/// A component type the collection does not take.
CalendarComponent,
/// Not parseable as one vCard.
AddressData,
}
impl Invalid {
pub fn condition(self) -> Element {
match self {
Invalid::CalendarData => el(CALDAV, "valid-calendar-data"),
Invalid::CalendarResource => el(CALDAV, "valid-calendar-object-resource"),
Invalid::CalendarComponent => el(CALDAV, "supported-calendar-component"),
Invalid::AddressData => el(CARDDAV, "valid-address-data"),
}
}
}
/// What the store needs to know about a valid calendar object.
#[derive(Debug, PartialEq, Eq)]
pub struct CalendarObject {
pub uid: String,
/// `VEVENT`, `VTODO` or `VJOURNAL`.
pub component: &'static str,
}
/// Checks a calendar object resource (RFC 4791, 4.1). `supported` lists the
/// component types the collection takes.
pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::CalendarData)?;
let mut parser = Parser::new(text);
let Entry::ICalendar(cal) = parser.entry() else {
return Err(Invalid::CalendarData);
};
if !matches!(parser.entry(), Entry::Eof) {
return Err(Invalid::CalendarResource);
}
let root = cal.components.first().ok_or(Invalid::CalendarData)?;
if root.component_type != ICalendarComponentType::VCalendar {
return Err(Invalid::CalendarData);
}
if root.has_property(&ICalendarProperty::Method) {
return Err(Invalid::CalendarResource);
}
let mut found: Option<CalendarObject> = None;
for c in root
.component_ids
.iter()
.filter_map(|&id| cal.components.get(id as usize))
{
let component = match c.component_type {
ICalendarComponentType::VTimezone => continue,
ICalendarComponentType::VEvent => "VEVENT",
ICalendarComponentType::VTodo => "VTODO",
ICalendarComponentType::VJournal => "VJOURNAL",
_ => return Err(Invalid::CalendarComponent),
};
let uid = c.uid().ok_or(Invalid::CalendarResource)?;
match &found {
Some(f) if f.uid != uid || f.component != component => {
return Err(Invalid::CalendarResource);
}
Some(_) => {}
None => {
found = Some(CalendarObject {
uid: uid.to_string(),
component,
})
}
}
}
let found = found.ok_or(Invalid::CalendarResource)?;
if !supported.contains(&found.component) {
return Err(Invalid::CalendarComponent);
}
Ok(found)
}
/// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A
/// card without one is accepted: several clients omit it.
pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?;
let mut parser = Parser::new(text);
let Entry::VCard(card) = parser.entry() else {
return Err(Invalid::AddressData);
};
if !matches!(parser.entry(), Entry::Eof) {
return Err(Invalid::AddressData);
}
Ok(card.uid().map(str::to_string))
}
Apimdav/src/xml.rs
@@ -0,0 +1,307 @@
//! WebDAV XML: request bodies into typed values, and response bodies out.
use std::fmt::Write as _;
use xmltree::{Element, XMLNode};
pub const DAV: &str = "DAV:";
pub const CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
pub const CARDDAV: &str = "urn:ietf:params:xml:ns:carddav";
pub const CALSERVER: &str = "http://calendarserver.org/ns/";
pub const APPLE: &str = "http://apple.com/ns/ical/";
/// Prefixes declared once on every response root.
const PREFIXES: [(&str, &str); 5] = [
("d", DAV),
("c", CALDAV),
("card", CARDDAV),
("cs", CALSERVER),
("ical", APPLE),
];
/// A property or element name.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Name {
pub ns: String,
pub local: String,
}
impl Name {
pub fn new(ns: &str, local: &str) -> Self {
Name {
ns: ns.to_string(),
local: local.to_string(),
}
}
pub fn of(e: &Element) -> Self {
Name {
ns: e.namespace.clone().unwrap_or_default(),
local: e.name.clone(),
}
}
pub fn is(&self, ns: &str, local: &str) -> bool {
self.ns == ns && self.local == local
}
/// An element with this name, to be filled with a value.
pub fn element(&self) -> Element {
el(&self.ns, &self.local)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invalid;
#[derive(Debug, PartialEq)]
pub enum Propfind {
/// With the names an `include` element adds.
AllProp(Vec<Name>),
PropName,
Prop(Vec<Name>),
}
/// A PROPFIND body. An empty body means `allprop`.
pub fn propfind(body: &[u8]) -> Result<Propfind, Invalid> {
if body.iter().all(u8::is_ascii_whitespace) {
return Ok(Propfind::AllProp(Vec::new()));
}
let root = parse(body, DAV, "propfind")?;
let names = |e: &Element| elements(e).map(Name::of).collect();
for e in elements(&root) {
match (e.namespace.as_deref(), e.name.as_str()) {
(Some(DAV), "prop") => return Ok(Propfind::Prop(names(e))),
(Some(DAV), "propname") => return Ok(Propfind::PropName),
(Some(DAV), "allprop") => {
let include = child(&root, DAV, "include").map_or_else(Vec::new, names);
return Ok(Propfind::AllProp(include));
}
_ => {}
}
}
Err(Invalid)
}
/// Properties to set and remove, from a PROPPATCH, MKCALENDAR or extended
/// MKCOL body. An empty body sets nothing.
#[derive(Debug, Default)]
pub struct Update {
/// Property elements with their values.
pub set: Vec<Element>,
pub remove: Vec<Name>,
}
pub fn update(body: &[u8]) -> Result<Update, Invalid> {
let mut out = Update::default();
if body.iter().all(u8::is_ascii_whitespace) {
return Ok(out);
}
let root = Element::parse(body).map_err(|_| Invalid)?;
let expected = [
(DAV, "propertyupdate"),
(CALDAV, "mkcalendar"),
(DAV, "mkcol"),
];
if !expected.iter().any(|(ns, n)| Name::of(&root).is(ns, n)) {
return Err(Invalid);
}
for op in elements(&root) {
let props = child(op, DAV, "prop").into_iter().flat_map(elements);
match (op.namespace.as_deref(), op.name.as_str()) {
(Some(DAV), "set") => out.set.extend(props.cloned()),
(Some(DAV), "remove") => out.remove.extend(props.map(Name::of)),
_ => {}
}
}
Ok(out)
}
fn parse(body: &[u8], ns: &str, local: &str) -> Result<Element, Invalid> {
let root = Element::parse(body).map_err(|_| Invalid)?;
if Name::of(&root).is(ns, local) {
Ok(root)
} else {
Err(Invalid)
}
}
pub fn elements(e: &Element) -> impl Iterator<Item = &Element> {
e.children.iter().filter_map(XMLNode::as_element)
}
pub fn child<'a>(e: &'a Element, ns: &str, local: &str) -> Option<&'a Element> {
elements(e).find(|c| Name::of(c).is(ns, local))
}
/// The concatenated text content, trimmed.
pub fn text(e: &Element) -> String {
e.get_text()
.map_or_else(String::new, |t| t.trim().to_string())
}
pub fn el(ns: &str, local: &str) -> Element {
let mut e = Element::new(local);
e.namespace = Some(ns.to_string());
e
}
pub fn with_text(mut e: Element, text: impl Into<String>) -> Element {
e.children.push(XMLNode::Text(text.into()));
e
}
pub fn with_children(mut e: Element, children: impl IntoIterator<Item = Element>) -> Element {
e.children
.extend(children.into_iter().map(XMLNode::Element));
e
}
pub fn with_attr(mut e: Element, name: &str, value: &str) -> Element {
e.attributes.insert(name.to_string(), value.to_string());
e
}
/// `<d:href>` elements.
pub fn hrefs<'a>(hrefs: impl IntoIterator<Item = &'a str>) -> Vec<Element> {
hrefs
.into_iter()
.map(|h| with_text(el(DAV, "href"), h))
.collect()
}
/// One `<d:response>` of a multistatus.
#[derive(Debug, Default)]
pub struct Response {
pub href: String,
/// Status code and the properties that share it.
pub propstats: Vec<(u16, Vec<Element>)>,
}
impl Response {
pub fn new(href: impl Into<String>) -> Self {
Response {
href: href.into(),
propstats: Vec::new(),
}
}
/// Adds `prop` under `status`, next to the others with that status.
pub fn push(&mut self, status: u16, prop: Element) {
match self.propstats.iter_mut().find(|(s, _)| *s == status) {
Some((_, props)) => props.push(prop),
None => self.propstats.push((status, vec![prop])),
}
}
}
/// A `<d:multistatus>` body, or another root such as
/// `<c:mkcalendar-response>` holding the same propstats.
pub fn multistatus(root: &Name, responses: &[Response]) -> String {
let body = responses.iter().map(|r| {
let mut children = vec![with_text(el(DAV, "href"), r.href.as_str())];
children.extend(
r.propstats
.iter()
.map(|(status, props)| propstat(*status, props)),
);
with_children(el(DAV, "response"), children)
});
let root = with_children(root.element(), body);
document(&root)
}
/// The propstats alone, for roots that hold them without `<d:response>`.
pub fn propstat_document(root: &Name, propstats: &[(u16, Vec<Element>)]) -> String {
let root = with_children(
root.element(),
propstats.iter().map(|(s, p)| propstat(*s, p)),
);
document(&root)
}
fn propstat(status: u16, props: &[Element]) -> Element {
with_children(
el(DAV, "propstat"),
[
with_children(el(DAV, "prop"), props.iter().cloned()),
with_text(el(DAV, "status"), status_line(status)),
],
)
}
/// A `<d:error>` body naming the failed precondition.
pub fn error(condition: Element) -> String {
document(&with_children(el(DAV, "error"), [condition]))
}
pub fn status_line(code: u16) -> String {
let reason = match code {
200 => "OK",
201 => "Created",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
424 => "Failed Dependency",
507 => "Insufficient Storage",
_ => "",
};
format!("HTTP/1.1 {code} {reason}")
}
pub fn document(root: &Element) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
write(&mut out, root, true);
out
}
/// Known namespaces use the fixed prefixes. Any other element declares its
/// namespace as the default on itself.
fn write(out: &mut String, e: &Element, root: bool) {
let ns = e.namespace.as_deref().unwrap_or("");
let tag = match PREFIXES.iter().find(|(_, uri)| *uri == ns) {
Some((p, _)) => format!("{p}:{}", e.name),
None => e.name.clone(),
};
let _ = write!(out, "<{tag}");
if !tag.contains(':') {
let _ = write!(out, " xmlns=\"{}\"", escape(ns));
}
if root {
for (p, uri) in PREFIXES {
let _ = write!(out, " xmlns:{p}=\"{uri}\"");
}
}
let mut attrs: Vec<_> = e.attributes.iter().collect();
attrs.sort();
for (k, v) in attrs {
let _ = write!(out, " {k}=\"{}\"", escape(v));
}
if e.children.is_empty() {
out.push_str("/>");
return;
}
out.push('>');
for c in &e.children {
match c {
XMLNode::Element(c) => write(out, c, false),
XMLNode::Text(t) | XMLNode::CData(t) => out.push_str(&escape(t)),
_ => {}
}
}
let _ = write!(out, "</{tag}>");
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
Apimdav/tests/protocol.rs
@@ -0,0 +1,161 @@
//! XML request parsing, response building, and PUT body validation.
use pimdav::object::{self, CalendarObject, Invalid};
use pimdav::xml::{self, CALDAV, DAV, Name, Propfind};
use xmltree::Element;
#[test]
fn propfind_bodies() {
assert_eq!(xml::propfind(b""), Ok(Propfind::AllProp(vec![])));
let body = br#"<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:x="urn:x">
<d:prop><d:getetag/><x:odd/></d:prop>
</d:propfind>"#;
assert_eq!(
xml::propfind(body),
Ok(Propfind::Prop(vec![
Name::new(DAV, "getetag"),
Name::new("urn:x", "odd")
]))
);
let body = br#"<propfind xmlns="DAV:"><allprop/><include><getetag/></include></propfind>"#;
assert_eq!(
xml::propfind(body),
Ok(Propfind::AllProp(vec![Name::new(DAV, "getetag")]))
);
let body = br#"<propfind xmlns="DAV:"><propname/></propfind>"#;
assert_eq!(xml::propfind(body), Ok(Propfind::PropName));
assert!(xml::propfind(b"<nope/>").is_err());
assert!(xml::propfind(b"<propfind").is_err());
}
#[test]
fn update_bodies() {
let body = br#"<d:propertyupdate xmlns:d="DAV:" xmlns:i="http://apple.com/ns/ical/">
<d:set><d:prop><d:displayname>Work</d:displayname><i:calendar-color>#ff0000</i:calendar-color></d:prop></d:set>
<d:remove><d:prop><d:displayname/></d:prop></d:remove>
</d:propertyupdate>"#;
let u = xml::update(body).unwrap();
assert_eq!(u.set.len(), 2);
assert_eq!(xml::text(&u.set[0]), "Work");
assert_eq!(u.remove, vec![Name::new(DAV, "displayname")]);
let body = br#"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop><c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set></d:prop></d:set>
</c:mkcalendar>"#;
let u = xml::update(body).unwrap();
assert!(Name::of(&u.set[0]).is(CALDAV, "supported-calendar-component-set"));
assert!(xml::update(b"").unwrap().set.is_empty());
assert!(xml::update(b"<d:other xmlns:d=\"DAV:\"/>").is_err());
}
#[test]
fn multistatus_round_trips() {
let mut r = xml::Response::new("/a b/");
r.push(
200,
xml::with_text(xml::el(DAV, "displayname"), "x < y & \"z\""),
);
r.push(404, Name::new("urn:x", "odd").element());
r.push(
200,
xml::with_attr(xml::el(CALDAV, "comp"), "name", "VEVENT"),
);
let out = xml::multistatus(&Name::new(DAV, "multistatus"), &[r]);
let root = Element::parse(out.as_bytes()).unwrap();
assert!(Name::of(&root).is(DAV, "multistatus"));
let response = xml::child(&root, DAV, "response").unwrap();
assert_eq!(
xml::text(xml::child(response, DAV, "href").unwrap()),
"/a b/"
);
let stats: Vec<_> = xml::elements(response)
.filter(|e| e.name == "propstat")
.collect();
assert_eq!(stats.len(), 2);
let ok = xml::child(stats[0], DAV, "prop").unwrap();
assert_eq!(
xml::text(xml::child(ok, DAV, "displayname").unwrap()),
"x < y & \"z\""
);
assert_eq!(
xml::child(ok, CALDAV, "comp").unwrap().attributes["name"],
"VEVENT"
);
let missing = xml::child(stats[1], DAV, "prop").unwrap();
assert!(xml::child(missing, "urn:x", "odd").is_some());
assert_eq!(
xml::text(xml::child(stats[1], DAV, "status").unwrap()),
"HTTP/1.1 404 Not Found"
);
}
fn ics(body: &str) -> Vec<u8> {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
.into_bytes()
}
const EVENT: &str = "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nEND:VEVENT\r\n";
#[test]
fn calendar_objects() {
let all = ["VEVENT", "VTODO"];
assert_eq!(
object::calendar(&ics(EVENT), &all),
Ok(CalendarObject {
uid: "a".into(),
component: "VEVENT"
})
);
let override_ = EVENT.replace("DTSTART", "RECURRENCE-ID:20260102T100000Z\r\nDTSTART");
assert!(object::calendar(&ics(&format!("{EVENT}{override_}")), &all).is_ok());
let cases = [
(
ics(&format!("METHOD:REQUEST\r\n{EVENT}")),
Invalid::CalendarResource,
),
(
ics(&format!("{EVENT}{}", EVENT.replace("UID:a", "UID:b"))),
Invalid::CalendarResource,
),
(
ics(&format!("{EVENT}{}", EVENT.replace("VEVENT", "VTODO"))),
Invalid::CalendarResource,
),
(
ics(&EVENT.replace("UID:a\r\n", "")),
Invalid::CalendarResource,
),
(ics(""), Invalid::CalendarResource),
(
ics(&EVENT.replace("VEVENT", "VJOURNAL")),
Invalid::CalendarComponent,
),
(b"not a calendar".to_vec(), Invalid::CalendarData),
(vec![0xff, 0xfe], Invalid::CalendarData),
([ics(EVENT), ics(EVENT)].concat(), Invalid::CalendarResource),
];
for (body, want) in cases {
assert_eq!(
object::calendar(&body, &all),
Err(want),
"{}",
String::from_utf8_lossy(&body)
);
}
}
#[test]
fn vcards() {
let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:c1\r\nFN:Bob\r\nEND:VCARD\r\n";
assert_eq!(object::vcard(card.as_bytes()), Ok(Some("c1".into())));
let no_uid = card.replace("UID:c1\r\n", "");
assert_eq!(object::vcard(no_uid.as_bytes()), Ok(None));
assert_eq!(object::vcard(&ics(EVENT)), Err(Invalid::AddressData));
assert_eq!(
object::vcard(format!("{card}{card}").as_bytes()),
Err(Invalid::AddressData)
);
}
Mserver/Cargo.toml
@@ -10,6 +10,7 @@ path = "src/main.rs"
[dependencies]
api-types = { path = "../api-types" }
pimdav = { path = "../pimdav" }
anyhow = "1"
argon2 = "0.6"
axum = "0.8"
@@ -39,6 +40,8 @@ webp = "0.3"
# detection we do not need) and makes this a zero-dependency crate.
infer = { version = "0.22", default-features = false, features = ["alloc"] }
mime_guess = "2"
# CalDAV hrefs. Already in the tree via `url`.
percent-encoding = "2"
multer = "3"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
Mserver/src/api/dav.rs
@@ -56,9 +56,11 @@ const REALM: &str = "filebrowser-ng";
/// A browser session cookie is accepted, but the usual caller is a mount
/// client, which only speaks HTTP Basic.
pub async fn user(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
let (principal, roots) = match authenticate(&state, req.headers()).await {
Some(v) => v,
None => return challenge(),
let Some((user_id, principal)) = authenticate(&state, req.headers()).await else {
return challenge();
};
let Ok(roots) = state.db.user_roots(user_id).await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
// The admin pseudo-root (the whole server root, read-only) is deliberately
// not mounted: `session_auth` does not add it, and a mount that silently
@@ -246,7 +248,7 @@ fn dav_target(
/// 401 with the Basic challenge every mount client needs to see before it
/// will send credentials at all.
fn challenge() -> Response<Body> {
pub(crate) fn challenge() -> Response<Body> {
(
StatusCode::UNAUTHORIZED,
[(WWW_AUTHENTICATE, format!("Basic realm=\"{REALM}\""))],
@@ -258,14 +260,14 @@ fn challenge() -> Response<Body> {
// Authentication
// ---------------------------------------------------------------------------
/// Resolve the caller to a principal name and the roots they may mount.
async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String, Vec<RootRow>)> {
/// Resolve the caller to a user id and name.
pub(crate) async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(i64, String)> {
// A browser hitting the mount already has a session; take it and skip
// Argon2 entirely.
if auth::parse_session_cookie(headers).is_some()
&& let Ok((user, roots)) = session_auth(headers, state).await
&& let Ok((user, _)) = session_auth(headers, state).await
{
return Some((user.name, roots));
return Some((user.id, user.name));
}
let (name, password) = auth::basic_credentials(headers)?;
@@ -276,10 +278,7 @@ async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String,
.user_by_app_password(&auth::app_password_hash(&password))
.await
{
Ok(Some(user)) => {
let roots = state.db.user_roots(user.id).await.ok()?;
return Some((user.name, roots));
}
Ok(Some(user)) => return Some((user.id, user.name)),
Ok(None) => {}
// A lookup error 401s a valid app password and counts as a failed
// login for that name below. Nothing else records that.
@@ -308,8 +307,7 @@ async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String,
}
})
.await?;
let roots = state.db.user_roots(id).await.ok()?;
Some((name, roots))
Some((id, name))
}
// ---------------------------------------------------------------------------
Mserver/src/api/mod.rs
@@ -3,7 +3,8 @@ use std::sync::Arc;
use api_types::{
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, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
AUTH_SETUP, DAV, DAV_SHARE, FILES, FINISH_SUFFIX, PIM, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX,
SHARES, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -92,6 +93,7 @@ pub(crate) mod common;
mod dav;
mod files;
mod passkeys;
mod pim;
mod search;
mod shares;
mod spa;
@@ -116,6 +118,8 @@ pub fn router(state: Arc<AppState>) -> Router {
let dav_root = format!("{DAV}/");
let dav_item = format!("{DAV}/{{*path}}");
let dav_share = format!("{DAV_SHARE}/{{*path}}");
let pim_root = format!("{PIM}/");
let pim_item = format!("{PIM}/{{*path}}");
Router::new()
.route(AUTH_LOGIN, post(auth::login))
@@ -174,6 +178,11 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(&dav_root, any(dav::user))
.route(&dav_item, any(dav::user))
.route(&dav_share, any(dav::share))
.route(WELL_KNOWN_CALDAV, any(pim::well_known))
.route(WELL_KNOWN_CARDDAV, any(pim::well_known))
.route(PIM, any(pim::handle))
.route(&pim_root, any(pim::handle))
.route(&pim_item, any(pim::handle))
.fallback(spa::fallback)
// The editor save body is checked against `MAX_TEXT_BYTES` in the
// handler; axum's default limit is the same 2 MiB, which would win
Aserver/src/api/pim.rs
@@ -0,0 +1,838 @@
//! CalDAV and CardDAV.
//!
//! URL layout under [`PIM`]:
//!
//! * `/principals/{user}/`
//! * `/calendars/{user}/` and `/addressbooks/{user}/`, the homes
//! * `/calendars/{user}/{collection}/` and `.../{collection}/{object}`, the
//! same for address books
//!
//! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto
//! the store and assembles the responses.
use std::sync::Arc;
use api_types::PIM;
use axum::body::Body;
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 pimdav::object;
use pimdav::xml::{
self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr,
with_children, with_text,
};
use sha2::{Digest, Sha256};
use xmltree::Element;
use crate::db::{PimCollection, PimKind, PimObject, PimWrite, Precondition};
use crate::error::{ApiError, AppState};
/// Largest object a PUT may store. Contacts carry photos inline.
const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
/// Largest XML request body.
const MAX_XML_SIZE: usize = 1024 * 1024;
/// The domain of the addresses users schedule with. `.invalid` is reserved
/// (RFC 2606), so nothing sent there can reach anyone.
const MAIL_DOMAIN: &str = "filebrowser.invalid";
/// Characters escaped in an href segment.
const SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'`')
.add(b'{')
.add(b'}');
type Reply = Result<Response<Body>, ApiError>;
/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
pub async fn well_known() -> Response<Body> {
(
StatusCode::MOVED_PERMANENTLY,
[(LOCATION, format!("{PIM}/"))],
)
.into_response()
}
/// `{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();
};
serve(&state, user_id, req)
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// The signed-in user.
struct Me {
id: i64,
name: String,
}
impl Me {
fn principal(&self) -> String {
format!("{PIM}/principals/{}/", seg(&self.name))
}
fn home(&self, kind: PimKind) -> String {
format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.name))
}
fn collection(&self, kind: PimKind, slug: &str) -> String {
format!("{}{}/", self.home(kind), seg(slug))
}
fn object(&self, kind: PimKind, slug: &str, name: &str) -> String {
format!("{}{}", self.collection(kind, slug), seg(name))
}
}
async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
let Some(name) = state.db.user_name(user_id).await? else {
return Ok(status(StatusCode::UNAUTHORIZED));
};
let me = Me { id: user_id, name };
let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
let Some(target) = parse_target(path) else {
return Ok(status(StatusCode::NOT_FOUND));
};
// ponytail: own resources only. Sharing between users comes with the
// access model.
if target
.owner()
.is_some_and(|o| !o.eq_ignore_ascii_case(&me.name))
{
return Ok(status(StatusCode::FORBIDDEN));
}
state.db.pim_ensure_defaults(me.id).await?;
let method = req.method().clone();
let (parts, body) = req.into_parts();
match method.as_str() {
"OPTIONS" => Ok(options()),
"PROPFIND" => propfind(state, &me, &target, &parts.headers, body).await,
"PROPPATCH" => proppatch(state, &me, &target, body).await,
"MKCALENDAR" | "MKCOL" => mkcol(state, &me, &target, method.as_str(), body).await,
"GET" | "HEAD" => get(state, &me, &target, method == Method::HEAD).await,
"PUT" => put(state, &me, &target, &parts.headers, body).await,
"DELETE" => delete(state, &me, &target, &parts.headers).await,
"REPORT" => Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report"))),
_ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
}
}
#[derive(Debug)]
enum Target {
Root,
Principal(String),
Home(PimKind, String),
Collection(PimKind, String, String),
Object(PimKind, String, String, String),
}
impl Target {
fn owner(&self) -> Option<&str> {
match self {
Target::Root => None,
Target::Principal(u)
| Target::Home(_, u)
| Target::Collection(_, u, _)
| Target::Object(_, u, _, _) => Some(u),
}
}
}
fn parse_target(path: &str) -> Option<Target> {
let segs = path
.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
let s = percent_decode_str(s).decode_utf8().ok()?;
(s != "." && s != "..").then(|| s.into_owned())
})
.collect::<Option<Vec<_>>>()?;
let kind = |s: &str| match s {
"calendars" => Some(PimKind::Calendar),
"addressbooks" => Some(PimKind::AddressBook),
_ => None,
};
let mut it = segs.into_iter();
let Some(first) = it.next() else {
return Some(Target::Root);
};
let rest: Vec<String> = it.collect();
if first == "principals" {
return match <[String; 1]>::try_from(rest) {
Ok([user]) => Some(Target::Principal(user)),
Err(_) => None,
};
}
let kind = kind(&first)?;
let mut rest = rest.into_iter();
Some(match (rest.next(), rest.next(), rest.next(), rest.next()) {
(Some(u), None, None, None) => Target::Home(kind, u),
(Some(u), Some(c), None, None) => Target::Collection(kind, u, c),
(Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o),
_ => return None,
})
}
fn kind_segment(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "calendars",
PimKind::AddressBook => "addressbooks",
}
}
fn seg(s: &str) -> String {
utf8_percent_encode(s, SEGMENT).to_string()
}
fn status(code: StatusCode) -> Response<Body> {
code.into_response()
}
fn xml_response(code: StatusCode, body: String) -> Response<Body> {
(
code,
[(CONTENT_TYPE, "application/xml; charset=utf-8")],
body,
)
.into_response()
}
/// A failed precondition, named in a `<d:error>` body.
fn error(code: StatusCode, condition: Element) -> Response<Body> {
xml_response(code, xml::error(condition))
}
fn options() -> Response<Body> {
(
StatusCode::OK,
[
("dav", "1, 3, calendar-access, addressbook, extended-mkcol"),
(
ALLOW.as_str(),
"OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
),
],
)
.into_response()
}
async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
axum::body::to_bytes(body, limit).await.ok()
}
// ---------------------------------------------------------------------------
// PROPFIND
// ---------------------------------------------------------------------------
/// A resource PROPFIND can describe.
enum Res {
Root,
Principal,
Home,
Collection(PimKind, PimCollection),
Object(PimKind, PimObject),
}
async fn propfind(
state: &AppState,
me: &Me,
target: &Target,
headers: &HeaderMap,
body: Body,
) -> Reply {
// Missing means infinity to RFC 4918, but clients that omit it mean 0.
let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) {
None | Some("0") => false,
Some("1") => true,
Some(_) => {
return Ok(error(
StatusCode::FORBIDDEN,
el(DAV, "propfind-finite-depth"),
));
}
};
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let Ok(request) = xml::propfind(&body) else {
return Ok(status(StatusCode::BAD_REQUEST));
};
let mut list: Vec<(String, Res)> = Vec::new();
match target {
Target::Root => list.push((format!("{PIM}/"), Res::Root)),
Target::Principal(_) => list.push((me.principal(), Res::Principal)),
Target::Home(kind, _) => {
list.push((me.home(*kind), Res::Home));
if deep {
for c in state.db.pim_collections(me.id, *kind).await? {
list.push((me.collection(*kind, &c.slug), Res::Collection(*kind, c)));
}
}
}
Target::Collection(kind, _, slug) => {
let Some(c) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
if deep {
for o in state.db.pim_objects(c.id).await? {
let href = me.object(*kind, &c.slug, &o.name);
list.push((href, Res::Object(*kind, o)));
}
}
list.insert(
0,
(me.collection(*kind, &c.slug), Res::Collection(*kind, c)),
);
}
Target::Object(kind, _, slug, name) => {
let found = match state.db.pim_collection(me.id, *kind, slug).await? {
Some(c) => state.db.pim_object(c.id, name).await?,
None => None,
};
let Some((o, _)) = found else {
return Ok(status(StatusCode::NOT_FOUND));
};
list.push((me.object(*kind, slug, name), Res::Object(*kind, o)));
}
}
let responses: Vec<xml::Response> = list
.into_iter()
.map(|(href, res)| {
let mut r = xml::Response::new(href);
let all = props(me, &res);
match &request {
Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
Propfind::Prop(names) => {
for n in names {
match all.iter().find(|p| Name::of(p) == *n) {
Some(p) => r.push(200, p.clone()),
None => r.push(404, n.element()),
}
}
}
}
r
})
.collect();
Ok(xml_response(
StatusCode::MULTI_STATUS,
xml::multistatus(&Name::new(DAV, "multistatus"), &responses),
))
}
/// Every live property of a resource, with its value.
fn props(me: &Me, res: &Res) -> Vec<Element> {
let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h]));
let resourcetype = |types: &[(&str, &str)]| {
with_children(
el(DAV, "resourcetype"),
types.iter().map(|(ns, l)| el(ns, l)),
)
};
let mut out = vec![href_prop(DAV, "current-user-principal", &me.principal())];
match res {
Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
Res::Principal => {
let principal = me.principal();
let addresses = [
format!("mailto:{}@{MAIL_DOMAIN}", seg(&me.name)),
principal.clone(),
format!("urn:uuid:{}", principal_uuid(me.id)),
];
out.extend([
resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
text(DAV, "displayname", &me.name),
href_prop(DAV, "principal-URL", &principal),
href_prop(CALDAV, "calendar-home-set", &me.home(PimKind::Calendar)),
href_prop(
CARDDAV,
"addressbook-home-set",
&me.home(PimKind::AddressBook),
),
with_children(
el(CALDAV, "calendar-user-address-set"),
hrefs(addresses.iter().map(String::as_str)),
),
text(CALDAV, "calendar-user-type", "INDIVIDUAL"),
privileges(),
]);
}
Res::Home => out.extend([
resourcetype(&[(DAV, "collection")]),
href_prop(DAV, "owner", &me.principal()),
privileges(),
]),
Res::Collection(kind, c) => {
let (types, desc) = match kind {
PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
PimKind::AddressBook => (
(CARDDAV, "addressbook"),
(CARDDAV, "addressbook-description"),
),
};
out.extend([
resourcetype(&[(DAV, "collection"), types]),
href_prop(DAV, "owner", &me.principal()),
privileges(),
// Empty until the REPORTs exist.
el(DAV, "supported-report-set"),
text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c)),
text(
if *kind == PimKind::Calendar {
CALDAV
} else {
CARDDAV
},
"max-resource-size",
&MAX_RESOURCE_SIZE.to_string(),
),
]);
if let Some(v) = &c.displayname {
out.push(text(DAV, "displayname", v));
}
if let Some(v) = &c.description {
out.push(text(desc.0, desc.1, v));
}
match kind {
PimKind::Calendar => {
out.push(with_children(
el(CALDAV, "supported-calendar-component-set"),
c.components
.split(',')
.map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)),
));
out.push(with_children(
el(CALDAV, "supported-calendar-data"),
[with_attr(
with_attr(el(CALDAV, "calendar-data"), "content-type", "text/calendar"),
"version",
"2.0",
)],
));
if let Some(v) = &c.color {
out.push(text(APPLE, "calendar-color", v));
}
if let Some(v) = &c.sort_order {
out.push(text(APPLE, "calendar-order", v));
}
if let Some(v) = &c.timezone {
out.push(text(CALDAV, "calendar-timezone", v));
}
}
PimKind::AddressBook => out.push(with_children(
el(CARDDAV, "supported-address-data"),
["3.0", "4.0"].map(|v| {
with_attr(
with_attr(
el(CARDDAV, "address-data-type"),
"content-type",
"text/vcard",
),
"version",
v,
)
}),
)),
}
}
Res::Object(kind, o) => {
out.extend([
resourcetype(&[]),
text(DAV, "getetag", &o.etag),
text(DAV, "getcontenttype", &content_type(*kind, &o.component)),
text(DAV, "getcontentlength", &o.size.to_string()),
]);
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) {
let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
out.push(text(DAV, "getlastmodified", &http_date));
}
}
}
out
}
fn privileges() -> Element {
let names = [
"all",
"read",
"write",
"write-properties",
"write-content",
"bind",
"unbind",
"read-current-user-privilege-set",
];
with_children(
el(DAV, "current-user-privilege-set"),
names.map(|n| with_children(el(DAV, "privilege"), [el(DAV, n)])),
)
}
/// Carries the collection id, so a token handed out for a deleted
/// collection never matches the one that later takes its URL.
fn sync_token(c: &PimCollection) -> String {
format!("urn:fbng:sync:{}-{}", c.id, c.seq)
}
/// A stable UUID per account, for the `urn:uuid:` calendar user address.
fn principal_uuid(user_id: i64) -> String {
let h = crate::hex(&Sha256::digest(format!("filebrowser-ng principal {user_id}"))[..16]);
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn content_type(kind: PimKind, component: &str) -> String {
match kind {
PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
}
}
// ---------------------------------------------------------------------------
// PROPPATCH, MKCALENDAR, MKCOL
// ---------------------------------------------------------------------------
async fn proppatch(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply {
let Target::Collection(kind, _, slug) = target else {
return Ok(status(StatusCode::FORBIDDEN));
};
let Some(mut col) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
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 {
state.db.pim_update_collection(&col).await?;
}
let mut r = xml::Response::new(me.collection(*kind, slug));
for (code, prop) in results {
r.push(code, prop);
}
Ok(xml_response(
StatusCode::MULTI_STATUS,
xml::multistatus(&Name::new(DAV, "multistatus"), &[r]),
))
}
async fn mkcol(state: &AppState, me: &Me, target: &Target, method: &str, body: Body) -> Reply {
let Target::Collection(kind, _, slug) = target else {
return Ok(status(StatusCode::FORBIDDEN));
};
let calendar = method == "MKCALENDAR";
if calendar && *kind != PimKind::Calendar {
return Ok(status(StatusCode::FORBIDDEN));
}
if state.db.pim_collection(me.id, *kind, slug).await?.is_some() {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
}
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));
};
// A plain MKCOL makes a plain collection, which a calendar home cannot
// hold. An address book home takes it as an address book.
let typed = update
.set
.iter()
.any(|p| Name::of(p).is(DAV, "resourcetype"));
if !calendar && *kind == PimKind::Calendar && !typed {
return Ok(status(StatusCode::FORBIDDEN));
}
let mut col = PimCollection {
slug: slug.clone(),
components: match kind {
PimKind::Calendar => "VEVENT,VTODO".to_string(),
PimKind::AddressBook => String::new(),
},
..Default::default()
};
let (ok, results) = apply(*kind, &mut col, &update, true);
if !ok {
let root = match calendar {
true => Name::new(CALDAV, "mkcalendar-response"),
false => Name::new(DAV, "mkcol-response"),
};
let propstats = group(results);
return Ok(xml_response(
StatusCode::FORBIDDEN,
xml::propstat_document(&root, &propstats),
));
}
if !state.db.pim_create_collection(me.id, *kind, &col).await? {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
}
Ok(status(StatusCode::CREATED))
}
fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
let mut r = xml::Response::default();
for (code, prop) in results {
r.push(code, prop);
}
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.
fn apply(
kind: PimKind,
col: &mut PimCollection,
update: &Update,
creating: bool,
) -> (bool, Vec<(u16, Element)>) {
let cal = kind == PimKind::Calendar;
let mut results = Vec::new();
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
}
(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
}
(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
}
_ => false,
};
results.push((if ok { 200 } else { 403 }, name.element()));
}
for name in &update.remove {
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 *code == 200 {
*code = 424;
}
}
}
(ok, results)
}
/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
fn is_timezone(v: &str) -> bool {
use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
ICalendar::parse(v).is_ok_and(|c| {
c.components
.iter()
.any(|c| c.component_type == ICalendarComponentType::VTimezone)
})
}
// ---------------------------------------------------------------------------
// Objects
// ---------------------------------------------------------------------------
async fn get(state: &AppState, me: &Me, target: &Target, head: bool) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let found = match state.db.pim_collection(me.id, *kind, slug).await? {
Some(c) => state.db.pim_object(c.id, name).await?,
None => None,
};
let Some((o, data)) = found else {
return Ok(status(StatusCode::NOT_FOUND));
};
let body = if head {
Body::empty()
} else {
Body::from(data)
};
Ok((
StatusCode::OK,
[
(CONTENT_TYPE, content_type(*kind, &o.component)),
(ETAG, o.etag),
],
body,
)
.into_response())
}
async fn put(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::CONFLICT));
};
let ns = match kind {
PimKind::Calendar => CALDAV,
PimKind::AddressBook => CARDDAV,
};
let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
};
let parsed = match kind {
PimKind::Calendar => {
let supported: Vec<&str> = col.components.split(',').collect();
object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
}
PimKind::AddressBook => {
object::vcard(&data).map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into()))
}
};
let (uid, component) = match parsed {
Ok(v) => v,
Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
};
let etag = format!("\"{}\"", crate::hex(&Sha256::digest(&data)[..16]));
let obj = PimObject {
name: name.clone(),
uid,
component,
etag: etag.clone(),
..Default::default()
};
// The stored bytes are the request bytes, so the ETag may be returned.
match state
.db
.pim_put_object(col.id, &obj, &data, &precondition(headers))
.await?
{
PimWrite::Created => Ok((StatusCode::CREATED, [(ETAG, etag)]).into_response()),
PimWrite::Updated => Ok((StatusCode::NO_CONTENT, [(ETAG, etag)]).into_response()),
PimWrite::PreconditionFailed => Ok(status(StatusCode::PRECONDITION_FAILED)),
PimWrite::UidConflict(holder) => Ok(error(
StatusCode::FORBIDDEN,
with_children(
el(ns, "no-uid-conflict"),
hrefs([me.object(*kind, slug, &holder).as_str()]),
),
)),
PimWrite::Deleted | PimWrite::NotFound => Ok(status(StatusCode::INTERNAL_SERVER_ERROR)),
}
}
async fn delete(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply {
let (kind, slug, name) = match target {
Target::Collection(k, _, s) => (k, s, None),
Target::Object(k, _, s, n) => (k, s, Some(n)),
_ => return Ok(status(StatusCode::FORBIDDEN)),
};
let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let Some(name) = name else {
state.db.pim_delete_collection(col.id).await?;
return Ok(status(StatusCode::NO_CONTENT));
};
Ok(
match state
.db
.pim_delete_object(col.id, name, &precondition(headers))
.await?
{
PimWrite::Deleted => status(StatusCode::NO_CONTENT),
PimWrite::NotFound => status(StatusCode::NOT_FOUND),
PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
_ => status(StatusCode::INTERNAL_SERVER_ERROR),
},
)
}
fn precondition(headers: &HeaderMap) -> Precondition {
let header = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
Precondition {
if_match: header("if-match"),
if_none_match: header("if-none-match"),
}
}
Mserver/src/db.rs
@@ -6,7 +6,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 11;
const SCHEMA_VERSION: i64 = 12;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -149,6 +149,87 @@ pub struct ShareWithCreator {
pub creator_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PimKind {
Calendar,
AddressBook,
}
impl PimKind {
fn as_str(self) -> &'static str {
match self {
PimKind::Calendar => "cal",
PimKind::AddressBook => "card",
}
}
}
/// A calendar or address book.
#[derive(Debug, Clone, Default)]
pub struct PimCollection {
pub id: i64,
/// The URL segment.
pub slug: String,
pub displayname: Option<String>,
pub description: Option<String>,
pub color: Option<String>,
/// A VCALENDAR holding one VTIMEZONE.
pub timezone: Option<String>,
pub sort_order: Option<String>,
/// The component types a calendar takes, comma-separated. Empty for an
/// address book.
pub components: String,
/// Grows with every change to the collection or its members.
pub seq: i64,
}
/// A calendar or address object, without its data.
#[derive(Debug, Clone, Default)]
pub struct PimObject {
/// The URL segment.
pub name: String,
pub uid: String,
/// `VEVENT`, `VTODO`, `VJOURNAL` or `VCARD`.
pub component: String,
/// With the quotes.
pub etag: String,
pub size: i64,
pub modified_at: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum PimWrite {
Created,
Updated,
Deleted,
NotFound,
PreconditionFailed,
/// Another object in the collection has this UID, under this name.
UidConflict(String),
}
/// The `If-Match` and `If-None-Match` headers of a write.
#[derive(Debug, Default)]
pub struct Precondition {
pub if_match: Option<String>,
pub if_none_match: Option<String>,
}
impl Precondition {
/// Whether the write may go ahead given the current ETag, if any.
fn allows(&self, current: Option<&str>) -> bool {
let listed = |header: &str| match current {
Some(etag) => header.split(',').any(|t| {
let t = t.trim();
t == "*" || t.strip_prefix("W/").unwrap_or(t) == etag
}),
None => false,
};
self.if_match.as_deref().is_none_or(listed)
&& !self.if_none_match.as_deref().is_some_and(listed)
}
}
/// The columns [`map_user`] reads, in order. Every SELECT that builds a
/// [`User`] uses one of these two, so a new column is added in one place.
/// `USER_COLS_U` is the same list qualified for the queries that join
@@ -305,6 +386,51 @@ impl Db {
ON app_passwords(user_id);",
)?;
}
if version < 12 {
// CalDAV and CardDAV. `seq` counts every change to a collection
// and its members; `pim_changes` keeps the latest change per
// member, deletions included, for sync tokens.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pim_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('cal','card')),
slug TEXT NOT NULL,
displayname TEXT,
description TEXT,
color TEXT,
timezone TEXT,
sort_order TEXT,
components TEXT NOT NULL DEFAULT '',
seq INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE (user_id, kind, slug)
);
CREATE TABLE IF NOT EXISTS pim_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
uid TEXT NOT NULL,
component TEXT NOT NULL,
data BLOB NOT NULL,
etag TEXT NOT NULL,
modified_at TEXT NOT NULL,
UNIQUE (collection_id, name),
UNIQUE (collection_id, uid)
);
CREATE TABLE IF NOT EXISTS pim_changes (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seq INTEGER NOT NULL,
deleted INTEGER NOT NULL,
PRIMARY KEY (collection_id, name)
);
CREATE INDEX IF NOT EXISTS idx_pim_changes_seq
ON pim_changes(collection_id, seq);",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -898,6 +1024,14 @@ impl Db {
}
/// Delete a user. `false` means no row matched.
/// The stored spelling of the name, which a Basic login may differ from
/// in case.
pub async fn user_name(&self, id: i64) -> DbResult<Option<String>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached("SELECT name FROM users WHERE id = ?1")?;
stmt.query_row([id], |r| r.get(0)).optional()
}
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute("DELETE FROM users WHERE id = ?1", [id])? > 0)
@@ -1124,6 +1258,215 @@ impl Db {
Ok(())
}
// ---------- CalDAV and CardDAV ----------
/// Gives the user a calendar and an address book when they have none.
/// Scheduling needs a calendar to deliver into, so a user who deletes the
/// last one gets a new one.
pub async fn pim_ensure_defaults(&self, user_id: i64) -> DbResult<()> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
SELECT ?1, ?2, 'default', ?3, ?4, ?5
WHERE NOT EXISTS (SELECT 1 FROM pim_collections WHERE user_id = ?1 AND kind = ?2)",
)?;
let now = now();
stmt.execute(params![user_id, "cal", "Calendar", "VEVENT,VTODO", now])?;
stmt.execute(params![user_id, "card", "Contacts", "", now])?;
Ok(())
}
pub async fn pim_collections(
&self,
user_id: i64,
kind: PimKind,
) -> DbResult<Vec<PimCollection>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE user_id = ?1 AND kind = ?2 ORDER BY id"
))?;
stmt.query_map(params![user_id, kind.as_str()], map_pim_collection)?
.collect()
}
pub async fn pim_collection(
&self,
user_id: i64,
kind: PimKind,
slug: &str,
) -> DbResult<Option<PimCollection>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE user_id = ?1 AND kind = ?2 AND slug = ?3"
))?;
stmt.query_row(params![user_id, kind.as_str(), slug], map_pim_collection)
.optional()
}
/// `false` if the slug is taken. `id` and `seq` of `new` are ignored.
pub async fn pim_create_collection(
&self,
user_id: i64,
kind: PimKind,
new: &PimCollection,
) -> DbResult<bool> {
let c = self.0.lock().await;
let n = c.execute(
"INSERT OR IGNORE INTO pim_collections (user_id, kind, slug, displayname,
description, color, timezone, sort_order, components, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
user_id,
kind.as_str(),
new.slug,
new.displayname,
new.description,
new.color,
new.timezone,
new.sort_order,
new.components,
now()
],
)?;
Ok(n > 0)
}
/// Writes the properties of `col` and counts it as a change.
pub async fn pim_update_collection(&self, col: &PimCollection) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE pim_collections SET displayname = ?2, description = ?3, color = ?4,
timezone = ?5, sort_order = ?6, seq = seq + 1
WHERE id = ?1",
params![
col.id,
col.displayname,
col.description,
col.color,
col.timezone,
col.sort_order
],
)?;
Ok(())
}
pub async fn pim_delete_collection(&self, id: i64) -> DbResult<()> {
let c = self.0.lock().await;
c.execute("DELETE FROM pim_collections WHERE id = ?1", [id])?;
Ok(())
}
pub async fn pim_objects(&self, collection_id: i64) -> DbResult<Vec<PimObject>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS} FROM pim_objects WHERE collection_id = ?1 ORDER BY name"
))?;
stmt.query_map([collection_id], map_pim_object)?.collect()
}
pub async fn pim_object(
&self,
collection_id: i64,
name: &str,
) -> DbResult<Option<(PimObject, Vec<u8>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS}, data FROM pim_objects
WHERE collection_id = ?1 AND name = ?2"
))?;
stmt.query_row(params![collection_id, name], |r| {
Ok((map_pim_object(r)?, r.get(6)?))
})
.optional()
}
/// Stores an object under `obj.name`. The precondition and the UID check
/// run in the same transaction as the write.
pub async fn pim_put_object(
&self,
collection_id: i64,
obj: &PimObject,
data: &[u8],
cond: &Precondition,
) -> DbResult<PimWrite> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let current: Option<String> = tx
.query_row(
"SELECT etag FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, obj.name],
|r| r.get(0),
)
.optional()?;
if !cond.allows(current.as_deref()) {
return Ok(PimWrite::PreconditionFailed);
}
let holder: Option<String> = tx
.query_row(
"SELECT name FROM pim_objects
WHERE collection_id = ?1 AND uid = ?2 AND name != ?3",
params![collection_id, obj.uid, obj.name],
|r| r.get(0),
)
.optional()?;
if let Some(holder) = holder {
return Ok(PimWrite::UidConflict(holder));
}
tx.execute(
"INSERT INTO pim_objects (collection_id, name, uid, component, data, etag, modified_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT (collection_id, name) DO UPDATE SET uid = ?3, component = ?4,
data = ?5, etag = ?6, modified_at = ?7",
params![
collection_id,
obj.name,
obj.uid,
obj.component,
data,
obj.etag,
now()
],
)?;
record_pim_change(&tx, collection_id, &obj.name, false)?;
tx.commit()?;
Ok(match current {
Some(_) => PimWrite::Updated,
None => PimWrite::Created,
})
}
pub async fn pim_delete_object(
&self,
collection_id: i64,
name: &str,
cond: &Precondition,
) -> DbResult<PimWrite> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let current: Option<String> = tx
.query_row(
"SELECT etag FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
|r| r.get(0),
)
.optional()?;
if current.is_none() {
return Ok(PimWrite::NotFound);
}
if !cond.allows(current.as_deref()) {
return Ok(PimWrite::PreconditionFailed);
}
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![collection_id, name],
)?;
record_pim_change(&tx, collection_id, name, true)?;
tx.commit()?;
Ok(PimWrite::Deleted)
}
/// Whether users may create writable (read-write) shares. Off by default;
/// the admin setting gates it.
pub async fn allow_writable_shares(&self) -> DbResult<bool> {
@@ -1237,6 +1580,56 @@ fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordInfo> {
})
}
/// Bumps the collection's `seq` and records it as the latest change of `name`.
fn record_pim_change(
tx: &rusqlite::Transaction,
collection_id: i64,
name: &str,
deleted: bool,
) -> DbResult<()> {
let seq: i64 = tx.query_row(
"UPDATE pim_collections SET seq = seq + 1 WHERE id = ?1 RETURNING seq",
[collection_id],
|r| r.get(0),
)?;
tx.execute(
"INSERT INTO pim_changes (collection_id, name, seq, deleted) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (collection_id, name) DO UPDATE SET seq = ?3, deleted = ?4",
params![collection_id, name, seq, deleted],
)?;
Ok(())
}
const PIM_COLLECTION_COLS: &str = "id, slug, displayname, description, color, timezone,
sort_order, components, seq";
fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
Ok(PimCollection {
id: r.get(0)?,
slug: r.get(1)?,
displayname: r.get(2)?,
description: r.get(3)?,
color: r.get(4)?,
timezone: r.get(5)?,
sort_order: r.get(6)?,
components: r.get(7)?,
seq: r.get(8)?,
})
}
const PIM_OBJECT_COLS: &str = "name, uid, component, etag, length(data), modified_at";
fn map_pim_object(r: &rusqlite::Row) -> DbResult<PimObject> {
Ok(PimObject {
name: r.get(0)?,
uid: r.get(1)?,
component: r.get(2)?,
etag: r.get(3)?,
size: r.get(4)?,
modified_at: r.get(5)?,
})
}
/// Column order matched by the two `shares` SELECTs above.
fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
Ok(ShareRow {
Aserver/tests/api_pim.rs
@@ -0,0 +1,555 @@
//! CalDAV and CardDAV: discovery, collections, properties and objects.
mod common;
use axum::http::{Method, StatusCode};
use common::*;
use pimdav::xml::{self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name};
use serde_json::json;
use xmltree::Element;
const ALICE: &str = "alice";
const PW: &str = "alice12345";
async fn setup() -> (Env, String) {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, ALICE, PW, &[]).await;
(env, basic(ALICE, PW))
}
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
}
fn propfind_body(props: &[(&str, &str)]) -> String {
let props: String = props
.iter()
.map(|(ns, l)| format!("<{l} xmlns=\"{ns}\"/>"))
.collect();
format!("<d:propfind xmlns:d=\"DAV:\"><d:prop>{props}</d:prop></d:propfind>")
}
/// `href -> [(status, property element)]` of a multistatus.
fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> {
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| {
let href = xml::text(xml::child(resp, DAV, "href").unwrap());
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();
let prop = xml::child(ps, DAV, "prop").unwrap();
xml::elements(prop)
.map(move |p| (code, p.clone()))
.collect::<Vec<_>>()
})
.collect();
(href, props)
})
.collect()
}
/// The property of `href` with status 200.
fn prop(
ms: &[(String, Vec<(u16, Element)>)],
href: &str,
ns: &str,
local: &str,
) -> Option<Element> {
ms.iter()
.find(|(h, _)| h == href)
.unwrap_or_else(|| panic!("no response for {href}"))
.1
.iter()
.find(|(code, p)| *code == 200 && Name::of(p).is(ns, local))
.map(|(_, p)| p.clone())
}
fn prop_text(
ms: &[(String, Vec<(u16, Element)>)],
href: &str,
ns: &str,
local: &str,
) -> Option<String> {
prop(ms, href, ns, local).map(|p| xml::text(&p))
}
fn hrefs_of(p: &Element) -> Vec<String> {
xml::elements(p).map(xml::text).collect()
}
fn error_condition(r: &Resp) -> Name {
let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
assert!(Name::of(&root).is(DAV, "error"), "{}", r.text());
Name::of(xml::elements(&root).next().unwrap())
}
const HOME: &str = "/pim/calendars/alice/";
const CAL: &str = "/pim/calendars/alice/default/";
const BOOK: &str = "/pim/addressbooks/alice/default/";
fn event(uid: &str, summary: &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\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
}
#[tokio::test]
async fn discovery() {
let (env, auth) = setup().await;
let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("www-authenticate").is_some());
let r = req(&env, "PROPFIND", "/.well-known/caldav", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::MOVED_PERMANENTLY);
assert_eq!(r.header("location").as_deref(), Some("/pim/"));
// A Basic login spelled in another case still gets the stored spelling.
let body = propfind_body(&[(DAV, "current-user-principal")]);
let r = req(
&env,
"PROPFIND",
"/pim/",
&basic("ALICE", PW),
&[("depth", "0")],
&body,
)
.await;
let ms = parse_multistatus(&r);
let p = prop(&ms, "/pim/", DAV, "current-user-principal").unwrap();
assert_eq!(hrefs_of(&p), ["/pim/principals/alice/"]);
let body = propfind_body(&[
(CALDAV, "calendar-home-set"),
(CARDDAV, "addressbook-home-set"),
(CALDAV, "calendar-user-address-set"),
(DAV, "displayname"),
(DAV, "no-such-prop"),
]);
let r = req(
&env,
"PROPFIND",
"/pim/principals/alice/",
&auth,
&[("depth", "0")],
&body,
)
.await;
let ms = parse_multistatus(&r);
let p = "/pim/principals/alice/";
assert_eq!(
hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
[HOME]
);
assert_eq!(
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:"));
assert_eq!(
prop_text(&ms, p, DAV, "displayname").as_deref(),
Some(ALICE)
);
assert!(
ms[0]
.1
.iter()
.any(|(c, e)| *c == 404 && Name::of(e).is(DAV, "no-such-prop"))
);
// An app password works as well.
let admin = login(&env, ALICE, PW).await;
let r = admin
.post_json("/api/auth/app-passwords", &json!({ "name": "phone" }))
.await;
let secret = r.json()["secret"].as_str().unwrap().to_string();
let r = req(
&env,
"PROPFIND",
"/pim/",
&basic("x", &secret),
&[("depth", "0")],
"",
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let r = req(&env, "OPTIONS", "/pim/", &auth, &[], "").await;
assert!(r.header("dav").unwrap().contains("calendar-access"));
}
#[tokio::test]
async fn homes_list_the_default_collections() {
let (env, auth) = setup().await;
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(ms.len(), 2, "{}", r.text());
let rt = prop(&ms, CAL, DAV, "resourcetype").unwrap();
assert!(xml::child(&rt, CALDAV, "calendar").is_some());
assert_eq!(
prop_text(&ms, CAL, DAV, "displayname").as_deref(),
Some("Calendar")
);
let comps = prop(&ms, CAL, CALDAV, "supported-calendar-component-set").unwrap();
let comps: Vec<_> = xml::elements(&comps)
.map(|c| c.attributes["name"].clone())
.collect();
assert_eq!(comps, ["VEVENT", "VTODO"]);
assert!(prop_text(&ms, CAL, CALSERVER, "getctag").is_some());
assert!(
prop_text(&ms, CAL, DAV, "sync-token")
.unwrap()
.starts_with("urn:")
);
let r = req(
&env,
"PROPFIND",
"/pim/addressbooks/alice/",
&auth,
&[("depth", "1")],
"",
)
.await;
let ms = parse_multistatus(&r);
let rt = prop(&ms, BOOK, DAV, "resourcetype").unwrap();
assert!(xml::child(&rt, CARDDAV, "addressbook").is_some());
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "infinity")], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "propfind-finite-depth"));
}
#[tokio::test]
async fn other_users_are_off_limits() {
let (env, auth) = setup().await;
for path in ["/pim/principals/admin/", "/pim/calendars/admin/default/"] {
let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}");
}
}
#[tokio::test]
async fn make_and_patch_collections() {
let (env, auth) = setup().await;
let work = "/pim/calendars/alice/work/";
let body = r##"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:i="http://apple.com/ns/ical/">
<d:set><d:prop>
<d:displayname>Work</d:displayname>
<i:calendar-color>#00ff00</i:calendar-color>
<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>
</d:prop></d:set></c:mkcalendar>"##;
let r = req(&env, "MKCALENDAR", work, &auth, &[], body).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(&env, "MKCALENDAR", work, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, work, DAV, "displayname").as_deref(),
Some("Work")
);
assert_eq!(
prop_text(&ms, work, APPLE, "calendar-color").as_deref(),
Some("#00ff00")
);
let ctag = prop_text(&ms, work, CALSERVER, "getctag").unwrap();
// One bad property fails the whole request, and nothing is created.
let bad = body.replace("VTODO", "VCARD");
let r = req(
&env,
"MKCALENDAR",
"/pim/calendars/alice/bad/",
&auth,
&[],
&bad,
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(r.text().contains("mkcalendar-response"), "{}", r.text());
let r = req(
&env,
"PROPFIND",
"/pim/calendars/alice/bad/",
&auth,
&[("depth", "0")],
"",
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// A plain MKCOL cannot make a calendar, an extended one makes an address book.
let r = req(&env, "MKCOL", "/pim/calendars/alice/plain/", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let mkcol = r#"<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:set><d:prop>
<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>
<d:displayname>Friends</d:displayname></d:prop></d:set></d:mkcol>"#;
let r = req(
&env,
"MKCOL",
"/pim/addressbooks/alice/friends/",
&auth,
&[],
mkcol,
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop><d:displayname>Job</d:displayname><c:calendar-description>Tasks</c:calendar-description></d:prop></d:set>
</d:propertyupdate>"#;
let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
let ms = parse_multistatus(&r);
assert!(ms[0].1.iter().all(|(c, _)| *c == 200));
let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, work, DAV, "displayname").as_deref(),
Some("Job")
);
assert_eq!(
prop_text(&ms, work, CALDAV, "calendar-description").as_deref(),
Some("Tasks")
);
assert_ne!(prop_text(&ms, work, CALSERVER, "getctag").unwrap(), ctag);
let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop>
<d:displayname>Never</d:displayname><d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>"#;
let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
let ms = parse_multistatus(&r);
let codes: Vec<u16> = ms[0].1.iter().map(|(c, _)| *c).collect();
assert_eq!(codes, [424, 403]);
let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, work, DAV, "displayname").as_deref(),
Some("Job")
);
let r = req(&env, "DELETE", work, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn calendar_objects() {
let (env, auth) = setup().await;
let obj = format!("{CAL}a.ics");
let r = req(
&env,
"PUT",
&obj,
&auth,
&[("if-none-match", "*")],
&event("a", "One"),
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let etag = r.header("etag").unwrap();
let r = req(&env, "GET", &obj, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.text(), event("a", "One"));
assert_eq!(r.header("etag"), Some(etag.clone()));
assert!(
r.header("content-type")
.unwrap()
.starts_with("text/calendar")
);
let r = req(&env, "HEAD", &obj, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.body.is_empty());
let r = req(
&env,
"PUT",
&obj,
&auth,
&[("if-none-match", "*")],
&event("a", "Two"),
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
let r = req(
&env,
"PUT",
&obj,
&auth,
&[("if-match", "\"stale\"")],
&event("a", "Two"),
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await);
let r = req(
&env,
"PUT",
&obj,
&auth,
&[("if-match", &etag)],
&event("a", "Two"),
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let new_etag = r.header("etag").unwrap();
assert_ne!(new_etag, etag);
let after = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "1")], "").await);
assert_ne!(
prop_text(&before, CAL, DAV, "sync-token"),
prop_text(&after, CAL, DAV, "sync-token")
);
assert_eq!(prop_text(&after, &obj, DAV, "getetag"), Some(new_etag));
// The UID is already stored under another name.
let r = req(
&env,
"PUT",
&format!("{CAL}b.ics"),
&auth,
&[],
&event("a", "Dup"),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(CALDAV, "no-uid-conflict"));
assert!(r.text().contains(&obj), "{}", r.text());
let journal = event("j", "x").replace("VEVENT", "VJOURNAL");
let cases = [
("not a calendar".to_string(), "valid-calendar-data"),
(
event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"),
"valid-calendar-object-resource",
),
(journal, "supported-calendar-component"),
];
for (body, cond) in cases {
let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{cond}");
assert!(error_condition(&r).is(CALDAV, cond), "{cond}: {}", r.text());
}
let r = req(
&env,
"PUT",
"/pim/calendars/alice/nope/x.ics",
&auth,
&[],
&event("x", "x"),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
let r = req(
&env,
"DELETE",
&obj,
&auth,
&[("if-match", "\"stale\"")],
"",
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = req(&env, "REPORT", CAL, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn address_objects() {
let (env, auth) = setup().await;
let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Bob\r\nN:;Bob;;;\r\nEND:VCARD\r\n";
let r = req(&env, "PUT", &format!("{BOOK}c1.vcf"), &auth, &[], card).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(&env, "GET", &format!("{BOOK}c1.vcf"), &auth, &[], "").await;
assert_eq!(r.text(), card);
assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
let r = req(&env, "PUT", &format!("{BOOK}c2.vcf"), &auth, &[], card).await;
assert!(error_condition(&r).is(CARDDAV, "no-uid-conflict"));
let r = req(
&env,
"PUT",
&format!("{BOOK}c3.vcf"),
&auth,
&[],
&event("e", "x"),
)
.await;
assert!(error_condition(&r).is(CARDDAV, "valid-address-data"));
}
#[tokio::test]
async fn deleting_the_last_calendar_brings_a_new_default() {
let (env, auth) = setup().await;
let r = req(&env, "DELETE", CAL, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
let ms = parse_multistatus(&r);
assert!(ms.iter().any(|(h, _)| h == CAL));
}
#[tokio::test]
async fn deleting_a_user_deletes_their_collections() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, ALICE, PW, &[]).await;
let auth = basic(ALICE, PW);
let r = req(
&env,
"PUT",
&format!("{CAL}a.ics"),
&auth,
&[],
&event("a", "x"),
)
.await;
assert_eq!(r.status, StatusCode::CREATED);
let id = user_id(&admin, ALICE).await;
let r = admin.delete(&format!("/api/admin/users/{id}")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let db = &env.state.db;
assert!(
db.pim_collections(id, server::db::PimKind::Calendar)
.await
.unwrap()
.is_empty()
);
}