CalDAV/CardDAV access: sharing, system address book, rooms, principal search

- Lend calendars and address books to other accounts, ro or rw; they appear
  as shared-{id} in the borrower's home and every method checks access
- Generated read-only system address book of all accounts and rooms, with
  a CTag and sync token derived from its members
- Rooms and resources as non-login principals with a booking calendar,
  admin JSON API; accounts and rooms share one name space (schema v13)
- principal-property-search, principal-search-property-set and
  calendarserver-principal-search; other principals are readable
- JSON API for collections and their shares; need-privileges errors
- CR written as 
 so calendar and address data keep CRLF; filter
  errors answer valid-filter; i;unicode-casemap folds after NFKD
- .well-known answers 307 so clients keep the body of a REPORT

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit5951ce31ef8f45da8e8e7c5b3a3b874f0189ce0b
Parentc4dabc6
16 files changed, 2734 insertions(+), 693 deletions(-)
▾MCargo.lock
@@ -2316,6 +2316,7 @@ dependencies = [
"chrono",
"chrono-tz",
"rrule",
"unicode-normalization",
"xmltree",
]
@@ -3364,6 +3365,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -3595,6 +3602,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
▾Mapi-types/src/lib.rs
@@ -68,6 +68,14 @@ pub const ADMIN_USERS: &str = "/api/admin/users";
/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
pub const ADMIN_SHARES: &str = "/api/admin/shares";
pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
/// Admin management of rooms and resources: `{ADMIN_ROOMS}` and
/// `{ADMIN_ROOMS}/{id}`.
pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
/// The signed-in user's calendars and address books, own and lent to them
/// (`GET`). `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends
/// (`POST`) an own one; `DELETE` on `.../{user_id}` below it ends a loan.
pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
pub const SHARES_SUFFIX: &str = "/shares";
/// Pseudo root id every signed-in admin has on the files API: the whole
/// server root, read-only (the admin folder picker browses it). Real root
/// ids are positive database ids. Not listed in `/me`.
@@ -485,6 +493,76 @@ pub struct AdminUser {
#[derive(Serialize, Deserialize)]
pub struct OkResp {}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PimCollectionKind {
Calendar,
Addressbook,
}
/// One entry of `GET {PIM_COLLECTIONS}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimCollectionInfo {
pub id: i64,
pub kind: PimCollectionKind,
pub name: String,
/// The CalDAV or CardDAV URL, as seen by the signed-in user.
pub url: String,
pub owner: String,
/// `None` for an own collection, the loan's mode for a lent one.
pub mode: Option<Mode>,
}
/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PimShareInfo {
pub user_id: i64,
pub user_name: String,
pub mode: Mode,
}
/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
/// change the mode of an existing loan.
#[derive(Serialize, Deserialize)]
pub struct CreatePimShare {
pub user: String,
pub mode: Mode,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RoomKind {
Room,
Resource,
}
/// A room or resource: `GET {ADMIN_ROOMS}`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RoomInfo {
pub id: i64,
/// The URL segment. Fixed, since it is also the scheduling address.
pub name: String,
pub display_name: String,
pub kind: RoomKind,
/// The principal URL.
pub url: String,
}
/// `POST {ADMIN_ROOMS}`.
#[derive(Serialize, Deserialize)]
pub struct CreateRoom {
pub name: String,
/// Defaults to `name`.
pub display_name: Option<String>,
pub kind: RoomKind,
}
/// `PUT {ADMIN_ROOMS}/{id}`.
#[derive(Serialize, Deserialize)]
pub struct UpdateRoom {
pub display_name: String,
}
/// `POST ...?action=exists` body: upload targets relative to the request
/// directory (may contain subfolders, like upload part names).
#[derive(Serialize, Deserialize)]
▾Mpimdav/Cargo.toml
@@ -14,4 +14,6 @@ chrono-tz = "0.10"
# Wall-clock RRULE iteration only. Time zones, UNTIL, overrides, RDATE and
# EXDATE are handled in `expand`.
rrule = "0.14"
# NFKD for the i;unicode-casemap collation (RFC 5051), the CardDAV default.
unicode-normalization = "0.1"
xmltree = "0.12"
▾Mpimdav/src/filter.rs
@@ -10,6 +10,7 @@ use calcard::icalendar::{
};
use calcard::vcard::{VCard, VCardVersion};
use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use unicode_normalization::UnicodeNormalization;
use xmltree::Element;
use crate::expand::{expand, stamp};
@@ -90,8 +91,11 @@ impl Collation {
match self {
Collation::Octet => Cow::Borrowed(s),
Collation::AsciiCasemap => Cow::Owned(s.to_ascii_lowercase()),
// ponytail: plain lowercasing, without the NFKD step of RFC 5051.
Collation::UnicodeCasemap => Cow::Owned(s.to_lowercase()),
// Lowercase after NFKD, so compatibility forms such as U+FB01
// (the "fi" ligature) fold too.
Collation::UnicodeCasemap => {
Cow::Owned(s.nfkd().flat_map(char::to_lowercase).collect())
}
}
}
}
▾Mpimdav/src/lib.rs
@@ -5,6 +5,7 @@ pub mod expand;
pub mod filter;
pub mod freebusy;
pub mod object;
pub mod principal;
pub mod render;
pub mod report;
pub mod xml;
▾Apimdav/src/principal.rs
@@ -0,0 +1,211 @@
//! Principals: attendee search (RFC 3744 9.4 and Apple's
//! calendarserver-principal-search) and the system address book.
use xmltree::Element;
use crate::filter::{Collation, MatchType, TextMatch};
use crate::report::Refused;
use crate::xml::{CALDAV, CALSERVER, DAV, Name, Propfind, child, elements, text};
/// The `calendar-user-type` of a principal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserType {
Individual,
Room,
Resource,
}
impl UserType {
pub fn as_str(self) -> &'static str {
match self {
UserType::Individual => "INDIVIDUAL",
UserType::Room => "ROOM",
UserType::Resource => "RESOURCE",
}
}
}
/// What a search can see of a principal.
#[derive(Debug, Clone)]
pub struct Principal<'a> {
/// The URL segment.
pub name: &'a str,
pub display: &'a str,
/// The calendar user addresses, `mailto:` ones included.
pub addresses: &'a [String],
pub kind: UserType,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Search {
pub terms: Vec<Term>,
/// `allof`: every term must match. Otherwise one is enough.
pub all: bool,
/// Only principals of this type, from a calendarserver search context.
pub kind: Option<UserType>,
pub find: Propfind,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Term {
/// The properties the text is looked for in. Empty means all searchable
/// ones and the name.
pub props: Vec<Name>,
pub text: TextMatch,
}
/// The properties `principal-search-property-set` offers.
pub const SEARCHABLE: [(&str, &str, &str); 3] = [
(DAV, "displayname", "Display name"),
(CALDAV, "calendar-user-address-set", "Calendar user address"),
(CALDAV, "calendar-user-type", "Calendar user type"),
];
impl Search {
pub fn matches(&self, p: &Principal) -> bool {
if self.kind.is_some_and(|k| k != p.kind) {
return false;
}
let hit = |t: &Term| {
let any = t.props.is_empty();
let wants = |ns: &str, local: &str| any || t.props.iter().any(|n| n.is(ns, local));
(any && t.text.matches(p.name))
|| (wants(DAV, "displayname") && t.text.matches(p.display))
|| (wants(CALDAV, "calendar-user-type") && t.text.matches(p.kind.as_str()))
|| ((wants(CALDAV, "calendar-user-address-set")
|| wants(CALSERVER, "email-address-set"))
&& p.addresses.iter().any(|a| {
t.text.matches(a)
|| a.strip_prefix("mailto:").is_some_and(|m| t.text.matches(m))
}))
};
// No terms lists every principal.
match self.all || self.terms.is_empty() {
true => self.terms.iter().all(hit),
false => self.terms.iter().any(hit),
}
}
}
fn term_text(e: &Element) -> Result<TextMatch, Refused> {
let match_type = match e.attributes.get("match-type").map(String::as_str) {
None | Some("contains") => MatchType::Contains,
Some("starts-with") => MatchType::StartsWith,
Some("ends-with") => MatchType::EndsWith,
Some("equals") => MatchType::Equals,
Some(_) => return Err(Refused::Invalid),
};
Ok(TextMatch {
text: text(e),
collation: Collation::UnicodeCasemap,
match_type,
negate: false,
})
}
/// The properties in `<prop>`, and any stray property element beside it:
/// python-caldav puts them there.
fn prop_names(root: &Element) -> Propfind {
let mut names: Vec<Name> = child(root, DAV, "prop")
.map(|p| elements(p).map(Name::of).collect())
.unwrap_or_default();
let structure = [
(DAV, "prop"),
(DAV, "property-search"),
(DAV, "apply-to-principal-collection-set"),
(DAV, "limit"),
(CALSERVER, "search-token"),
(CALSERVER, "limit"),
];
names.extend(
elements(root)
.map(Name::of)
.filter(|n| !structure.iter().any(|(ns, l)| n.is(ns, l))),
);
Propfind::Prop(names)
}
fn nresults(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
child(root, ns, "limit")
.and_then(|l| child(l, ns, "nresults"))
.map(|n| text(n).parse().map_err(|_| Refused::Invalid))
.transpose()
}
/// A `DAV:principal-property-search` body.
pub fn property_search(root: &Element) -> Result<Search, Refused> {
let mut terms = Vec::new();
for s in elements(root).filter(|e| Name::of(e).is(DAV, "property-search")) {
let props = child(s, DAV, "prop")
.map(|p| elements(p).map(Name::of).collect())
.unwrap_or_default();
let text = term_text(child(s, DAV, "match").ok_or(Refused::Invalid)?)?;
terms.push(Term { props, text });
}
Ok(Search {
terms,
all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
kind: None,
find: prop_names(root),
limit: nresults(root, DAV)?,
})
}
/// A `calendarserver-principal-search` body. Every token must match one of
/// the name, the display name or an address.
pub fn calendarserver_search(root: &Element) -> Result<Search, Refused> {
let terms: Vec<Term> = elements(root)
.filter(|e| Name::of(e).is(CALSERVER, "search-token"))
.map(|e| {
term_text(e).map(|text| Term {
props: Vec::new(),
text,
})
})
.collect::<Result<_, _>>()?;
let kind = match root.attributes.get("context").map(String::as_str) {
Some("location") => Some(UserType::Room),
Some("resource") => Some(UserType::Resource),
Some("user") => Some(UserType::Individual),
_ => None,
};
Ok(Search {
terms,
all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
kind,
find: prop_names(root),
limit: nresults(root, CALSERVER)?,
})
}
/// The system address book's vCard of a principal.
pub fn card(uid: &str, p: &Principal, email: &str) -> String {
let kind = match p.kind {
UserType::Individual => "individual",
UserType::Room => "location",
// RFC 6869.
UserType::Resource => "device",
};
let fn_ = escape(p.display);
format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{fn_}\r\nN:{fn_};;;;\r\nEMAIL;TYPE=INTERNET:{}\r\nKIND:{kind}\r\nEND:VCARD\r\n",
escape(email)
)
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | ',' | ';' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("\\n"),
'\r' => {}
_ => out.push(c),
}
}
out
}
▾Mpimdav/src/report.rs
@@ -3,8 +3,9 @@
use xmltree::Element;
use crate::filter::{CardFilter, CompFilter, TimeRange, calendar_filter, card_filter, time_range};
use crate::principal::{Search, calendarserver_search, property_search};
use crate::render::{AddressData, CalendarData, address_request, calendar_request};
use crate::xml::{CALDAV, CARDDAV, DAV, Name, Propfind, child, elements, text};
use crate::xml::{CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, child, elements, text};
use crate::zone::{self, Zone};
/// Why a REPORT body is refused.
@@ -52,6 +53,8 @@ pub enum Report {
limit: Option<usize>,
},
FreeBusy(TimeRange),
PrincipalSearch(Search),
PrincipalSearchPropertySet,
}
pub fn parse(body: &[u8]) -> Result<Report, Refused> {
@@ -91,13 +94,14 @@ pub fn parse(body: &[u8]) -> Result<Report, Refused> {
};
Report::CalendarQuery {
props: props(&root)?,
filter: calendar_filter(filter)?,
filter: calendar_filter(filter).map_err(|r| invalid_filter(r, CALDAV))?,
timezone,
}
}
(CARDDAV, "addressbook-query") => Report::AddressbookQuery {
props: props(&root)?,
filter: card_filter(child(&root, CARDDAV, "filter").ok_or(Refused::Invalid)?)?,
filter: card_filter(child(&root, CARDDAV, "filter").ok_or(Refused::Invalid)?)
.map_err(|r| invalid_filter(r, CARDDAV))?,
limit: limit(&root, CARDDAV)?,
},
(DAV, "sync-collection") => {
@@ -123,10 +127,23 @@ pub fn parse(body: &[u8]) -> Result<Report, Refused> {
}
Report::FreeBusy(range)
}
(DAV, "principal-property-search") => Report::PrincipalSearch(property_search(&root)?),
(CALSERVER, "calendarserver-principal-search") => {
Report::PrincipalSearch(calendarserver_search(&root)?)
}
(DAV, "principal-search-property-set") => Report::PrincipalSearchPropertySet,
_ => return Err(Refused::Condition(Name::new(DAV, "supported-report"))),
})
}
/// A filter that does not parse is `valid-filter` in the namespace `ns`.
fn invalid_filter(r: Refused, ns: &str) -> Refused {
match r {
Refused::Invalid => Refused::Condition(Name::new(ns, "valid-filter")),
c => c,
}
}
/// The requested properties. Without any, only the hrefs come back.
fn props(root: &Element) -> Result<Props, Refused> {
let mut out = Props {
▾Mpimdav/src/xml.rs
@@ -326,6 +326,9 @@ fn escape(s: &str) -> String {
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
// A raw CR reaches the client as LF: XML parsers normalize line
// ends. iCalendar and vCard data need their CRLF.
'\r' => out.push_str("&#13;"),
_ => out.push(c),
}
}
▾Apimdav/tests/principal.rs
@@ -0,0 +1,100 @@
//! Attendee search and the system address book's cards.
use pimdav::calcard::vcard::VCard;
use pimdav::principal::{Principal, UserType, card};
use pimdav::report::{Report, parse};
use pimdav::xml::{DAV, Name, Propfind};
fn search(body: &str) -> pimdav::principal::Search {
match parse(body.as_bytes()).unwrap() {
Report::PrincipalSearch(s) => s,
other => panic!("{other:?}"),
}
}
#[test]
fn property_search_and_calendarserver_search() {
let addresses = ["mailto:board@rooms.filebrowser.invalid".to_string()];
let room = Principal {
name: "board",
display: "Board Room",
addresses: &addresses,
kind: UserType::Room,
};
let alice_addresses = ["mailto:alice@filebrowser.invalid".to_string()];
let alice = Principal {
name: "alice",
display: "Alice",
addresses: &alice_addresses,
kind: UserType::Individual,
};
let s = search(
r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:property-search><d:prop><d:displayname/></d:prop><d:match>ROOM</d:match></d:property-search>
<d:property-search><d:prop><c:calendar-user-type/></d:prop><d:match>room</d:match></d:property-search>
<d:prop><d:displayname/></d:prop>
</d:principal-property-search>"#,
);
assert!(s.matches(&room));
assert!(!s.matches(&alice));
assert_eq!(s.find, Propfind::Prop(vec![Name::new(DAV, "displayname")]));
let s = search(
r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:property-search><d:prop><c:calendar-user-address-set/></d:prop>
<d:match match-type="starts-with">alice@</d:match></d:property-search>
</d:principal-property-search>"#,
);
assert!(s.matches(&alice));
assert!(!s.matches(&room));
let s = search(
r#"<cs:calendarserver-principal-search xmlns:cs="http://calendarserver.org/ns/" context="location">
<cs:search-token>bo</cs:search-token><cs:search-token>ro</cs:search-token>
<cs:limit><cs:nresults>5</cs:nresults></cs:limit>
</cs:calendarserver-principal-search>"#,
);
assert!(s.matches(&room));
assert!(!s.matches(&alice));
assert_eq!(s.limit, Some(5));
}
#[test]
fn directory_card_escapes_text() {
let p = Principal {
name: "ops",
display: "Ops; Night, Shift",
addresses: &[],
kind: UserType::Resource,
};
let text = card("urn:uuid:1", &p, "ops@resources.filebrowser.invalid");
assert!(text.contains(r"FN:Ops\; Night\, Shift"), "{text}");
assert!(text.contains("KIND:device\r\n"));
assert!(VCard::parse(&text).is_ok());
}
#[test]
fn list_all_and_stray_properties() {
// What python-caldav sends: no property-search, properties beside an
// empty `<prop>`.
let s = search(
r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop/><c:calendar-home-set/><d:displayname/>
</d:principal-property-search>"#,
);
let p = Principal {
name: "x",
display: "X",
addresses: &[],
kind: UserType::Individual,
};
assert!(s.matches(&p));
assert_eq!(
s.find,
Propfind::Prop(vec![
Name::new(pimdav::xml::CALDAV, "calendar-home-set"),
Name::new(DAV, "displayname")
])
);
}
▾Mpimdav/tests/report.rs
@@ -261,3 +261,37 @@ fn busy_time() {
]
);
}
#[test]
fn unicode_casemap_folds_after_nfkd() {
use pimdav::filter::{Collation, MatchType, TextMatch};
let m = |text: &str, collation| TextMatch {
text: text.to_string(),
collation,
match_type: MatchType::Equals,
negate: false,
};
let unicode = Collation::UnicodeCasemap;
assert!(m("Müller", unicode).matches("Mu\u{0308}ller"));
assert!(m("file", unicode).matches("FILE"));
assert!(m("ix", unicode).matches("\u{2168}"));
assert!(m("é", Collation::AsciiCasemap).matches("é"));
assert!(!m("é", Collation::AsciiCasemap).matches("É"));
}
#[test]
fn bad_filters_are_valid_filter_conditions() {
let bad_range = query(
r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
);
assert!(matches!(
parse(bad_range.as_bytes()),
Err(Refused::Condition(n)) if n == Name::new(CALDAV, "valid-filter")
));
let bad_test = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<card:filter test="sometimes"/></card:addressbook-query>"#;
assert!(matches!(
parse(bad_test.as_bytes()),
Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter")
));
}
▾Mserver/src/api/admin.rs
@@ -3,7 +3,10 @@
use std::sync::Arc;
use api_types::{AdminShare, AdminUser, CreateUser, Mode, OkResp, Root, Settings, UpdateUser};
use api_types::{
AdminShare, AdminUser, CreateRoom, CreateUser, Mode, OkResp, RoomInfo, RoomKind, Root,
Settings, UpdateRoom, UpdateUser,
};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
@@ -12,8 +15,9 @@ use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{
blocking, hash_password, root_info, validate_account_name, validate_password,
};
use crate::api::pim::principal_href;
use crate::api::shares;
use crate::db::RootRow;
use crate::db::{PimPrincipal, RootRow, UserType};
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -268,6 +272,101 @@ pub async fn delete_share(
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
// Rooms and resources
// ---------------------------------------------------------------------------
fn room_info(p: &PimPrincipal) -> RoomInfo {
RoomInfo {
id: p.id,
name: p.name.clone(),
display_name: p.display().to_string(),
kind: match p.kind {
UserType::Resource => RoomKind::Resource,
_ => RoomKind::Room,
},
url: principal_href(&p.name),
}
}
fn room_not_found() -> ApiError {
ApiError::new(StatusCode::NOT_FOUND, "room not found")
}
fn room_display_name(v: &str) -> Result<String, ApiError> {
let v = v.trim();
if v.is_empty() || v.chars().count() > 200 || v.chars().any(char::is_control) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid display name",
));
}
Ok(v.to_string())
}
/// GET /api/admin/rooms
pub async fn list_rooms(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
) -> Result<Json<Vec<RoomInfo>>, ApiError> {
Ok(Json(
state.db.rooms().await?.iter().map(room_info).collect(),
))
}
/// POST /api/admin/rooms — a room or resource with its booking calendar.
pub async fn create_room(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
Json(body): Json<CreateRoom>,
) -> Result<Json<RoomInfo>, ApiError> {
let name = body.name.trim().to_string();
validate_account_name(&name)?;
let display = room_display_name(body.display_name.as_deref().unwrap_or(&name))?;
let kind = match body.kind {
RoomKind::Room => UserType::Room,
RoomKind::Resource => UserType::Resource,
};
let room = state
.db
.create_room(&name, &display, kind)
.await?
.ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "the name is taken"))?;
Ok(Json(room_info(&room)))
}
/// PUT /api/admin/rooms/{id} — change the display name. The name stays: it
/// is the scheduling address.
pub async fn update_room(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdateRoom>,
) -> Result<Json<RoomInfo>, ApiError> {
let display = room_display_name(&body.display_name)?;
if !state.db.set_room_display_name(id, &display).await? {
return Err(room_not_found());
}
let rooms = state.db.rooms().await?;
let room = rooms
.iter()
.find(|r| r.id == id)
.ok_or_else(room_not_found)?;
Ok(Json(room_info(room)))
}
/// DELETE /api/admin/rooms/{id} — with its bookings.
pub async fn delete_room(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.delete_room(id).await? {
return Err(room_not_found());
}
Ok(Json(OkResp {}))
}
/// GET /api/admin/settings
pub async fn get_settings(
State(state): State<Arc<AppState>>,
▾Mserver/src/api/mod.rs
@@ -1,10 +1,10 @@
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, PIM, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX,
SHARES, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
ADMIN_ROOMS, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER,
AUTH_PASSWORD, AUTH_SETUP, DAV, DAV_SHARE, FILES, FINISH_SUFFIX, PIM, PIM_COLLECTIONS, SEARCH,
SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -94,6 +94,7 @@ mod dav;
mod files;
mod passkeys;
mod pim;
mod pim_api;
mod search;
mod shares;
mod spa;
@@ -112,6 +113,9 @@ pub fn router(state: Arc<AppState>) -> Router {
let passkey_register_finish = format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}");
let passkey_login_finish = format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}");
let admin_share_id = format!("{ADMIN_SHARES}/{{id}}");
let admin_room_id = format!("{ADMIN_ROOMS}/{{id}}");
let pim_shares = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}");
let pim_share = format!("{PIM_COLLECTIONS}/{{id}}{SHARES_SUFFIX}/{{user_id}}");
// A wildcard needs something to capture, so `/dav/` gets its own pattern:
// mount clients ask for it with the trailing slash, which matches neither
// the bare path nor `{*path}`.
@@ -169,6 +173,14 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(&admin_user_id, delete(admin::delete_user))
.route(ADMIN_SHARES, get(admin::list_shares))
.route(&admin_share_id, delete(admin::delete_share))
.route(ADMIN_ROOMS, get(admin::list_rooms).post(admin::create_room))
.route(
&admin_room_id,
put(admin::update_room).delete(admin::delete_room),
)
.route(PIM_COLLECTIONS, get(pim_api::list))
.route(&pim_shares, get(pim_api::shares).post(pim_api::share))
.route(&pim_share, delete(pim_api::unshare))
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
// `any`, not a method filter: WebDAV's verbs (PROPFIND, MKCOL, MOVE, …)
▾Mserver/src/api/pim.rs
@@ -2,11 +2,15 @@
//!
//! URL layout under [`PIM`]:
//!
//! * `/principals/{user}/`
//! * `/calendars/{user}/` and `/addressbooks/{user}/`, the homes
//! * `/calendars/{user}/{collection}/` and `.../{collection}/{object}`, the
//! * `/principals/` and `/principals/{name}/`: accounts, rooms and resources
//! * `/calendars/{name}/` and `/addressbooks/{name}/`, the homes
//! * `/calendars/{name}/{collection}/` and `.../{collection}/{object}`, the
//! same for address books
//!
//! A home also shows the collections lent to its account, as
//! `shared-{collection id}`, and the address book home shows the generated
//! system address book as `system`. A room's home holds its bookings.
//!
//! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto
//! the store and assembles the responses.
@@ -21,6 +25,7 @@ use axum::response::IntoResponse;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use pimdav::calcard::icalendar::ICalendar;
use pimdav::calcard::vcard::VCard;
use pimdav::principal::{self, Principal, Search, UserType};
use pimdav::render::{self, TooManyInstances};
use pimdav::report::{self, Props, Refused, Report};
use pimdav::xml::{
@@ -32,7 +37,9 @@ use pimdav::{filter, freebusy, object};
use sha2::{Digest, Sha256};
use xmltree::Element;
use crate::db::{PimCollection, PimKind, PimObject, PimWrite, Precondition};
use crate::db::{
Mode, PimCollection, PimKind, PimObject, PimPrincipal, PimWrite, Precondition, User,
};
use crate::error::{ApiError, AppState};
/// Largest object a PUT may store. Contacts carry photos inline.
@@ -45,6 +52,12 @@ const MAX_XML_SIZE: usize = 1024 * 1024;
/// (RFC 2606), so nothing sent there can reach anyone.
const MAIL_DOMAIN: &str = "filebrowser.invalid";
/// The id of the system address book, which no stored collection has.
const DIRECTORY: i64 = 0;
const DIRECTORY_SLUG: &str = "system";
/// The slug prefix of a collection lent to the account.
const SHARED_PREFIX: &str = "shared-";
/// Characters escaped in an href segment.
const SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
@@ -64,9 +77,12 @@ const SEGMENT: &AsciiSet = &CONTROLS
type Reply = Result<Response<Body>, ApiError>;
/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
///
/// 307, not 301: HTTP clients drop the body on a 301, and python-caldav sends
/// its principal search to the URL it was configured with.
pub async fn well_known() -> Response<Body> {
(
StatusCode::MOVED_PERMANENTLY,
StatusCode::TEMPORARY_REDIRECT,
[(LOCATION, format!("{PIM}/"))],
)
.into_response()
@@ -82,18 +98,30 @@ pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> R
.unwrap_or_else(IntoResponse::into_response)
}
/// The signed-in user.
/// The signed-in account.
struct Me {
id: i64,
name: String,
/// The user segment of the hrefs: the name as the request spelled it.
/// A client that asked for `/ALICE/` must get hrefs it recognises.
admin: bool,
/// The own principal href. Spelled as the request spelled the name when
/// it named this account: a client that asked for `/ALICE/` must get
/// hrefs it recognises.
principal: String,
}
/// The principal whose URLs a request addresses: the signed-in account, or
/// a room or resource. Another account's principal is readable too.
struct Space {
id: i64,
/// The URL segment, as the request spelled it.
path: String,
display: String,
kind: UserType,
mine: bool,
}
impl Me {
impl Space {
fn principal(&self) -> String {
format!("{PIM}/principals/{}/", seg(&self.path))
principal_href(&self.path)
}
fn home(&self, kind: PimKind) -> String {
@@ -109,48 +137,125 @@ impl Me {
}
}
/// The URL of a principal.
pub(crate) fn principal_href(name: &str) -> String {
format!("{PIM}/principals/{}/", seg(name))
}
/// The URL of a collection in the home of `user`, whether it owns it or
/// has it lent (`lent_id`).
pub(crate) fn collection_href(
user: &str,
kind: PimKind,
slug: &str,
lent_id: Option<i64>,
) -> String {
let slug = match lent_id {
Some(id) => format!("{SHARED_PREFIX}{id}"),
None => slug.to_string(),
};
format!("{PIM}/{}/{}/{}/", kind_segment(kind), seg(user), seg(&slug))
}
/// What the signed-in account may do with a collection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Access {
Read,
/// Change members, not the collection's own properties.
Write,
Own,
}
/// A collection as the signed-in account sees it.
struct Col {
/// `slug` and `displayname` as this account sees them.
c: PimCollection,
access: Access,
/// The principal href of the owner.
owner: String,
}
async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
let Some(name) = state.db.user_name(user_id).await? else {
let Some(user) = state.db.find_user_by_id(user_id).await? else {
return Ok(status(StatusCode::UNAUTHORIZED));
};
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(&name))
{
return Ok(status(StatusCode::FORBIDDEN));
}
let me = Me {
id: user_id,
path: target.owner().unwrap_or(&name).to_string(),
name,
let (me, space) = match resolve_space(state, &user, &target).await? {
Ok(v) => v,
Err(code) => return Ok(status(code)),
};
state.db.pim_ensure_defaults(me.id).await?;
let method = req.method().clone();
let (parts, body) = req.into_parts();
let cx = Cx {
state,
me: &me,
space: space.as_ref(),
};
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" => report(state, &me, &target, body).await,
"MOVE" => move_object(state, &me, &target, &parts.headers).await,
"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,
"PUT" => cx.put(&target, &parts.headers, body).await,
"DELETE" => cx.delete(&target, &parts.headers).await,
"REPORT" => cx.report(&target, body).await,
"MOVE" => cx.move_object(&target, &parts.headers).await,
_ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
}
}
/// Who asks, and in whose URL space. Another account's space is off limits
/// except for its principal.
async fn resolve_space(
state: &AppState,
user: &User,
target: &Target,
) -> Result<Result<(Me, Option<Space>), StatusCode>, ApiError> {
let mut me = Me {
id: user.id,
admin: user.is_admin,
principal: principal_href(&user.name),
};
let Some(segment) = target.owner() else {
return Ok(Ok((me, None)));
};
if segment.eq_ignore_ascii_case(&user.name) {
me.principal = principal_href(segment);
let space = Space {
id: user.id,
path: segment.to_string(),
display: user.name.clone(),
kind: UserType::Individual,
mine: true,
};
return Ok(Ok((me, Some(space))));
}
let Some(p) = state.db.pim_principal(segment).await? else {
return Ok(Err(StatusCode::NOT_FOUND));
};
if p.kind == UserType::Individual && !matches!(target, Target::Principal(_)) {
return Ok(Err(StatusCode::FORBIDDEN));
}
let space = Space {
id: p.id,
path: segment.to_string(),
display: p.display().to_string(),
kind: p.kind,
mine: false,
};
Ok(Ok((me, Some(space))))
}
#[derive(Debug)]
enum Target {
Root,
Principals,
Principal(String),
Home(PimKind, String),
Collection(PimKind, String, String),
@@ -160,7 +265,7 @@ enum Target {
impl Target {
fn owner(&self) -> Option<&str> {
match self {
Target::Root => None,
Target::Root | Target::Principals => None,
Target::Principal(u)
| Target::Home(_, u)
| Target::Collection(_, u, _)
@@ -189,9 +294,11 @@ fn parse_target(path: &str) -> Option<Target> {
};
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 mut rest = rest.into_iter();
return match (rest.next(), rest.next()) {
(None, _) => Some(Target::Principals),
(Some(user), None) => Some(Target::Principal(user)),
_ => None,
};
}
let kind = kind(&first)?;
@@ -211,6 +318,13 @@ fn kind_segment(kind: PimKind) -> &'static str {
}
}
fn kind_ns(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => CALDAV,
PimKind::AddressBook => CARDDAV,
}
}
fn seg(s: &str) -> String {
utf8_percent_encode(s, SEGMENT).to_string()
}
@@ -233,11 +347,28 @@ fn error(code: StatusCode, condition: Element) -> Response<Body> {
xml_response(code, xml::error(condition))
}
/// 403 for a lacking privilege on `href` (RFC 3744, 7.1.1).
fn denied(href: &str, privilege: &str) -> Response<Body> {
error(
StatusCode::FORBIDDEN,
with_children(
el(DAV, "need-privileges"),
[with_children(
el(DAV, "resource"),
[
with_text(el(DAV, "href"), href),
with_children(el(DAV, "privilege"), [el(DAV, privilege)]),
],
)],
),
)
}
fn options() -> Response<Body> {
(
StatusCode::OK,
[
("dav", "1, 3, calendar-access, addressbook, extended-mkcol"),
("dav", "1, 3, access-control, calendar-access, addressbook, extended-mkcol"),
(
ALLOW.as_str(),
"OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
@@ -251,6 +382,242 @@ async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
axum::body::to_bytes(body, limit).await.ok()
}
fn etag_of(data: &[u8]) -> String {
format!("\"{}\"", crate::hex(&Sha256::digest(data)[..16]))
}
/// A stable UUID per principal, for the `urn:uuid:` calendar user address.
fn principal_uuid(id: i64) -> String {
let h = crate::hex(&Sha256::digest(format!("filebrowser-ng principal {id}"))[..16]);
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
/// The scheduling address of a principal. Rooms and resources use their own
/// subdomains, so no account name can take their address.
fn mailto(name: &str, kind: UserType) -> String {
let domain = match kind {
UserType::Individual => MAIL_DOMAIN.to_string(),
UserType::Room => format!("rooms.{MAIL_DOMAIN}"),
UserType::Resource => format!("resources.{MAIL_DOMAIN}"),
};
format!("{}@{domain}", seg(name))
}
/// A principal as PROPFIND and the searches describe it.
struct PrincipalView {
id: i64,
/// The URL segment.
path: String,
display: String,
kind: UserType,
/// The signed-in account itself.
me: bool,
}
impl PrincipalView {
fn of(p: &PimPrincipal, me: &Me) -> Self {
PrincipalView {
id: p.id,
path: p.name.clone(),
display: p.display().to_string(),
kind: p.kind,
me: p.id == me.id,
}
}
fn addresses(&self) -> Vec<String> {
vec![
format!("mailto:{}", mailto(&self.path, self.kind)),
principal_href(&self.path),
format!("urn:uuid:{}", principal_uuid(self.id)),
]
}
}
// ---------------------------------------------------------------------------
// Collections and members
// ---------------------------------------------------------------------------
/// The generated system address book: one card per visible principal.
async fn directory(
state: &AppState,
) -> Result<(PimCollection, Vec<(PimObject, Vec<u8>)>), ApiError> {
let mut members = Vec::new();
for p in state.db.pim_principals().await? {
let uuid = principal_uuid(p.id);
let uid = format!("urn:uuid:{uuid}");
let addresses: [String; 0] = [];
let view = Principal {
name: &p.name,
display: p.display(),
addresses: &addresses,
kind: p.kind,
};
let data = principal::card(&uid, &view, &mailto(&p.name, p.kind)).into_bytes();
let obj = PimObject {
name: format!("{uuid}.vcf"),
uid,
component: "VCARD".to_string(),
etag: etag_of(&data),
size: data.len() as i64,
modified_at: String::new(),
};
members.push((obj, data));
}
// The members' ETags stand in for a change counter: any added, removed or
// renamed principal changes the CTag and the sync token.
let digest = Sha256::digest(
members
.iter()
.map(|(o, _)| o.etag.as_str())
.collect::<String>(),
);
let seq = i64::from_be_bytes(digest[..8].try_into().expect("8 bytes")) & i64::MAX;
let col = PimCollection {
id: DIRECTORY,
slug: DIRECTORY_SLUG.to_string(),
displayname: Some("Directory".to_string()),
seq,
..Default::default()
};
Ok((col, members))
}
/// The request context: who asks, and in whose URL space.
struct Cx<'a> {
state: &'a AppState,
me: &'a Me,
space: Option<&'a Space>,
}
impl Cx<'_> {
fn space(&self) -> &Space {
self.space.expect("targets with an owner resolve a space")
}
/// A collection of the space by slug, with the access of the signed-in
/// account.
async fn collection(&self, kind: PimKind, slug: &str) -> Result<Option<Col>, ApiError> {
let space = self.space();
let db = &self.state.db;
if !space.mine {
// A room: everyone reads its bookings, admins may change them.
let access = if self.me.admin {
Access::Write
} else {
Access::Read
};
return Ok(db.pim_collection(space.id, kind, slug).await?.map(|c| Col {
c,
access,
owner: space.principal(),
}));
}
if let Some(c) = db.pim_collection(space.id, kind, slug).await? {
return Ok(Some(Col {
c,
access: Access::Own,
owner: space.principal(),
}));
}
if kind == PimKind::AddressBook && slug == DIRECTORY_SLUG {
return Ok(Some(Col {
c: directory(self.state).await?.0,
access: Access::Read,
owner: space.principal(),
}));
}
let Some(id) = slug
.strip_prefix(SHARED_PREFIX)
.and_then(|id| id.parse().ok())
else {
return Ok(None);
};
Ok(db
.pim_shared_collection(self.me.id, kind, id)
.await?
.map(|(c, owner, mode)| lent(c, &owner, mode)))
}
/// Every collection of `kind` in the space's home.
async fn collections(&self, kind: PimKind) -> Result<Vec<Col>, ApiError> {
let space = self.space();
let db = &self.state.db;
let own = if space.mine {
Access::Own
} else if self.me.admin {
Access::Write
} else {
Access::Read
};
let mut out: Vec<Col> = db
.pim_collections(space.id, kind)
.await?
.into_iter()
.map(|c| Col {
c,
access: own,
owner: space.principal(),
})
.collect();
if space.mine {
if kind == PimKind::AddressBook {
out.push(Col {
c: directory(self.state).await?.0,
access: Access::Read,
owner: space.principal(),
});
}
for (c, owner, mode) in db.pim_shared_collections(self.me.id, kind).await? {
out.push(lent(c, &owner, mode));
}
}
Ok(out)
}
async fn members(&self, c: &PimCollection) -> Result<Vec<(PimObject, Vec<u8>)>, ApiError> {
if c.id == DIRECTORY {
return Ok(directory(self.state).await?.1);
}
Ok(self.state.db.pim_objects_with_data(c.id).await?)
}
async fn member(
&self,
c: &PimCollection,
name: &str,
) -> Result<Option<(PimObject, Vec<u8>)>, ApiError> {
if c.id == DIRECTORY {
let all = directory(self.state).await?.1;
return Ok(all.into_iter().find(|(o, _)| o.name == name));
}
Ok(self.state.db.pim_object(c.id, name).await?)
}
}
/// A collection lent to the signed-in account, as it appears in their home.
fn lent(mut c: PimCollection, owner: &str, mode: Mode) -> Col {
let name = c.displayname.take().unwrap_or_else(|| c.slug.clone());
c.displayname = Some(format!("{name} ({owner})"));
c.slug = format!("{SHARED_PREFIX}{}", c.id);
Col {
c,
access: if mode.is_writable() {
Access::Write
} else {
Access::Read
},
owner: principal_href(owner),
}
}
// ---------------------------------------------------------------------------
// PROPFIND
// ---------------------------------------------------------------------------
@@ -258,81 +625,283 @@ async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
/// A resource PROPFIND can describe.
enum Res {
Root,
Principal,
Home,
Collection(PimKind, PimCollection),
Principals,
Principal(PrincipalView),
/// With its owner's principal href and whether the account may add to it.
Home(String, Access),
Collection(PimKind, Col),
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));
};
impl Cx<'_> {
async fn propfind(&self, 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)));
let mut list: Vec<(String, Res)> = Vec::new();
match target {
Target::Root => list.push((format!("{PIM}/"), Res::Root)),
Target::Principals => {
list.push((format!("{PIM}/principals/"), Res::Principals));
if deep {
for p in self.state.db.pim_principals().await? {
list.push((
principal_href(&p.name),
Res::Principal(PrincipalView::of(&p, self.me)),
));
}
}
}
}
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)));
Target::Principal(_) => {
let s = self.space();
list.push((
s.principal(),
Res::Principal(PrincipalView {
id: s.id,
path: s.path.clone(),
display: s.display.clone(),
kind: s.kind,
me: s.mine,
}),
));
}
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)));
if deep {
for col in self.collections(*kind).await? {
list.push((
s.collection(*kind, &col.c.slug),
Res::Collection(*kind, col),
));
}
}
}
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)));
Target::Collection(kind, _, slug) => {
let Some(col) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let objects = match (deep, col.c.id) {
(false, _) => Vec::new(),
(true, DIRECTORY) => self
.members(&col.c)
.await?
.into_iter()
.map(|(o, _)| o)
.collect(),
(true, id) => self.state.db.pim_objects(id).await?,
};
let s = self.space();
let slug = col.c.slug.clone();
list.push((s.collection(*kind, &slug), Res::Collection(*kind, col)));
for o in objects {
list.push((s.object(*kind, &slug, &o.name), Res::Object(*kind, o)));
}
}
Target::Object(kind, _, slug, name) => {
let found = match self.collection(*kind, slug).await? {
Some(col) => self.member(&col.c, name).await?,
None => None,
};
let Some((o, _)) = found else {
return Ok(status(StatusCode::NOT_FOUND));
};
list.push((
self.space().object(*kind, slug, name),
Res::Object(*kind, o),
));
}
}
let responses: Vec<xml::Response> = list
.into_iter()
.map(|(href, res)| select(href, &request, self.props(&res)))
.collect();
Ok(multistatus(&responses, None))
}
let responses: Vec<xml::Response> = list
.into_iter()
.map(|(href, res)| select(href, &request, props(me, &res)))
.collect();
Ok(multistatus(&responses, None))
/// 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);
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 principals = format!("{PIM}/principals/");
let mut out = vec![
href_prop(DAV, "current-user-principal", &self.me.principal),
href_prop(DAV, "principal-collection-set", &principals),
];
match res {
Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
Res::Principals => out.extend([
resourcetype(&[(DAV, "collection")]),
privileges(Access::Read),
principal_reports(),
]),
Res::Principal(p) => {
// The own principal in the spelling of the request.
let href = match p.me {
true => self.me.principal.clone(),
false => principal_href(&p.path),
};
let addresses = p.addresses();
out.extend([
resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
text(DAV, "displayname", &p.display),
href_prop(DAV, "principal-URL", &href),
with_children(
el(CALDAV, "calendar-user-address-set"),
hrefs(addresses.iter().map(String::as_str)),
),
with_children(
el(CALSERVER, "email-address-set"),
[with_text(
el(CALSERVER, "email-address"),
mailto(&p.path, p.kind),
)],
),
text(CALDAV, "calendar-user-type", p.kind.as_str()),
privileges(if p.me { Access::Own } else { Access::Read }),
principal_reports(),
]);
let home = |kind: PimKind| {
let name = match p.me {
true => self.space.map_or(p.path.clone(), |s| s.path.clone()),
false => p.path.clone(),
};
format!("{PIM}/{}/{}/", kind_segment(kind), seg(&name))
};
// Also for other accounts: python-caldav drops a search hit
// without one. Their homes still answer 403.
out.push(href_prop(
CALDAV,
"calendar-home-set",
&home(PimKind::Calendar),
));
if p.me {
let book = home(PimKind::AddressBook);
out.push(href_prop(CARDDAV, "addressbook-home-set", &book));
out.push(href_prop(
CARDDAV,
"directory-gateway",
&format!("{book}{DIRECTORY_SLUG}/"),
));
}
}
Res::Home(owner, access) => out.extend([
resourcetype(&[(DAV, "collection")]),
href_prop(DAV, "owner", owner),
privileges(*access),
]),
Res::Collection(kind, col) => {
let c = &col.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", &col.owner),
privileges(col.access),
supported_reports(*kind),
text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c.id, c.seq)),
text(
kind_ns(*kind),
"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
}
}
/// The response for one resource: the requested ones of `all`, and 404 for
@@ -364,140 +933,20 @@ fn multistatus(responses: &[xml::Response], tail: Option<Element>) -> Response<B
)
}
/// 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(),
supported_reports(*kind),
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 report_set(reports: &[(&str, &str)]) -> Element {
with_children(
el(DAV, "supported-report-set"),
reports.iter().map(|(ns, local)| {
with_children(
el(DAV, "supported-report"),
[with_children(el(DAV, "report"), [el(ns, local)])],
)
}),
)
}
fn supported_reports(kind: PimKind) -> Element {
let reports: &[(&str, &str)] = match kind {
report_set(match kind {
PimKind::Calendar => &[
(CALDAV, "calendar-multiget"),
(CALDAV, "calendar-query"),
@@ -509,52 +958,50 @@ fn supported_reports(kind: PimKind) -> Element {
(CARDDAV, "addressbook-query"),
(DAV, "sync-collection"),
],
};
with_children(
el(DAV, "supported-report-set"),
reports.iter().map(|(ns, local)| {
with_children(
el(DAV, "supported-report"),
[with_children(el(DAV, "report"), [el(ns, local)])],
)
}),
)
})
}
fn privileges() -> Element {
let names = [
"all",
"read",
"write",
"write-properties",
"write-content",
"bind",
"unbind",
"read-current-user-privilege-set",
];
fn principal_reports() -> Element {
report_set(&[
(DAV, "principal-property-search"),
(DAV, "principal-search-property-set"),
(CALSERVER, "calendarserver-principal-search"),
])
}
fn privileges(access: Access) -> Element {
let names: &[&str] = match access {
Access::Own => &[
"all",
"read",
"write",
"write-properties",
"write-content",
"bind",
"unbind",
"read-current-user-privilege-set",
],
Access::Write => &[
"read",
"write-content",
"bind",
"unbind",
"read-current-user-privilege-set",
],
Access::Read => &["read", "read-current-user-privilege-set"],
};
with_children(
el(DAV, "current-user-privilege-set"),
names.map(|n| with_children(el(DAV, "privilege"), [el(DAV, n)])),
names
.iter()
.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 sync_token(id: i64, seq: i64) -> String {
format!("urn:fbng:sync:{id}-{seq}")
}
fn content_type(kind: PimKind, component: &str) -> String {
@@ -568,80 +1015,102 @@ fn content_type(kind: PimKind, component: &str) -> 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);
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 = 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 mut r = xml::Response::new(href);
for (code, prop) in results {
r.push(code, prop);
}
Ok(multistatus(&[r], None))
}
Ok(multistatus(&[r], None))
}
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,VJOURNAL".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"),
async fn mkcol(&self, target: &Target, method: &str, body: Body) -> Reply {
let Target::Collection(kind, _, slug) = target else {
return Ok(status(StatusCode::FORBIDDEN));
};
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));
let space = self.space();
if !space.mine {
return Ok(denied(&space.home(*kind), "bind"));
}
let calendar = method == "MKCALENDAR";
if calendar && *kind != PimKind::Calendar {
return Ok(status(StatusCode::FORBIDDEN));
}
if self.collection(*kind, slug).await?.is_some() {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
}
// Names the home shows for lent and generated collections.
if slug.starts_with(SHARED_PREFIX) || slug == DIRECTORY_SLUG {
return Ok(status(StatusCode::FORBIDDEN));
}
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,VJOURNAL".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 !self
.state
.db
.pim_create_collection(self.me.id, *kind, &col)
.await?
{
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
}
Ok(status(StatusCode::CREATED))
}
Ok(status(StatusCode::CREATED))
}
fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
@@ -759,113 +1228,132 @@ fn is_timezone(v: &str) -> bool {
// 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)),
impl Cx<'_> {
async fn get(&self, target: &Target, head: bool) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let found = match self.collection(*kind, slug).await? {
Some(col) => self.member(&col.c, 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 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
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));
};
let Some(Col { c: col, access, .. }) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::CONFLICT));
};
let space = self.space();
if access < Access::Write {
return Ok(denied(&space.collection(*kind, slug), "bind"));
}
let ns = kind_ns(*kind);
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 = etag_of(&data);
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 self
.state
.db
.pim_delete_object(col.id, name, &precondition(headers))
.pim_put_object(col.id, &obj, &data, &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),
},
)
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([space.object(*kind, slug, &holder).as_str()]),
),
)),
PimWrite::Deleted | PimWrite::NotFound => Ok(status(StatusCode::INTERNAL_SERVER_ERROR)),
}
}
async fn delete(&self, 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 { c: col, access, .. }) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let space = self.space();
let href = space.collection(*kind, slug);
let Some(name) = name else {
return Ok(match access {
Access::Own => {
self.state.db.pim_delete_collection(col.id).await?;
status(StatusCode::NO_CONTENT)
}
// Deleting a lent collection only takes it out of this home.
_ if slug.starts_with(SHARED_PREFIX) && space.mine => {
self.state.db.pim_remove_share(col.id, self.me.id).await?;
status(StatusCode::NO_CONTENT)
}
_ => denied(&space.home(*kind), "unbind"),
});
};
if access < Access::Write {
return Ok(denied(&href, "unbind"));
}
Ok(
match self
.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 {
@@ -885,186 +1373,300 @@ fn precondition(headers: &HeaderMap) -> Precondition {
// REPORT
// ---------------------------------------------------------------------------
async fn report(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply {
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let report = match report::parse(&body) {
Ok(r) => r,
Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
};
let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
let Target::Collection(kind, _, slug) = target else {
return unsupported();
};
let calendar_report = matches!(
report,
Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
);
let card_report = matches!(
report,
Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
);
if (calendar_report && *kind != PimKind::Calendar)
|| (card_report && *kind != PimKind::AddressBook)
{
return unsupported();
}
let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let floating = col
.timezone
.as_deref()
.and_then(zone::from_vtimezone)
.unwrap_or(Zone::Utc);
let out = Out {
me,
kind: *kind,
col: &col,
};
impl Cx<'_> {
async fn report(&self, target: &Target, body: Body) -> Reply {
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let report = match report::parse(&body) {
Ok(r) => r,
Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
};
let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
let on_principals = matches!(
target,
Target::Root | Target::Principals | Target::Principal(_)
);
match report {
Report::PrincipalSearch(search) if on_principals => {
return self.principal_search(&search).await;
}
Report::PrincipalSearchPropertySet if on_principals => {
return Ok(search_property_set());
}
Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
return unsupported();
}
_ => {}
}
let Target::Collection(kind, _, slug) = target else {
return unsupported();
};
let calendar_report = matches!(
report,
Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
);
let card_report = matches!(
report,
Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
);
if (calendar_report && *kind != PimKind::Calendar)
|| (card_report && *kind != PimKind::AddressBook)
{
return unsupported();
}
let Some(Col { c: col, .. }) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let floating = col
.timezone
.as_deref()
.and_then(zone::from_vtimezone)
.unwrap_or(Zone::Utc);
let out = Out {
cx: self,
kind: *kind,
col: &col,
};
match report {
Report::CalendarMultiget { props, hrefs }
| Report::AddressbookMultiget { props, hrefs } => {
let mut responses = Vec::new();
for href in hrefs {
let found = match own_object(me, *kind, &href) {
Some((slug, name)) if slug == col.slug => {
state.db.pim_object(col.id, &name).await?
match report {
Report::CalendarMultiget { props, hrefs }
| Report::AddressbookMultiget { props, hrefs } => {
let mut responses = Vec::new();
for href in hrefs {
let found = match self.own_object(*kind, &href) {
Some((slug, name)) if slug == col.slug => self.member(&col, &name).await?,
_ => None,
};
responses.push(match found {
// The href as the client wrote it, so it can match it.
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => xml::Response { href, ..r },
Err(TooManyInstances) => return Ok(too_many()),
},
None => xml::Response::status(href, 404),
});
}
Ok(multistatus(&responses, None))
}
Report::CalendarQuery {
props,
filter,
timezone,
} => {
let floating = timezone.unwrap_or(floating);
let mut responses = Vec::new();
for (o, data) in self.members(&col).await? {
let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
continue;
};
if filter::matches_calendar(&cal, &filter, &floating) {
match out.object(&o, &data, &props, &floating) {
Ok(r) => responses.push(r),
Err(TooManyInstances) => return Ok(too_many()),
}
}
_ => None,
};
responses.push(match found {
// The href as the client wrote it, so it can match it.
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => xml::Response { href, ..r },
Err(TooManyInstances) => return Ok(too_many()),
},
None => xml::Response::status(href, 404),
});
}
Ok(multistatus(&responses, None))
}
Ok(multistatus(&responses, None))
}
Report::CalendarQuery {
props,
filter,
timezone,
} => {
let floating = timezone.unwrap_or(floating);
let mut responses = Vec::new();
for (o, data) in state.db.pim_objects_with_data(col.id).await? {
let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
continue;
};
if filter::matches_calendar(&cal, &filter, &floating) {
match out.object(&o, &data, &props, &floating) {
Ok(r) => responses.push(r),
Err(TooManyInstances) => return Ok(too_many()),
Report::AddressbookQuery {
props,
filter,
limit,
} => {
let mut responses = Vec::new();
let mut truncated = false;
for (o, data) in self.members(&col).await? {
let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
continue;
};
if !filter::matches_card(&card, &filter) {
continue;
}
if limit.is_some_and(|n| responses.len() >= n) {
truncated = true;
break;
}
if let Ok(r) = out.object(&o, &data, &props, &floating) {
responses.push(r);
}
}
if truncated {
responses.push(out.over_limit());
}
Ok(multistatus(&responses, None))
}
Ok(multistatus(&responses, None))
}
Report::AddressbookQuery {
props,
filter,
limit,
} => {
let mut responses = Vec::new();
let mut truncated = false;
for (o, data) in state.db.pim_objects_with_data(col.id).await? {
let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
continue;
Report::SyncCollection {
token,
props,
limit,
} => {
let since = match token.is_empty() {
true => None,
false => match parse_sync_token(&token) {
// The system address book has no change log: only
// its current token is valid.
Some((DIRECTORY, seq)) if col.id == DIRECTORY && seq == col.seq => {
Some(seq)
}
Some((id, seq))
if id == col.id && col.id != DIRECTORY && seq <= col.seq =>
{
Some(seq)
}
_ => {
return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token")));
}
},
};
let mut changes = if col.id == DIRECTORY {
match since {
Some(_) => Vec::new(),
None => self
.members(&col)
.await?
.into_iter()
.map(|(o, _)| (o.name, col.seq, false))
.collect(),
}
} else {
self.state.db.pim_changes(col.id, since).await?
};
if !filter::matches_card(&card, &filter) {
continue;
let truncated = limit.is_some_and(|n| changes.len() > n);
if let Some(n) = limit {
changes.truncate(n);
}
if limit.is_some_and(|n| responses.len() >= n) {
truncated = true;
break;
// A truncated answer hands out the token of its last change, so
// the next sync resumes after it.
let seq = match (truncated, changes.last()) {
(true, Some((_, s, _))) if col.id != DIRECTORY => *s,
_ if col.id == DIRECTORY => col.seq,
(_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
};
let mut responses = Vec::new();
for (name, _, deleted) in changes {
let href = self.space().object(*kind, &col.slug, &name);
let found = match deleted {
true => None,
false => self.member(&col, &name).await?,
};
responses.push(match found {
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => r,
Err(TooManyInstances) => return Ok(too_many()),
},
None => xml::Response::status(href, 404),
});
}
if let Ok(r) = out.object(&o, &data, &props, &floating) {
responses.push(r);
if truncated {
responses.push(out.over_limit());
}
Ok(multistatus(
&responses,
Some(with_text(el(DAV, "sync-token"), sync_token(col.id, seq))),
))
}
if truncated {
responses.push(out.over_limit());
}
Ok(multistatus(&responses, None))
}
Report::SyncCollection {
token,
props,
limit,
} => {
let since = if token.is_empty() {
None
} else {
match parse_sync_token(&token) {
Some((id, seq)) if id == col.id && seq <= col.seq => Some(seq),
_ => return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))),
Report::FreeBusy(range) => {
let mut busy = Vec::new();
for (_, data) in self.members(&col).await? {
if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
// ponytail: one period per instance, so a long range over
// a frequent series makes a long answer.
busy.extend(freebusy::busy(&cal, &range, &floating));
}
}
};
let mut changes = state.db.pim_changes(col.id, since).await?;
let truncated = limit.is_some_and(|n| changes.len() > n);
if let Some(n) = limit {
changes.truncate(n);
let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "text/calendar; charset=utf-8")],
body,
)
.into_response())
}
// A truncated answer hands out the token of its last change, so
// the next sync resumes after it.
let seq = match (truncated, changes.last()) {
(true, Some((_, s, _))) => *s,
(_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
Report::PrincipalSearch(_) | Report::PrincipalSearchPropertySet => {
unreachable!("answered above")
}
}
}
/// principal-property-search and calendarserver-principal-search.
async fn principal_search(&self, search: &Search) -> Reply {
let mut responses = Vec::new();
let mut truncated = false;
for p in self.state.db.pim_principals().await? {
let view = PrincipalView::of(&p, self.me);
let addresses = view.addresses();
let candidate = Principal {
name: &p.name,
display: p.display(),
addresses: &addresses,
kind: p.kind,
};
let mut responses = Vec::new();
for (name, _, deleted) in changes {
let href = me.object(*kind, &col.slug, &name);
let found = match deleted {
true => None,
false => state.db.pim_object(col.id, &name).await?,
};
responses.push(match found {
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => r,
Err(TooManyInstances) => return Ok(too_many()),
},
None => xml::Response::status(href, 404),
});
if !search.matches(&candidate) {
continue;
}
if truncated {
responses.push(out.over_limit());
if search.limit.is_some_and(|n| responses.len() >= n) {
truncated = true;
break;
}
let token = format!("urn:fbng:sync:{}-{seq}", col.id);
Ok(multistatus(
&responses,
Some(with_text(el(DAV, "sync-token"), token)),
))
let href = principal_href(&p.name);
responses.push(select(
href,
&search.find,
self.props(&Res::Principal(view)),
));
}
Report::FreeBusy(range) => {
let mut busy = Vec::new();
for (_, data) in state.db.pim_objects_with_data(col.id).await? {
if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
// ponytail: one period per instance, so a long range over
// a frequent series makes a long answer.
busy.extend(freebusy::busy(&cal, &range, &floating));
}
if truncated {
let mut r = xml::Response::status(format!("{PIM}/principals/"), 507);
r.error = Some(el(DAV, "number-of-matches-within-limits"));
responses.push(r);
}
Ok(multistatus(&responses, None))
}
/// `(collection slug, object name)` of an href to an object of `kind` in
/// the space of this request. Takes a path or a full URL.
fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
};
let space = self.space?;
match parse_target(path.strip_prefix(PIM)?)? {
Target::Object(k, owner, slug, name)
if k == kind && owner.eq_ignore_ascii_case(&space.path) =>
{
Some((slug, name))
}
let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "text/calendar; charset=utf-8")],
body,
)
.into_response())
_ => None,
}
}
}
fn search_property_set() -> Response<Body> {
let body = xml::document(&with_children(
el(DAV, "principal-search-property-set"),
principal::SEARCHABLE.map(|(ns, local, description)| {
with_children(
el(DAV, "principal-search-property"),
[
with_children(el(DAV, "prop"), [el(ns, local)]),
with_attr(
with_text(el(DAV, "description"), description),
"xml:lang",
"en",
),
],
)
}),
));
xml_response(StatusCode::OK, body)
}
/// What a REPORT answer about one collection needs.
struct Out<'a> {
me: &'a Me,
cx: &'a Cx<'a>,
kind: PimKind,
col: &'a PimCollection,
}
@@ -1077,7 +1679,7 @@ impl Out<'_> {
props: &Props,
floating: &Zone,
) -> Result<xml::Response, TooManyInstances> {
let mut all = self::props(self.me, &Res::Object(self.kind, o.clone()));
let mut all = self.cx.props(&Res::Object(self.kind, o.clone()));
let raw = String::from_utf8_lossy(data);
if let Some(req) = &props.calendar {
let text = render::calendar_data(&raw, req, floating)?;
@@ -1089,13 +1691,14 @@ impl Out<'_> {
render::address_data(&raw, req),
));
}
let href = self.me.object(self.kind, &self.col.slug, &o.name);
let href = self.cx.space().object(self.kind, &self.col.slug, &o.name);
Ok(select(href, &props.find, all))
}
/// The response a query or sync adds when a client limit cut it short.
fn over_limit(&self) -> xml::Response {
let mut r = xml::Response::status(self.me.collection(self.kind, &self.col.slug), 507);
let href = self.cx.space().collection(self.kind, &self.col.slug);
let mut r = xml::Response::status(href, 507);
r.error = Some(el(DAV, "number-of-matches-within-limits"));
r
}
@@ -1111,85 +1714,72 @@ fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
Some((id.parse().ok()?, seq.parse().ok()?))
}
/// `(collection slug, object name)` of an href to one of the user's own
/// objects of `kind`. Takes a path or a full URL.
fn own_object(me: &Me, kind: PimKind, href: &str) -> Option<(String, String)> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
};
match parse_target(path.strip_prefix(PIM)?)? {
Target::Object(k, owner, slug, name)
if k == kind && owner.eq_ignore_ascii_case(&me.name) =>
{
Some((slug, name))
}
_ => None,
}
}
// ---------------------------------------------------------------------------
// MOVE
// ---------------------------------------------------------------------------
async fn move_object(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let destination = headers.get("destination").and_then(|v| v.to_str().ok());
let Some((to_slug, to_name)) = destination.and_then(|d| own_object(me, *kind, d)) else {
return Ok(status(StatusCode::FORBIDDEN));
};
if (&to_slug, &to_name) == (slug, name) {
return Ok(status(StatusCode::FORBIDDEN));
}
let Some(from) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let Some(to) = state.db.pim_collection(me.id, *kind, &to_slug).await? else {
return Ok(status(StatusCode::CONFLICT));
};
let Some((obj, _)) = state.db.pim_object(from.id, name).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
if *kind == PimKind::Calendar && !to.components.split(',').any(|c| c == obj.component) {
return Ok(error(
StatusCode::FORBIDDEN,
el(CALDAV, "supported-calendar-component"),
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
Ok(
match state
.db
.pim_move_object(
from.id,
name,
to.id,
&to_name,
overwrite,
&precondition(headers),
)
.await?
{
PimWrite::Created => status(StatusCode::CREATED),
PimWrite::Updated => status(StatusCode::NO_CONTENT),
PimWrite::NotFound => status(StatusCode::NOT_FOUND),
PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
PimWrite::UidConflict(holder) => error(
impl Cx<'_> {
async fn move_object(&self, target: &Target, headers: &HeaderMap) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let destination = headers.get("destination").and_then(|v| v.to_str().ok());
let Some((to_slug, to_name)) = destination.and_then(|d| self.own_object(*kind, d)) else {
return Ok(status(StatusCode::FORBIDDEN));
};
if (&to_slug, &to_name) == (slug, name) {
return Ok(status(StatusCode::FORBIDDEN));
}
let space = self.space();
let Some(from) = self.collection(*kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let Some(to) = self.collection(*kind, &to_slug).await? else {
return Ok(status(StatusCode::CONFLICT));
};
if from.access < Access::Write {
return Ok(denied(&space.collection(*kind, slug), "unbind"));
}
if to.access < Access::Write {
return Ok(denied(&space.collection(*kind, &to_slug), "bind"));
}
let Some((obj, _)) = self.member(&from.c, name).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
if *kind == PimKind::Calendar && !to.c.components.split(',').any(|c| c == obj.component) {
return Ok(error(
StatusCode::FORBIDDEN,
with_children(
el(
match kind {
PimKind::Calendar => CALDAV,
PimKind::AddressBook => CARDDAV,
},
"no-uid-conflict",
el(CALDAV, "supported-calendar-component"),
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
Ok(
match self
.state
.db
.pim_move_object(
from.c.id,
name,
to.c.id,
&to_name,
overwrite,
&precondition(headers),
)
.await?
{
PimWrite::Created => status(StatusCode::CREATED),
PimWrite::Updated => status(StatusCode::NO_CONTENT),
PimWrite::NotFound => status(StatusCode::NOT_FOUND),
PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
PimWrite::UidConflict(holder) => error(
StatusCode::FORBIDDEN,
with_children(
el(kind_ns(*kind), "no-uid-conflict"),
hrefs([space.object(*kind, &to_slug, &holder).as_str()]),
),
hrefs([me.object(*kind, &to_slug, &holder).as_str()]),
),
),
PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
},
)
PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
},
)
}
}
▾Aserver/src/api/pim_api.rs
@@ -0,0 +1,131 @@
//! JSON management of calendars and address books (session-authenticated):
//! - `GET {PIM_COLLECTIONS}` — own and lent collections
//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection
//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
use std::sync::Arc;
use api_types::{CreatePimShare, OkResp, PimCollectionInfo, PimCollectionKind, PimShareInfo};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::SessionUser;
use crate::api::pim::collection_href;
use crate::db::{PimCollection, PimKind, UserType};
use crate::error::{ApiError, AppState};
fn wire_kind(kind: PimKind) -> PimCollectionKind {
match kind {
PimKind::Calendar => PimCollectionKind::Calendar,
PimKind::AddressBook => PimCollectionKind::Addressbook,
}
}
fn name_of(c: &PimCollection) -> String {
c.displayname.clone().unwrap_or_else(|| c.slug.clone())
}
/// GET {PIM_COLLECTIONS}
pub async fn list(
State(state): State<Arc<AppState>>,
auth: SessionUser,
) -> Result<Json<Vec<PimCollectionInfo>>, ApiError> {
let me = &auth.user;
state.db.pim_ensure_defaults(me.id).await?;
let mut out = Vec::new();
for kind in [PimKind::Calendar, PimKind::AddressBook] {
for c in state.db.pim_collections(me.id, kind).await? {
out.push(PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
name: name_of(&c),
url: collection_href(&me.name, kind, &c.slug, None),
owner: me.name.clone(),
mode: None,
});
}
for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? {
out.push(PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
name: name_of(&c),
url: collection_href(&me.name, kind, &c.slug, Some(c.id)),
owner,
mode: Some(mode),
});
}
}
Ok(Json(out))
}
/// The id of a collection the signed-in user owns, or 404.
async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
match state.db.pim_collection_by_id(id).await? {
Some((owner, _, _)) if owner == auth.user.id => Ok(id),
_ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
}
}
/// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
pub async fn shares(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<PimShareInfo>>, ApiError> {
let id = own(&state, &auth, id).await?;
let out = state
.db
.pim_shares(id)
.await?
.into_iter()
.map(|(user_id, user_name, mode)| PimShareInfo {
user_id,
user_name,
mode,
})
.collect();
Ok(Json(out))
}
/// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
pub async fn share(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
Json(body): Json<CreatePimShare>,
) -> Result<Json<PimShareInfo>, ApiError> {
let id = own(&state, &auth, id).await?;
let user = state
.db
.pim_principal(body.user.trim())
.await?
.filter(|p| p.kind == UserType::Individual)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
if user.id == auth.user.id {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"a collection cannot be shared with its owner",
));
}
state.db.pim_set_share(id, user.id, body.mode).await?;
Ok(Json(PimShareInfo {
user_id: user.id,
user_name: user.name,
mode: body.mode,
}))
}
/// DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}
pub async fn unshare(
State(state): State<Arc<AppState>>,
auth: SessionUser,
AxumPath((id, user_id)): AxumPath<(i64, i64)>,
) -> Result<Json<OkResp>, ApiError> {
let id = own(&state, &auth, id).await?;
if !state.db.pim_remove_share(id, user_id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
}
Ok(Json(OkResp {}))
}
▾Mserver/src/db.rs
@@ -2,11 +2,12 @@ use std::path::Path;
use std::sync::Arc;
pub use api_types::{AppPasswordInfo, AuthMode, Mode};
pub use pimdav::principal::UserType;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 12;
const SCHEMA_VERSION: i64 = 13;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -156,6 +157,13 @@ pub enum PimKind {
}
impl PimKind {
fn parse(s: &str) -> Self {
match s {
"cal" => PimKind::Calendar,
_ => PimKind::AddressBook,
}
}
fn as_str(self) -> &'static str {
match self {
PimKind::Calendar => "cal",
@@ -431,6 +439,24 @@ impl Db {
ON pim_changes(collection_id, seq);",
)?;
}
if version < 13 {
// Rooms and resources are rows of `users` that cannot sign in, so
// they share the name space and the collection ownership of
// accounts. `pim_shares` lends a collection to another account.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'person'
CHECK (kind IN ('person','room','resource'));
ALTER TABLE users ADD COLUMN display_name TEXT;
CREATE TABLE IF NOT EXISTS pim_shares (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
PRIMARY KEY (collection_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -479,7 +505,9 @@ impl Db {
let row: Option<(User, String)> = {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1"),
&format!(
"SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1 AND kind = 'person'"
),
[name],
|r| Ok((map_user(r)?, r.get(USER_COL_COUNT)?)),
)
@@ -574,6 +602,7 @@ impl Db {
"SELECT {USER_COLS_U}, r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
WHERE u.kind = 'person'
ORDER BY u.id, r.id",
))?;
// Rows arrive grouped by user, so a new user id starts a new group.
@@ -598,13 +627,14 @@ impl Db {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"),
&format!("SELECT {USER_COLS} FROM users WHERE id = ?1 AND kind = 'person'"),
[id],
map_user,
)
.optional()
}
/// Rooms and resources too, since they share the name space.
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
@@ -800,7 +830,7 @@ impl Db {
pub async fn find_user_by_webauthn_id(&self, wid: &Uuid) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1"),
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1 AND kind = 'person'"),
[wid.to_string()],
map_user,
)
@@ -1569,6 +1599,176 @@ impl Db {
Ok(PimWrite::Deleted)
}
// ---------- principals, sharing, rooms ----------
/// An account, room or resource by URL name. Disabled accounts are
/// invisible.
pub async fn pim_principal(&self, name: &str) -> DbResult<Option<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM users WHERE name = ?1 AND {VISIBLE}"
))?;
stmt.query_row([name], map_principal).optional()
}
pub async fn pim_principals(&self) -> DbResult<Vec<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM users WHERE {VISIBLE} ORDER BY id"
))?;
stmt.query_map([], map_principal)?.collect()
}
/// The collection `collection_id` as lent to `user_id`, with its owner's
/// name and the mode.
pub async fn pim_shared_collection(
&self,
user_id: i64,
kind: PimKind,
collection_id: i64,
) -> DbResult<Option<(PimCollection, String, Mode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, u.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN users u ON u.id = c.user_id
WHERE s.user_id = ?1 AND c.kind = ?2 AND c.id = ?3"
))?;
stmt.query_row(params![user_id, kind.as_str(), collection_id], map_shared)
.optional()
}
/// Every collection of `kind` lent to `user_id`.
pub async fn pim_shared_collections(
&self,
user_id: i64,
kind: PimKind,
) -> DbResult<Vec<(PimCollection, String, Mode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, u.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN users u ON u.id = c.user_id
WHERE s.user_id = ?1 AND c.kind = ?2 ORDER BY c.id"
))?;
stmt.query_map(params![user_id, kind.as_str()], map_shared)?
.collect()
}
/// The owner and kind of a collection.
pub async fn pim_collection_by_id(
&self,
id: i64,
) -> DbResult<Option<(i64, PimKind, PimCollection)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS}, user_id, kind FROM pim_collections WHERE id = ?1"
))?;
stmt.query_row([id], |r| {
Ok((
r.get(9)?,
PimKind::parse(&r.get::<_, String>(10)?),
map_pim_collection(r)?,
))
})
.optional()
}
/// `(user id, name, mode)` of everyone a collection is lent to.
pub async fn pim_shares(&self, collection_id: i64) -> DbResult<Vec<(i64, String, Mode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, s.mode FROM pim_shares s JOIN users u ON u.id = s.user_id
WHERE s.collection_id = ?1 ORDER BY u.name",
)?;
stmt.query_map([collection_id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get::<_, SqlMode>(2)?.0))
})?
.collect()
}
/// Lends a collection, or changes the mode of an existing loan.
pub async fn pim_set_share(
&self,
collection_id: i64,
user_id: i64,
mode: Mode,
) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO pim_shares (collection_id, user_id, mode) VALUES (?1, ?2, ?3)
ON CONFLICT (collection_id, user_id) DO UPDATE SET mode = ?3",
params![collection_id, user_id, mode.as_str()],
)?;
Ok(())
}
pub async fn pim_remove_share(&self, collection_id: i64, user_id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute(
"DELETE FROM pim_shares WHERE collection_id = ?1 AND user_id = ?2",
params![collection_id, user_id],
)? > 0)
}
pub async fn rooms(&self) -> DbResult<Vec<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM users WHERE kind != 'person' ORDER BY name"
))?;
stmt.query_map([], map_principal)?.collect()
}
/// A room or resource with its booking calendar. `None` if the name is
/// taken by any principal.
pub async fn create_room(
&self,
name: &str,
display_name: &str,
kind: UserType,
) -> DbResult<Option<PimPrincipal>> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
// No password and never active: no sign-in path accepts it.
let inserted = tx.execute(
"INSERT INTO users (name, pass_hash, is_admin, active, created_at, kind, display_name)
SELECT ?1, '', 0, 0, ?2, ?3, ?4
WHERE NOT EXISTS (SELECT 1 FROM users WHERE name = ?1)",
params![name, now(), kind_str(kind), display_name],
)?;
if inserted == 0 {
return Ok(None);
}
let id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
VALUES (?1, 'cal', 'default', ?2, 'VEVENT', ?3)",
params![id, display_name, now()],
)?;
tx.commit()?;
Ok(Some(PimPrincipal {
id,
name: name.to_string(),
display_name: Some(display_name.to_string()),
kind,
}))
}
pub async fn set_room_display_name(&self, id: i64, display_name: &str) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute(
"UPDATE users SET display_name = ?2 WHERE id = ?1 AND kind != 'person'",
params![id, display_name],
)? > 0)
}
pub async fn delete_room(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute("DELETE FROM users WHERE id = ?1 AND kind != 'person'", [id])? > 0)
}
/// 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> {
@@ -1702,6 +1902,57 @@ fn record_pim_change(
Ok(())
}
/// A signed-in-capable account or a room, as CalDAV sees it.
#[derive(Debug, Clone)]
pub struct PimPrincipal {
pub id: i64,
/// The URL segment.
pub name: String,
pub display_name: Option<String>,
pub kind: UserType,
}
impl PimPrincipal {
pub fn display(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
}
const PRINCIPAL_COLS: &str = "id, name, display_name, kind";
/// Rooms are never active; disabled accounts are hidden.
const VISIBLE: &str = "(kind != 'person' OR active = 1)";
fn map_principal(r: &rusqlite::Row) -> DbResult<PimPrincipal> {
Ok(PimPrincipal {
id: r.get(0)?,
name: r.get(1)?,
display_name: r.get(2)?,
kind: match r.get::<_, String>(3)?.as_str() {
"room" => UserType::Room,
"resource" => UserType::Resource,
_ => UserType::Individual,
},
})
}
fn kind_str(kind: UserType) -> &'static str {
match kind {
UserType::Individual => "person",
UserType::Room => "room",
UserType::Resource => "resource",
}
}
fn map_shared(r: &rusqlite::Row) -> DbResult<(PimCollection, String, Mode)> {
Ok((
map_pim_collection(r)?,
r.get(9)?,
r.get::<_, SqlMode>(10)?.0,
))
}
const PIM_COLLECTION_COLS_C: &str = "c.id, c.slug, c.displayname, c.description, c.color,
c.timezone, c.sort_order, c.components, c.seq";
const PIM_COLLECTION_COLS: &str = "id, slug, displayname, description, color, timezone,
sort_order, components, seq";
▾Mserver/tests/api_pim.rs
@@ -127,7 +127,7 @@ async fn discovery() {
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.status, StatusCode::TEMPORARY_REDIRECT);
assert_eq!(r.header("location").as_deref(), Some("/pim/"));
// A Basic login spelled in another case still gets the stored spelling.
@@ -251,10 +251,29 @@ async fn homes_list_the_default_collections() {
#[tokio::test]
async fn other_users_are_off_limits() {
let (env, auth) = setup().await;
for path in ["/pim/principals/admin/", "/pim/calendars/admin/default/"] {
for path in ["/pim/calendars/admin/", "/pim/calendars/admin/default/"] {
let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}");
}
// Another account's principal is readable, for scheduling.
let body = propfind_body(&[
(DAV, "displayname"),
(CALDAV, "calendar-user-address-set"),
(CARDDAV, "addressbook-home-set"),
]);
let p = "/pim/principals/admin/";
let r = req(&env, "PROPFIND", p, &auth, &[("depth", "0")], &body).await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, p, DAV, "displayname").as_deref(),
Some("admin")
);
let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
assert!(addresses.contains(&"mailto:admin@filebrowser.invalid".to_string()));
assert!(prop(&ms, p, CARDDAV, "addressbook-home-set").is_none());
let r = req(&env, "PROPFIND", "/pim/principals/nobody/", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
@@ -634,9 +653,10 @@ async fn calendar_reports() {
let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await;
let ms = parse_multistatus(&r);
let lunch = format!("{CAL}lunch.ics");
// XML parsing turns CRLF into LF.
// The CR of each CRLF goes out as `&#13;`, which XML parsing keeps.
assert!(r.text().contains("&#13;\n"), "{}", r.text());
let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap();
assert_eq!(data, ics(LUNCH).replace("\r\n", "\n").trim());
assert_eq!(data, ics(LUNCH).trim());
assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404))));
// Time range: the Monday 2026-01-05 holds only an instance of the weekly
@@ -1018,3 +1038,475 @@ async fn hrefs_follow_the_requested_spelling() {
"{hrefs:?}"
);
}
// ---------------------------------------------------------------------------
// Sharing, the system address book, rooms and principal search
// ---------------------------------------------------------------------------
const BOB: &str = "bob";
const BOB_PW: &str = "bob12345678";
/// alice with an event in her default calendar, and bob.
async fn two_users() -> (Env, Client, String, String) {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, ALICE, PW, &[]).await;
create_user(&admin, BOB, BOB_PW, &[]).await;
let alice = basic(ALICE, PW);
put(&env, &alice, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
(env, admin, alice, basic(BOB, BOB_PW))
}
/// The id of alice's default calendar, from the JSON API.
async fn calendar_id(alice: &Client) -> i64 {
let r = alice.get("/api/pim/collections").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json()
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "calendar" && c["mode"].is_null())
.unwrap()["id"]
.as_i64()
.unwrap()
}
fn privilege_names(p: &Element) -> Vec<String> {
xml::elements(p)
.flat_map(xml::elements)
.map(|e| e.name.clone())
.collect()
}
#[tokio::test]
async fn lent_collections() {
let (env, admin, alice_auth, bob) = two_users().await;
let alice = login(&env, ALICE, PW).await;
let id = calendar_id(&alice).await;
let shares = format!("/api/pim/collections/{id}/shares");
let r = alice
.post_json(&shares, &json!({"user": "nobody", "mode": "ro"}))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = alice
.post_json(&shares, &json!({"user": ALICE, "mode": "ro"}))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = alice
.post_json(&shares, &json!({"user": "BOB", "mode": "ro"}))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let bob_client = login(&env, BOB, BOB_PW).await;
assert_eq!(bob_client.get(&shares).await.status, StatusCode::NOT_FOUND);
let listed = bob_client.get("/api/pim/collections").await.json();
let lent = listed
.as_array()
.unwrap()
.iter()
.find(|c| c["id"] == id)
.unwrap()
.clone();
assert_eq!(lent["mode"], "ro");
assert_eq!(lent["owner"], ALICE);
let shared = format!("/pim/calendars/bob/shared-{id}/");
assert_eq!(lent["url"], shared.as_str());
// bob's home shows it, read-only, with alice as the owner.
let body = propfind_body(&[
(DAV, "displayname"),
(DAV, "owner"),
(DAV, "current-user-privilege-set"),
]);
let r = req(
&env,
"PROPFIND",
"/pim/calendars/bob/",
&bob,
&[("depth", "1")],
&body,
)
.await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, &shared, DAV, "displayname").as_deref(),
Some("Calendar (alice)")
);
assert_eq!(
hrefs_of(&prop(&ms, &shared, DAV, "owner").unwrap()),
["/pim/principals/alice/"]
);
let privs = privilege_names(&prop(&ms, &shared, DAV, "current-user-privilege-set").unwrap());
assert_eq!(privs, ["read", "read-current-user-privilege-set"]);
// Read works through every method, writes do not.
let lunch = format!("{shared}lunch.ics");
let r = req(&env, "GET", &lunch, &bob, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
let r = req(&env, "REPORT", &shared, &bob, &[], &query("", "")).await;
assert_eq!(hrefs_in(&r), [lunch.as_str()]);
let sync = r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
let r = req(&env, "REPORT", &shared, &bob, &[], sync).await;
assert_eq!(hrefs_in(&r), [lunch.as_str()]);
let r = req(
&env,
"PUT",
&format!("{shared}new.ics"),
&bob,
&[],
&event("new", "x"),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "need-privileges"));
let r = req(&env, "DELETE", &lunch, &bob, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Mine</d:displayname></d:prop></d:set></d:propertyupdate>"#;
let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Read-write: bob adds an event, alice sees it; bob moves one out.
let r = alice
.post_json(&shares, &json!({"user": BOB, "mode": "rw"}))
.await;
assert_eq!(r.status, StatusCode::OK);
put(
&env,
&bob,
&format!("{shared}new.ics"),
&event("new", "from bob"),
)
.await;
let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
let r = req(
&env,
"MOVE",
&format!("{shared}new.ics"),
&bob,
&[("destination", "/pim/calendars/bob/default/new.ics")],
"",
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// bob deleting it only takes it out of his home.
let r = req(&env, "DELETE", &shared, &bob, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
assert_eq!(
req(&env, "GET", &lunch, &bob, &[], "").await.status,
StatusCode::NOT_FOUND
);
assert!(
alice
.get(&shares)
.await
.json()
.as_array()
.unwrap()
.is_empty()
);
let r = req(
&env,
"GET",
&format!("{CAL}lunch.ics"),
&alice_auth,
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::OK);
// Revoking, and deleting the borrower, end the loan.
alice
.post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
.await;
let r = alice
.delete(&format!("{shares}/{}", user_id(&admin, BOB).await))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
req(&env, "GET", &lunch, &bob, &[], "").await.status,
StatusCode::NOT_FOUND
);
alice
.post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
.await;
let r = admin
.delete(&format!("/api/admin/users/{}", user_id(&admin, BOB).await))
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(
alice
.get(&shares)
.await
.json()
.as_array()
.unwrap()
.is_empty()
);
}
const DIR: &str = "/pim/addressbooks/alice/system/";
#[tokio::test]
async fn system_address_book() {
let (env, admin, alice, _) = two_users().await;
let body = propfind_body(&[(DAV, "getetag"), (CALSERVER, "getctag")]);
let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "1")], &body).await;
let ms = parse_multistatus(&r);
// admin, alice and bob.
assert_eq!(ms.len(), 4);
let ctag = prop_text(&ms, DIR, CALSERVER, "getctag").unwrap();
let card = ms.iter().find(|(h, _)| h != DIR).unwrap().0.clone();
let r = req(&env, "GET", &card, &alice, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.text().contains("EMAIL;TYPE=INTERNET:"), "{}", r.text());
let q = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop><card:address-data/></d:prop>
<card:filter><card:prop-filter name="FN"><card:text-match>BOB</card:text-match></card:prop-filter></card:filter>
</card:addressbook-query>"#;
let r = req(&env, "REPORT", DIR, &alice, &[], q).await;
assert_eq!(statuses(&r).len(), 1);
assert!(r.text().contains("FN:bob"));
let sync = |token: &str| {
format!(
r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
)
};
let r = req(&env, "REPORT", DIR, &alice, &[], &sync("")).await;
assert_eq!(statuses(&r).len(), 3);
let token = sync_token_of(&r);
let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
assert!(statuses(&r).is_empty());
// A new account changes the CTag, and the old token no longer works.
create_user(&admin, "carol", "carol12345", &[]).await;
let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "0")], &body).await;
assert_ne!(
prop_text(&parse_multistatus(&r), DIR, CALSERVER, "getctag").unwrap(),
ctag
);
let r = req(
&env,
"PUT",
&format!("{DIR}x.vcf"),
&alice,
&[],
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:x\r\nEND:VCARD\r\n",
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "need-privileges"));
assert_eq!(
req(&env, "DELETE", &card, &alice, &[], "").await.status,
StatusCode::FORBIDDEN
);
assert_eq!(
req(&env, "DELETE", DIR, &alice, &[], "").await.status,
StatusCode::FORBIDDEN
);
let r = req(&env, "MKCOL", DIR, &alice, &[], "").await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn rooms_and_resources() {
let (env, admin, alice, _) = two_users().await;
let alice_client = login(&env, ALICE, PW).await;
let room = json!({"name": "board", "display_name": "Board Room", "kind": "room"});
assert_eq!(
alice_client
.post_json("/api/admin/rooms", &room)
.await
.status,
StatusCode::FORBIDDEN
);
let r = admin.post_json("/api/admin/rooms", &room).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let id = r.json()["id"].as_i64().unwrap();
assert_eq!(r.json()["url"], "/pim/principals/board/");
let taken = json!({"name": "ALICE", "kind": "resource"});
assert_eq!(
admin.post_json("/api/admin/rooms", &taken).await.status,
StatusCode::CONFLICT
);
let r = admin
.post_json(
"/api/admin/users",
&json!({"name": "Board", "password": "x1234567", "is_admin": false, "roots": []}),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
// Not an account: not listed, not editable, no sign-in.
let users = admin.get("/api/admin/users").await.json();
assert!(
users
.as_array()
.unwrap()
.iter()
.all(|u| u["name"] != "board")
);
let r = admin
.put_json(
&format!("/api/admin/users/{id}"),
&json!({"password": "x1234567"}),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = req(&env, "PROPFIND", "/pim/", &basic("board", ""), &[], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let p = "/pim/principals/board/";
let body = propfind_body(&[
(DAV, "displayname"),
(CALDAV, "calendar-user-type"),
(CALDAV, "calendar-user-address-set"),
(CALDAV, "calendar-home-set"),
]);
let r = req(&env, "PROPFIND", p, &alice, &[], &body).await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, p, DAV, "displayname").as_deref(),
Some("Board Room")
);
assert_eq!(
prop_text(&ms, p, CALDAV, "calendar-user-type").as_deref(),
Some("ROOM")
);
let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
assert!(addresses.contains(&"mailto:board@rooms.filebrowser.invalid".to_string()));
assert_eq!(
hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
["/pim/calendars/board/"]
);
// Everyone reads the bookings; only admins write them.
let cal = "/pim/calendars/board/default/";
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let r = req(
&env,
"PUT",
&format!("{cal}b.ics"),
&alice,
&[],
&event("b", "x"),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
put(
&env,
&basic("admin", "admin1234"),
&format!("{cal}b.ics"),
&event("b", "x"),
)
.await;
assert_eq!(
req(&env, "GET", &format!("{cal}b.ics"), &alice, &[], "")
.await
.status,
StatusCode::OK
);
// In the system address book as a location.
let r = req(&env, "REPORT", DIR, &alice, &[], r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:address-data/></d:prop><card:filter><card:prop-filter name="KIND"><card:text-match match-type="equals">location</card:text-match></card:prop-filter></card:filter></card:addressbook-query>"#).await;
assert!(r.text().contains("FN:Board Room"), "{}", r.text());
let r = admin
.put_json(
&format!("/api/admin/rooms/{id}"),
&json!({"display_name": "Boardroom"}),
)
.await;
assert_eq!(r.json()["display_name"], "Boardroom");
assert_eq!(
admin
.get("/api/admin/rooms")
.await
.json()
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
admin.delete(&format!("/api/admin/rooms/{id}")).await.status,
StatusCode::OK
);
assert_eq!(
req(&env, "PROPFIND", p, &alice, &[], "").await.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn principal_search() {
let (env, admin, alice, _) = two_users().await;
admin
.post_json(
"/api/admin/rooms",
&json!({"name": "board", "display_name": "Board Room", "kind": "room"}),
)
.await;
let principals = "/pim/principals/";
let r = req(&env, "PROPFIND", principals, &alice, &[("depth", "1")], "").await;
// The collection, admin, alice, bob and the room.
assert_eq!(parse_multistatus(&r).len(), 5);
let pps = r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" test="anyof">
<d:property-search><d:prop><d:displayname/></d:prop><d:match>BO</d:match></d:property-search>
<d:prop><d:displayname/><c:calendar-user-type/></d:prop>
</d:principal-property-search>"#;
let r = req(&env, "REPORT", principals, &alice, &[("depth", "0")], pps).await;
assert_eq!(
hrefs_in(&r),
["/pim/principals/board/", "/pim/principals/bob/"]
);
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, "/pim/principals/board/", CALDAV, "calendar-user-type").as_deref(),
Some("ROOM")
);
let cs = r#"<cs:calendarserver-principal-search xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" context="location">
<cs:search-token>bo</cs:search-token><d:prop><d:displayname/></d:prop>
</cs:calendarserver-principal-search>"#;
let r = req(&env, "REPORT", principals, &alice, &[], cs).await;
assert_eq!(hrefs_in(&r), ["/pim/principals/board/"]);
let set = r#"<d:principal-search-property-set xmlns:d="DAV:"/>"#;
let r = req(&env, "REPORT", principals, &alice, &[], set).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.text().contains("calendar-user-address-set"));
// Not a collection report.
let r = req(&env, "REPORT", CAL, &alice, &[], pps).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn bad_filters_are_refused_by_name() {
let (env, auth) = setup().await;
let bad = query(
r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
"",
);
let r = req(&env, "REPORT", CAL, &auth, &[], &bad).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(CALDAV, "valid-filter"));
let bad = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><card:filter test="sometimes"/></card:addressbook-query>"#;
let r = req(&env, "REPORT", BOOK, &auth, &[], bad).await;
assert!(error_condition(&r).is(CARDDAV, "valid-filter"));
}