From 91944162d9c76faba26f5d4b365a1146ac0ef189 Mon Sep 17 00:00:00 2001
From: mdecimus
Date: Tue, 29 Apr 2025 18:37:38 +0200
Subject: [PATCH] Basic, PUT, GET, DELETE and MKCOL WebDAV tests
---
Cargo.lock | 2 +
.../src/config/{dav.rs => groupware.rs} | 21 +-
crates/common/src/config/mod.rs | 24 +-
crates/common/src/lib.rs | 29 +-
crates/dav-proto/src/responses/acl.rs | 1 +
crates/dav-proto/src/responses/error.rs | 6 +-
crates/dav-proto/src/responses/mkcol.rs | 1 +
crates/dav-proto/src/responses/multistatus.rs | 6 +-
crates/dav-proto/src/responses/property.rs | 13 +-
crates/dav-proto/src/responses/propstat.rs | 3 +-
crates/dav-proto/src/schema/mod.rs | 4 +-
crates/dav-proto/src/schema/property.rs | 2 +-
crates/dav-proto/src/schema/request.rs | 6 +-
crates/dav-proto/src/schema/response.rs | 14 +-
crates/dav/src/calendar/delete.rs | 2 +-
crates/dav/src/calendar/mkcol.rs | 25 +-
crates/dav/src/calendar/proppatch.rs | 243 +++--
crates/dav/src/calendar/update.rs | 15 +-
crates/dav/src/card/delete.rs | 2 +-
crates/dav/src/card/mkcol.rs | 41 +-
crates/dav/src/card/proppatch.rs | 200 ++---
crates/dav/src/card/update.rs | 4 +-
crates/dav/src/common/lock.rs | 16 +-
crates/dav/src/common/propfind.rs | 16 +-
crates/dav/src/common/uri.rs | 5 +-
crates/dav/src/file/copy_move.rs | 30 +-
crates/dav/src/file/delete.rs | 2 +-
crates/dav/src/file/mkcol.rs | 8 +-
crates/dav/src/file/proppatch.rs | 117 ++-
crates/dav/src/file/update.rs | 22 +-
crates/dav/src/lib.rs | 85 +-
crates/dav/src/request.rs | 5 +-
crates/groupware/src/calendar/dates.rs | 6 +-
crates/groupware/src/calendar/index.rs | 4 +-
crates/groupware/src/calendar/mod.rs | 2 +-
crates/groupware/src/hierarchy.rs | 10 +-
tests/Cargo.toml | 3 +
tests/src/lib.rs | 2 +
tests/src/webdav/basic.rs | 48 +
tests/src/webdav/mkcol.rs | 202 +++++
tests/src/webdav/mod.rs | 827 ++++++++++++++++++
tests/src/webdav/put_get.rs | 403 +++++++++
42 files changed, 1993 insertions(+), 484 deletions(-)
rename crates/common/src/config/{dav.rs => groupware.rs} (90%)
create mode 100644 tests/src/webdav/basic.rs
create mode 100644 tests/src/webdav/mkcol.rs
create mode 100644 tests/src/webdav/mod.rs
create mode 100644 tests/src/webdav/put_get.rs
diff --git a/Cargo.lock b/Cargo.lock
index 0a1dc80b..c0b46e7b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7332,6 +7332,7 @@ dependencies = [
"flate2",
"form_urlencoded",
"futures",
+ "groupware",
"http 0.11.7",
"http-body-util",
"http_proto",
@@ -7350,6 +7351,7 @@ dependencies = [
"nlp",
"num_cpus",
"pop3",
+ "quick-xml 0.37.4",
"rayon",
"reqwest 0.12.15",
"ring 0.17.14",
diff --git a/crates/common/src/config/dav.rs b/crates/common/src/config/groupware.rs
similarity index 90%
rename from crates/common/src/config/dav.rs
rename to crates/common/src/config/groupware.rs
index 82b96ebd..672169a9 100644
--- a/crates/common/src/config/dav.rs
+++ b/crates/common/src/config/groupware.rs
@@ -7,7 +7,8 @@
use utils::config::Config;
#[derive(Debug, Clone, Default)]
-pub struct DavConfig {
+pub struct GroupwareConfig {
+ // DAV settings
pub max_request_size: usize,
pub dead_property_size: Option,
pub live_property_size: usize,
@@ -15,19 +16,26 @@ pub struct DavConfig {
pub max_locks_per_user: usize,
pub max_changes: usize,
pub max_match_results: usize,
- pub max_vcard_size: usize,
+
+ // Calendar settings
pub max_ical_size: usize,
pub max_ical_instances: usize,
pub max_ical_attendees_per_instance: usize,
pub default_calendar_name: Option,
- pub default_addressbook_name: Option,
pub default_calendar_display_name: Option,
+
+ // Addressbook settings
+ pub max_vcard_size: usize,
+ pub default_addressbook_name: Option,
pub default_addressbook_display_name: Option,
+
+ // File storage settings
+ pub max_file_size: usize,
}
-impl DavConfig {
+impl GroupwareConfig {
pub fn parse(config: &mut Config) -> Self {
- DavConfig {
+ GroupwareConfig {
max_request_size: config
.property("dav.limits.size.request")
.unwrap_or(25 * 1024 * 1024),
@@ -75,6 +83,9 @@ impl DavConfig {
max_ical_attendees_per_instance: config
.property("dav.limits.ical.max-attendees-per-instance")
.unwrap_or(1000),
+ max_file_size: config
+ .property("dav.limits.size.file")
+ .unwrap_or(25 * 1024 * 1024),
}
}
}
diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs
index ae3d69d8..1cf4efb2 100644
--- a/crates/common/src/config/mod.rs
+++ b/crates/common/src/config/mod.rs
@@ -6,10 +6,18 @@
use std::{str::FromStr, sync::Arc};
+use self::{
+ imap::ImapConfig, jmap::settings::JmapConfig, scripts::Scripting, smtp::SmtpConfig,
+ storage::Storage,
+};
+use crate::{
+ Core, Network, Security, auth::oauth::config::OAuthConfig, expr::*,
+ listener::tls::AcmeProviders, manager::config::ConfigManager,
+};
use arc_swap::ArcSwap;
use base64::{Engine, engine::general_purpose};
-use dav::DavConfig;
use directory::{Directories, Directory};
+use groupware::GroupwareConfig;
use hyper::{
HeaderMap,
header::{AUTHORIZATION, HeaderName, HeaderValue},
@@ -20,17 +28,7 @@ use store::{BlobBackend, BlobStore, FtsStore, InMemoryStore, Store, Stores};
use telemetry::Metrics;
use utils::config::{Config, utils::AsKey};
-use crate::{
- Core, Network, Security, auth::oauth::config::OAuthConfig, expr::*,
- listener::tls::AcmeProviders, manager::config::ConfigManager,
-};
-
-use self::{
- imap::ImapConfig, jmap::settings::JmapConfig, scripts::Scripting, smtp::SmtpConfig,
- storage::Storage,
-};
-
-pub mod dav;
+pub mod groupware;
pub mod imap;
pub mod inner;
pub mod jmap;
@@ -188,7 +186,7 @@ impl Core {
acme: AcmeProviders::parse(config),
metrics: Metrics::parse(config),
spam: SpamFilterConfig::parse(config).await,
- dav: DavConfig::parse(config),
+ groupware: GroupwareConfig::parse(config),
storage: Storage {
data,
blob,
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
index d6727722..a36eca2a 100644
--- a/crates/common/src/lib.rs
+++ b/crates/common/src/lib.rs
@@ -6,22 +6,12 @@
#![warn(clippy::large_futures)]
-use std::{
- hash::{BuildHasher, Hasher},
- net::{IpAddr, Ipv4Addr, Ipv6Addr},
- sync::{
- Arc,
- atomic::{AtomicBool, AtomicU8},
- },
- time::Duration,
-};
-
use ahash::{AHashMap, AHashSet};
use arc_swap::ArcSwap;
use auth::{AccessToken, oauth::config::OAuthConfig, roles::RolePermissions};
use calcard::common::timezone::Tz;
use config::{
- dav::DavConfig,
+ groupware::GroupwareConfig,
imap::ImapConfig,
jmap::settings::{JmapConfig, SpecialUse},
network::Network,
@@ -34,16 +24,23 @@ use config::{
storage::Storage,
telemetry::Metrics,
};
-
use ipc::{HousekeeperEvent, QueueEvent, ReportingEvent, StateEvent};
use jmap_proto::types::value::AclGrant;
use listener::{asn::AsnGeoLookupData, blocked::Security, tls::AcmeProviders};
-
use mail_auth::{MX, Txt};
use manager::webadmin::{Resource, WebAdminManager};
use nlp::bayes::{TokenHash, Weights};
use parking_lot::{Mutex, RwLock};
use rustls::sign::CertifiedKey;
+use std::{
+ hash::{BuildHasher, Hasher},
+ net::{IpAddr, Ipv4Addr, Ipv6Addr},
+ sync::{
+ Arc,
+ atomic::{AtomicBool, AtomicU8},
+ },
+ time::Duration,
+};
use store::roaring::RoaringBitmap;
use tinyvec::TinyVec;
use tokio::sync::{Notify, Semaphore, mpsc};
@@ -253,7 +250,7 @@ pub struct DavResources {
pub modseq: Option,
}
-#[derive(Debug, Default)]
+#[derive(Debug, Default, Clone)]
pub struct DavResource {
pub document_id: u32,
pub parent_id: Option,
@@ -261,7 +258,7 @@ pub struct DavResource {
pub data: DavResourceMetadata,
}
-#[derive(Debug, Default)]
+#[derive(Debug, Default, Clone)]
pub enum DavResourceMetadata {
File {
size: u32,
@@ -288,7 +285,7 @@ pub struct Core {
pub oauth: OAuthConfig,
pub smtp: SmtpConfig,
pub jmap: JmapConfig,
- pub dav: DavConfig,
+ pub groupware: GroupwareConfig,
pub spam: SpamFilterConfig,
pub imap: ImapConfig,
pub metrics: Metrics,
diff --git a/crates/dav-proto/src/responses/acl.rs b/crates/dav-proto/src/responses/acl.rs
index 8f34aa73..67dfde4d 100644
--- a/crates/dav-proto/src/responses/acl.rs
+++ b/crates/dav-proto/src/responses/acl.rs
@@ -193,6 +193,7 @@ impl Display for Resource {
impl Display for PrincipalSearchPropertySet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "")?;
write!(
f,
"{}",
diff --git a/crates/dav-proto/src/responses/error.rs b/crates/dav-proto/src/responses/error.rs
index aaa818fe..b416eac5 100644
--- a/crates/dav-proto/src/responses/error.rs
+++ b/crates/dav-proto/src/responses/error.rs
@@ -13,7 +13,11 @@ use crate::schema::{
impl Display for ErrorResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "", self.namespaces)?;
+ write!(
+ f,
+ "",
+ self.namespaces
+ )?;
match &self.error {
Condition::Base(e) => e.fmt(f)?,
diff --git a/crates/dav-proto/src/responses/mkcol.rs b/crates/dav-proto/src/responses/mkcol.rs
index d05478a2..e46e9fc3 100644
--- a/crates/dav-proto/src/responses/mkcol.rs
+++ b/crates/dav-proto/src/responses/mkcol.rs
@@ -13,6 +13,7 @@ use crate::schema::{
impl Display for MkColResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "")?;
if !self.mkcalendar {
write!(
f,
diff --git a/crates/dav-proto/src/responses/multistatus.rs b/crates/dav-proto/src/responses/multistatus.rs
index d7fe3270..c59a534c 100644
--- a/crates/dav-proto/src/responses/multistatus.rs
+++ b/crates/dav-proto/src/responses/multistatus.rs
@@ -18,7 +18,11 @@ use crate::schema::{
impl Display for MultiStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.namespaces, self.response)?;
+ write!(
+ f,
+ "{}",
+ self.namespaces, self.response
+ )?;
if let Some(response_description) = &self.response_description {
write!(f, "{response_description}")?;
}
diff --git a/crates/dav-proto/src/responses/property.rs b/crates/dav-proto/src/responses/property.rs
index d3a4eb06..c527d676 100644
--- a/crates/dav-proto/src/responses/property.rs
+++ b/crates/dav-proto/src/responses/property.rs
@@ -26,7 +26,7 @@ impl Display for PropResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
- "{}",
+ "{}",
self.namespaces, self.properties
)
}
@@ -35,10 +35,17 @@ impl Display for PropResponse {
impl Display for DavPropertyValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (name, attrs) = self.property.tag_name();
+
+ write!(f, "<{}", name)?;
+
if let Some(attrs) = attrs {
- write!(f, "<{} {}>{}{}>", name, attrs, self.value, name)
+ write!(f, " {attrs}")?;
+ }
+
+ if !matches!(self.value, DavValue::Null) {
+ write!(f, ">{}{}>", self.value, name)
} else {
- write!(f, "<{}>{}{}>", name, self.value, name)
+ write!(f, "/>")
}
}
}
diff --git a/crates/dav-proto/src/responses/propstat.rs b/crates/dav-proto/src/responses/propstat.rs
index 11c13334..bf47dc67 100644
--- a/crates/dav-proto/src/responses/propstat.rs
+++ b/crates/dav-proto/src/responses/propstat.rs
@@ -35,7 +35,8 @@ impl Display for Prop {
}
impl PropStat {
- pub fn new(prop: impl Into) -> Self {
+ #[cfg(test)]
+ pub(crate) fn new(prop: impl Into) -> Self {
PropStat {
prop: Prop(List(vec![prop.into()])),
status: Status(StatusCode::OK),
diff --git a/crates/dav-proto/src/schema/mod.rs b/crates/dav-proto/src/schema/mod.rs
index 9e326df3..0c92bcd1 100644
--- a/crates/dav-proto/src/schema/mod.rs
+++ b/crates/dav-proto/src/schema/mod.rs
@@ -1342,7 +1342,7 @@ impl AttributeValue for String {
}
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub enum Collation {
AsciiNumeric,
@@ -1371,7 +1371,7 @@ impl Collation {
}
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub enum MatchType {
Equals,
diff --git a/crates/dav-proto/src/schema/property.rs b/crates/dav-proto/src/schema/property.rs
index 70cc69af..0735e366 100644
--- a/crates/dav-proto/src/schema/property.rs
+++ b/crates/dav-proto/src/schema/property.rs
@@ -122,7 +122,7 @@ pub struct CalendarData {
pub limit_freebusy: Option,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub struct TimeRange {
pub start: i64,
diff --git a/crates/dav-proto/src/schema/request.rs b/crates/dav-proto/src/schema/request.rs
index 55ea9af6..53f96d96 100644
--- a/crates/dav-proto/src/schema/request.rs
+++ b/crates/dav-proto/src/schema/request.rs
@@ -140,7 +140,7 @@ pub struct SyncCollection {
pub limit: Option,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(test, serde(tag = "type"))]
pub enum Filter {
@@ -163,7 +163,7 @@ pub enum Filter {
},
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(test, serde(tag = "type", content = "data"))]
pub enum FilterOp {
@@ -173,7 +173,7 @@ pub enum FilterOp {
TextMatch(TextMatch),
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(test, serde(tag = "type"))]
pub struct TextMatch {
diff --git a/crates/dav-proto/src/schema/response.rs b/crates/dav-proto/src/schema/response.rs
index c4a82c4b..77e626a8 100644
--- a/crates/dav-proto/src/schema/response.rs
+++ b/crates/dav-proto/src/schema/response.rs
@@ -59,12 +59,12 @@ pub struct ResponseDescription(pub String);
#[repr(transparent)]
pub struct SyncToken(pub String);
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
#[repr(transparent)]
pub struct Href(pub String);
-#[derive(Debug, Default, Clone, PartialEq, Eq)]
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
#[repr(transparent)]
pub struct List(pub Vec);
@@ -172,7 +172,7 @@ pub struct ErrorResponse {
pub error: Condition,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub enum Condition {
Base(BaseCondition),
@@ -180,7 +180,7 @@ pub enum Condition {
Card(CardCondition),
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub enum BaseCondition {
NoConflictingLock(List),
@@ -210,14 +210,14 @@ pub enum BaseCondition {
ValidSyncToken,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub struct Resource {
pub href: Href,
pub privilege: Privilege,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub enum CalCondition {
CalendarCollectionLocationOk,
@@ -239,7 +239,7 @@ pub enum CalCondition {
MaxAttendeesPerInstance,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))]
pub enum CardCondition {
SupportedAddressData,
diff --git a/crates/dav/src/calendar/delete.rs b/crates/dav/src/calendar/delete.rs
index cc61f828..52edf1a5 100644
--- a/crates/dav/src/calendar/delete.rs
+++ b/crates/dav/src/calendar/delete.rs
@@ -59,7 +59,7 @@ impl CalendarDeleteRequestHandler for Server {
let delete_resource = resources
.paths
.by_name(delete_path)
- .ok_or(DavError::Code(StatusCode::FORBIDDEN))?;
+ .ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
let document_id = delete_resource.document_id;
// Fetch entry
diff --git a/crates/dav/src/calendar/mkcol.rs b/crates/dav/src/calendar/mkcol.rs
index 492dc24e..18e41bce 100644
--- a/crates/dav/src/calendar/mkcol.rs
+++ b/crates/dav/src/calendar/mkcol.rs
@@ -20,7 +20,7 @@ use store::write::BatchBuilder;
use trc::AddContext;
use crate::{
- DavError, DavMethod,
+ DavError, DavMethod, PropStatBuilder,
common::{
lock::{LockRequestHandler, ResourceState},
uri::DavUriResource,
@@ -54,15 +54,16 @@ impl CalendarMkColRequestHandler for Server {
let name = resource
.resource
.ok_or(DavError::Code(StatusCode::FORBIDDEN))?;
- if name.contains('/') || !access_token.is_member(account_id) {
+ if !access_token.is_member(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
- } else if self
- .fetch_dav_resources(access_token, account_id, Collection::Calendar)
- .await
- .caused_by(trc::location!())?
- .paths
- .by_name(name)
- .is_some()
+ } else if name.contains('/')
+ || self
+ .fetch_dav_resources(access_token, account_id, Collection::Calendar)
+ .await
+ .caused_by(trc::location!())?
+ .paths
+ .by_name(name)
+ .is_some()
{
return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED));
}
@@ -98,7 +99,7 @@ impl CalendarMkColRequestHandler for Server {
let mut return_prop_stat = None;
let mut is_mkcalendar = false;
if let Some(mkcol) = request {
- let mut prop_stat = Vec::new();
+ let mut prop_stat = PropStatBuilder::default();
is_mkcalendar = mkcol.is_mkcalendar;
if !self.apply_calendar_properties(
account_id,
@@ -108,7 +109,7 @@ impl CalendarMkColRequestHandler for Server {
&mut prop_stat,
) {
return Ok(HttpResponse::new(StatusCode::FORBIDDEN).with_xml_body(
- MkColResponse::new(prop_stat)
+ MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CalDav)
.with_mkcalendar(is_mkcalendar)
.to_string(),
@@ -133,7 +134,7 @@ impl CalendarMkColRequestHandler for Server {
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body(
- MkColResponse::new(prop_stat)
+ MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CalDav)
.with_mkcalendar(is_mkcalendar)
.to_string(),
diff --git a/crates/dav/src/calendar/proppatch.rs b/crates/dav/src/calendar/proppatch.rs
index 4308ef5e..5b204506 100644
--- a/crates/dav/src/calendar/proppatch.rs
+++ b/crates/dav/src/calendar/proppatch.rs
@@ -4,14 +4,17 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
+use std::str::FromStr;
+
use crate::{
- DavError, DavMethod,
+ DavError, DavMethod, PropStatBuilder,
common::{
ETag, ExtractETag,
lock::{LockRequestHandler, ResourceState},
uri::DavUriResource,
},
};
+use calcard::common::timezone::Tz;
use common::{Server, auth::AccessToken};
use dav_proto::{
RequestHeaders, Return,
@@ -19,7 +22,7 @@ use dav_proto::{
Namespace,
property::{CalDavProperty, DavProperty, DavValue, ResourceType, WebDavProperty},
request::{DavPropertyValue, PropertyUpdate},
- response::{BaseCondition, CalCondition, MultiStatus, PropStat, Response},
+ response::{BaseCondition, CalCondition, MultiStatus, Response},
},
};
use groupware::{
@@ -46,7 +49,7 @@ pub(crate) trait CalendarPropPatchRequestHandler: Sync + Send {
calendar: &mut Calendar,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool;
fn apply_event_properties(
@@ -54,7 +57,7 @@ pub(crate) trait CalendarPropPatchRequestHandler: Sync + Send {
event: &mut CalendarEvent,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool;
}
@@ -140,7 +143,7 @@ impl CalendarPropPatchRequestHandler for Server {
let is_success;
let mut batch = BatchBuilder::new();
- let mut items = Vec::with_capacity(request.remove.len() + request.set.len());
+ let mut items = PropStatBuilder::default();
let etag = if resource.is_container() {
// Deserialize
@@ -231,7 +234,7 @@ impl CalendarPropPatchRequestHandler for Server {
if headers.ret != Return::Minimal || !is_success {
Ok(HttpResponse::new(StatusCode::MULTI_STATUS)
.with_xml_body(
- MultiStatus::new(vec![Response::new_propstat(uri, items)])
+ MultiStatus::new(vec![Response::new_propstat(uri, items.build())])
.with_namespace(Namespace::CalDav)
.to_string(),
)
@@ -247,24 +250,21 @@ impl CalendarPropPatchRequestHandler for Server {
calendar: &mut Calendar,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool {
let mut has_errors = false;
for property in properties {
- match (property.property, property.value) {
+ match (&property.property, property.value) {
(DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
calendar.preferences_mut(account_id).name = name;
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Display name too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
@@ -273,18 +273,16 @@ impl CalendarPropPatchRequestHandler for Server {
DavProperty::CalDav(CalDavProperty::CalendarDescription),
DavValue::String(name),
) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
calendar.preferences_mut(account_id).description = Some(name);
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::CalendarDescription))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::CalendarDescription))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Calendar description too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
+
has_errors = true;
}
}
@@ -292,42 +290,36 @@ impl CalendarPropPatchRequestHandler for Server {
DavProperty::CalDav(CalDavProperty::CalendarTimezone),
DavValue::ICalendar(ical),
) => {
- if ical.size() > self.core.dav.max_ical_size {
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::CalendarTimezone))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Calendar timezone too large"),
+ if ical.size() > self.core.groupware.max_ical_size {
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
} else if !ical.is_timezone() {
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::CalendarTimezone))
- .with_status(StatusCode::PRECONDITION_FAILED)
- .with_error(CalCondition::ValidCalendarData)
- .with_response_description("Invalid calendar timezone"),
+ items.insert_precondition_failed_with_description(
+ property.property,
+ StatusCode::PRECONDITION_FAILED,
+ CalCondition::ValidCalendarData,
+ "Invalid calendar timezone",
);
has_errors = true;
} else {
calendar.preferences_mut(account_id).time_zone = Timezone::Custom(ical);
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::CalendarTimezone))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
}
}
(DavProperty::CalDav(CalDavProperty::TimezoneId), DavValue::String(tz_id)) => {
- if !tz_id.is_empty() {
- calendar.preferences_mut(account_id).time_zone = Timezone::IANA(tz_id);
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::TimezoneId))
- .with_status(StatusCode::OK),
- );
+ if let Ok(tz) = Tz::from_str(&tz_id) {
+ calendar.preferences_mut(account_id).time_zone = Timezone::IANA(tz.as_id());
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::TimezoneId))
- .with_status(StatusCode::PRECONDITION_FAILED)
- .with_error(CalCondition::ValidTimezone)
- .with_response_description("Invalid timezone ID"),
+ items.insert_precondition_failed_with_description(
+ property.property,
+ StatusCode::PRECONDITION_FAILED,
+ CalCondition::ValidTimezone,
+ "Invalid timezone ID",
);
has_errors = true;
}
@@ -339,53 +331,48 @@ impl CalendarPropPatchRequestHandler for Server {
DavProperty::WebDav(WebDavProperty::ResourceType),
DavValue::ResourceTypes(types),
) => {
- if types
+ if !types
.0
.iter()
.all(|rt| matches!(rt, ResourceType::Collection | ResourceType::Calendar))
{
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::ResourceType))
- .with_status(StatusCode::FORBIDDEN)
- .with_error(BaseCondition::ValidResourceType),
+ items.insert_precondition_failed(
+ property.property,
+ StatusCode::FORBIDDEN,
+ BaseCondition::ValidResourceType,
);
has_errors = true;
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::ResourceType))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
}
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
- if self.core.dav.dead_property_size.is_some() =>
+ if self.core.groupware.dead_property_size.is_some() =>
{
if is_update {
- calendar.dead_properties.remove_element(&dead);
+ calendar.dead_properties.remove_element(dead);
}
if calendar.dead_properties.size() + values.size() + dead.size()
- < self.core.dav.dead_property_size.unwrap()
+ < self.core.groupware.dead_property_size.unwrap()
{
calendar.dead_properties.add_element(dead.clone(), values.0);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Dead property is too large."),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
+
has_errors = true;
}
}
- (property, _) => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::CONFLICT,
+ "Property cannot be modified",
);
has_errors = true;
}
@@ -400,24 +387,21 @@ impl CalendarPropPatchRequestHandler for Server {
event: &mut CalendarEvent,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool {
let mut has_errors = false;
for property in properties {
- match (property.property, property.value) {
+ match (&property.property, property.value) {
(DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
event.display_name = Some(name);
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Display name too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
@@ -426,34 +410,31 @@ impl CalendarPropPatchRequestHandler for Server {
event.created = dt;
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
- if self.core.dav.dead_property_size.is_some() =>
+ if self.core.groupware.dead_property_size.is_some() =>
{
if is_update {
- event.dead_properties.remove_element(&dead);
+ event.dead_properties.remove_element(dead);
}
if event.dead_properties.size() + values.size() + dead.size()
- < self.core.dav.dead_property_size.unwrap()
+ < self.core.groupware.dead_property_size.unwrap()
{
event.dead_properties.add_element(dead.clone(), values.0);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Dead property is too large."),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
}
- (property, _) => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::CONFLICT,
+ "Property cannot be modified",
);
has_errors = true;
}
@@ -467,28 +448,23 @@ impl CalendarPropPatchRequestHandler for Server {
fn remove_event_properties(
event: &mut CalendarEvent,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) {
for property in properties {
- match property {
+ match &property {
DavProperty::WebDav(WebDavProperty::DisplayName) => {
event.display_name = None;
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::DeadProperty(dead) => {
- event.dead_properties.remove_element(&dead);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead)).with_status(StatusCode::OK),
- );
+ event.dead_properties.remove_element(dead);
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
- property => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be deleted"),
+ _ => {
+ items.insert_error_with_description(
+ property,
+ StatusCode::CONFLICT,
+ "Property cannot be deleted",
);
}
}
@@ -499,33 +475,28 @@ fn remove_calendar_properties(
account_id: u32,
calendar: &mut Calendar,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) {
for property in properties {
- match property {
+ match &property {
DavProperty::CalDav(CalDavProperty::CalendarDescription) => {
calendar.preferences_mut(account_id).description = None;
- items.push(
- PropStat::new(DavProperty::CalDav(CalDavProperty::CalendarDescription))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
- property @ (DavProperty::CalDav(CalDavProperty::CalendarTimezone)
- | DavProperty::CalDav(CalDavProperty::TimezoneId)) => {
+ DavProperty::CalDav(CalDavProperty::CalendarTimezone)
+ | DavProperty::CalDav(CalDavProperty::TimezoneId) => {
calendar.preferences_mut(account_id).time_zone = Timezone::Default;
- items.push(PropStat::new(property).with_status(StatusCode::OK));
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::DeadProperty(dead) => {
- calendar.dead_properties.remove_element(&dead);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead)).with_status(StatusCode::OK),
- );
+ calendar.dead_properties.remove_element(dead);
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
- property => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be deleted"),
+ _ => {
+ items.insert_error_with_description(
+ property,
+ StatusCode::CONFLICT,
+ "Property cannot be deleted",
);
}
}
diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs
index 1b1a7eef..e805d412 100644
--- a/crates/dav/src/calendar/update.rs
+++ b/crates/dav/src/calendar/update.rs
@@ -70,10 +70,10 @@ impl CalendarUpdateRequestHandler for Server {
.resource
.ok_or(DavError::Code(StatusCode::CONFLICT))?;
- if bytes.len() > self.core.dav.max_ical_size {
+ if bytes.len() > self.core.groupware.max_ical_size {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
- CalCondition::MaxResourceSize(self.core.dav.max_ical_size as u32),
+ CalCondition::MaxResourceSize(self.core.groupware.max_ical_size as u32),
)));
}
let ical_raw = std::str::from_utf8(&bytes).map_err(|_| {
@@ -184,7 +184,7 @@ impl CalendarUpdateRequestHandler for Server {
.deserialize::()
.caused_by(trc::location!())?;
new_event.size = bytes.len() as u32;
- new_event.data = CalendarEventData::new(ical, self.core.dav.max_ical_instances);
+ new_event.data = CalendarEventData::new(ical, self.core.groupware.max_ical_instances);
// Prepare write batch
let mut batch = BatchBuilder::new();
@@ -257,7 +257,7 @@ impl CalendarUpdateRequestHandler for Server {
name: name.to_string(),
parent_id: parent.document_id,
}],
- data: CalendarEventData::new(ical, self.core.dav.max_ical_instances),
+ data: CalendarEventData::new(ical, self.core.groupware.max_ical_instances),
size: bytes.len() as u32,
..Default::default()
};
@@ -287,7 +287,7 @@ fn validate_ical(ical: &ICalendar) -> crate::Result<&str> {
let uids = ical.uids().collect::>();
// Validate component types
- let mut types: [u8; 4] = [0; 4];
+ let mut types: [u8; 5] = [0; 5];
for comp in &ical.components {
match comp.component_type {
ICalendarComponentType::VEvent => {
@@ -302,11 +302,14 @@ fn validate_ical(ical: &ICalendar) -> crate::Result<&str> {
ICalendarComponentType::VFreebusy => {
types[3] += 1;
}
+ ICalendarComponentType::VAvailability => {
+ types[4] += 1;
+ }
_ => {}
}
}
- if uids.len() == 1 && types.iter().filter(|&&v| v == 0).count() == 3 {
+ if uids.len() == 1 && types.iter().filter(|&&v| v == 0).count() == 4 {
Ok(uids.iter().next().unwrap())
} else {
Err(DavError::Condition(DavErrorCondition::new(
diff --git a/crates/dav/src/card/delete.rs b/crates/dav/src/card/delete.rs
index c62ebb47..61b87fcd 100644
--- a/crates/dav/src/card/delete.rs
+++ b/crates/dav/src/card/delete.rs
@@ -59,7 +59,7 @@ impl CardDeleteRequestHandler for Server {
let delete_resource = resources
.paths
.by_name(delete_path)
- .ok_or(DavError::Code(StatusCode::FORBIDDEN))?;
+ .ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
let document_id = delete_resource.document_id;
// Fetch entry
diff --git a/crates/dav/src/card/mkcol.rs b/crates/dav/src/card/mkcol.rs
index 23e77d76..8705a16e 100644
--- a/crates/dav/src/card/mkcol.rs
+++ b/crates/dav/src/card/mkcol.rs
@@ -4,6 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
+use super::proppatch::CardPropPatchRequestHandler;
+use crate::{
+ DavError, DavMethod, PropStatBuilder,
+ common::{
+ lock::{LockRequestHandler, ResourceState},
+ uri::DavUriResource,
+ },
+};
use common::{Server, auth::AccessToken};
use dav_proto::{
RequestHeaders, Return,
@@ -16,16 +24,6 @@ use jmap_proto::types::collection::Collection;
use store::write::BatchBuilder;
use trc::AddContext;
-use crate::{
- DavError, DavMethod,
- common::{
- lock::{LockRequestHandler, ResourceState},
- uri::DavUriResource,
- },
-};
-
-use super::proppatch::CardPropPatchRequestHandler;
-
pub(crate) trait CardMkColRequestHandler: Sync + Send {
fn handle_card_mkcol_request(
&self,
@@ -51,15 +49,16 @@ impl CardMkColRequestHandler for Server {
let name = resource
.resource
.ok_or(DavError::Code(StatusCode::FORBIDDEN))?;
- if name.contains('/') || !access_token.is_member(account_id) {
+ if !access_token.is_member(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
- } else if self
- .fetch_dav_resources(access_token, account_id, Collection::AddressBook)
- .await
- .caused_by(trc::location!())?
- .paths
- .by_name(name)
- .is_some()
+ } else if name.contains('/')
+ || self
+ .fetch_dav_resources(access_token, account_id, Collection::AddressBook)
+ .await
+ .caused_by(trc::location!())?
+ .paths
+ .by_name(name)
+ .is_some()
{
return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED));
}
@@ -89,10 +88,10 @@ impl CardMkColRequestHandler for Server {
// Apply MKCOL properties
let mut return_prop_stat = None;
if let Some(mkcol) = request {
- let mut prop_stat = Vec::new();
+ let mut prop_stat = PropStatBuilder::default();
if !self.apply_addressbook_properties(&mut book, false, mkcol.props, &mut prop_stat) {
return Ok(HttpResponse::new(StatusCode::FORBIDDEN).with_xml_body(
- MkColResponse::new(prop_stat)
+ MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CardDav)
.to_string(),
));
@@ -115,7 +114,7 @@ impl CardMkColRequestHandler for Server {
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body(
- MkColResponse::new(prop_stat)
+ MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CardDav)
.to_string(),
))
diff --git a/crates/dav/src/card/proppatch.rs b/crates/dav/src/card/proppatch.rs
index 09579beb..43cbb74c 100644
--- a/crates/dav/src/card/proppatch.rs
+++ b/crates/dav/src/card/proppatch.rs
@@ -11,7 +11,7 @@ use dav_proto::{
Namespace,
property::{CardDavProperty, DavProperty, DavValue, ResourceType, WebDavProperty},
request::{DavPropertyValue, PropertyUpdate},
- response::{BaseCondition, MultiStatus, PropStat, Response},
+ response::{BaseCondition, MultiStatus, Response},
},
};
use groupware::{
@@ -25,7 +25,7 @@ use store::write::BatchBuilder;
use trc::AddContext;
use crate::{
- DavError, DavMethod,
+ DavError, DavMethod, PropStatBuilder,
common::{
ETag, ExtractETag,
lock::{LockRequestHandler, ResourceState},
@@ -46,7 +46,7 @@ pub(crate) trait CardPropPatchRequestHandler: Sync + Send {
address_book: &mut AddressBook,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool;
fn apply_card_properties(
@@ -54,7 +54,7 @@ pub(crate) trait CardPropPatchRequestHandler: Sync + Send {
card: &mut ContactCard,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool;
}
@@ -140,7 +140,7 @@ impl CardPropPatchRequestHandler for Server {
let is_success;
let mut batch = BatchBuilder::new();
- let mut items = Vec::with_capacity(request.remove.len() + request.set.len());
+ let mut items = PropStatBuilder::default();
let etag = if resource.is_container() {
// Deserialize
@@ -220,7 +220,7 @@ impl CardPropPatchRequestHandler for Server {
if headers.ret != Return::Minimal || !is_success {
Ok(HttpResponse::new(StatusCode::MULTI_STATUS)
.with_xml_body(
- MultiStatus::new(vec![Response::new_propstat(uri, items)])
+ MultiStatus::new(vec![Response::new_propstat(uri, items.build())])
.with_namespace(Namespace::CardDav)
.to_string(),
)
@@ -235,24 +235,21 @@ impl CardPropPatchRequestHandler for Server {
address_book: &mut AddressBook,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool {
let mut has_errors = false;
for property in properties {
- match (property.property, property.value) {
+ match (&property.property, property.value) {
(DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
address_book.display_name = Some(name);
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Display name too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
@@ -261,22 +258,16 @@ impl CardPropPatchRequestHandler for Server {
DavProperty::CardDav(CardDavProperty::AddressbookDescription),
DavValue::String(name),
) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
address_book.description = Some(name);
- items.push(
- PropStat::new(DavProperty::CardDav(
- CardDavProperty::AddressbookDescription,
- ))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::CardDav(
- CardDavProperty::AddressbookDescription,
- ))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Addressbook description too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
+
has_errors = true;
}
}
@@ -287,53 +278,47 @@ impl CardPropPatchRequestHandler for Server {
DavProperty::WebDav(WebDavProperty::ResourceType),
DavValue::ResourceTypes(types),
) => {
- if types.0.iter().all(|rt| {
+ if !types.0.iter().all(|rt| {
matches!(rt, ResourceType::Collection | ResourceType::AddressBook)
}) {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::ResourceType))
- .with_status(StatusCode::FORBIDDEN)
- .with_error(BaseCondition::ValidResourceType),
+ items.insert_precondition_failed(
+ property.property,
+ StatusCode::FORBIDDEN,
+ BaseCondition::ValidResourceType,
);
has_errors = true;
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::ResourceType))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
}
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
- if self.core.dav.dead_property_size.is_some() =>
+ if self.core.groupware.dead_property_size.is_some() =>
{
if is_update {
- address_book.dead_properties.remove_element(&dead);
+ address_book.dead_properties.remove_element(dead);
}
if address_book.dead_properties.size() + values.size() + dead.size()
- < self.core.dav.dead_property_size.unwrap()
+ < self.core.groupware.dead_property_size.unwrap()
{
address_book
.dead_properties
.add_element(dead.clone(), values.0);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Dead property is too large."),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
}
- (property, _) => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::CONFLICT,
+ "Property cannot be modified",
);
has_errors = true;
}
@@ -348,24 +333,21 @@ impl CardPropPatchRequestHandler for Server {
card: &mut ContactCard,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool {
let mut has_errors = false;
for property in properties {
- match (property.property, property.value) {
+ match (&property.property, property.value) {
(DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
card.display_name = Some(name);
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Display name too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
@@ -374,34 +356,31 @@ impl CardPropPatchRequestHandler for Server {
card.created = dt;
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
- if self.core.dav.dead_property_size.is_some() =>
+ if self.core.groupware.dead_property_size.is_some() =>
{
if is_update {
- card.dead_properties.remove_element(&dead);
+ card.dead_properties.remove_element(dead);
}
if card.dead_properties.size() + values.size() + dead.size()
- < self.core.dav.dead_property_size.unwrap()
+ < self.core.groupware.dead_property_size.unwrap()
{
card.dead_properties.add_element(dead.clone(), values.0);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Dead property is too large."),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
}
- (property, _) => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::CONFLICT,
+ "Property cannot be modified",
);
has_errors = true;
}
@@ -415,28 +394,23 @@ impl CardPropPatchRequestHandler for Server {
fn remove_card_properties(
card: &mut ContactCard,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) {
for property in properties {
- match property {
+ match &property {
DavProperty::WebDav(WebDavProperty::DisplayName) => {
card.display_name = None;
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::DeadProperty(dead) => {
- card.dead_properties.remove_element(&dead);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead)).with_status(StatusCode::OK),
- );
+ card.dead_properties.remove_element(dead);
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
- property => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property,
+ StatusCode::CONFLICT,
+ "Property cannot be deleted",
);
}
}
@@ -446,37 +420,27 @@ fn remove_card_properties(
fn remove_addressbook_properties(
book: &mut AddressBook,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) {
for property in properties {
- match property {
+ match &property {
DavProperty::CardDav(CardDavProperty::AddressbookDescription) => {
book.description = None;
- items.push(
- PropStat::new(DavProperty::CardDav(
- CardDavProperty::AddressbookDescription,
- ))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::WebDav(WebDavProperty::DisplayName) => {
book.display_name = None;
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::DeadProperty(dead) => {
- book.dead_properties.remove_element(&dead);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead)).with_status(StatusCode::OK),
- );
+ book.dead_properties.remove_element(dead);
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
- property => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property,
+ StatusCode::CONFLICT,
+ "Property cannot be deleted",
);
}
}
diff --git a/crates/dav/src/card/update.rs b/crates/dav/src/card/update.rs
index c021896a..d6ad2199 100644
--- a/crates/dav/src/card/update.rs
+++ b/crates/dav/src/card/update.rs
@@ -61,10 +61,10 @@ impl CardUpdateRequestHandler for Server {
.resource
.ok_or(DavError::Code(StatusCode::CONFLICT))?;
- if bytes.len() > self.core.dav.max_vcard_size {
+ if bytes.len() > self.core.groupware.max_vcard_size {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
- CardCondition::MaxResourceSize(self.core.dav.max_vcard_size as u32),
+ CardCondition::MaxResourceSize(self.core.groupware.max_vcard_size as u32),
)));
}
let vcard_raw = std::str::from_utf8(&bytes).map_err(|_| {
diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs
index 007db54d..0f0e47f3 100644
--- a/crates/dav/src/common/lock.rs
+++ b/crates/dav/src/common/lock.rs
@@ -186,15 +186,13 @@ impl LockRequestHandler for Server {
}
// Validate lock_info
- if lock_info
- .owner
- .as_ref()
- .is_some_and(|o| o.size() > self.core.dav.dead_property_size.unwrap_or(512))
- {
+ if lock_info.owner.as_ref().is_some_and(|o| {
+ o.size() > self.core.groupware.dead_property_size.unwrap_or(512)
+ }) {
return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE));
}
- if self.core.dav.max_locks_per_user > 0
+ if self.core.groupware.max_locks_per_user > 0
&& lock_data
.locks
.values()
@@ -205,7 +203,7 @@ impl LockRequestHandler for Server {
.filter(|lock| lock.owner == access_token.primary_id)
})
.count()
- >= self.core.dav.max_locks_per_user
+ >= self.core.groupware.max_locks_per_user
{
return Err(DavError::Code(StatusCode::TOO_MANY_REQUESTS));
}
@@ -234,9 +232,9 @@ impl LockRequestHandler for Server {
let now = now();
let response = if is_lock_request {
let timeout = if let Timeout::Second(seconds) = headers.timeout {
- std::cmp::min(seconds, self.core.dav.max_lock_timeout)
+ std::cmp::min(seconds, self.core.groupware.max_lock_timeout)
} else {
- self.core.dav.max_lock_timeout
+ self.core.groupware.max_lock_timeout
};
let expires = now + timeout;
diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs
index ed0b5bfe..f736f89a 100644
--- a/crates/dav/src/common/propfind.rs
+++ b/crates/dav/src/common/propfind.rs
@@ -391,7 +391,7 @@ impl PropFindRequestHandler for Server {
.caused_by(trc::location!())?;
let limit = std::cmp::min(
query.limit.unwrap_or(u32::MAX) as usize,
- self.core.dav.max_changes,
+ self.core.groupware.max_changes,
);
// Set sync token
@@ -591,7 +591,7 @@ impl PropFindRequestHandler for Server {
DavQueryResource::None => unreachable!(),
}
- if query.depth == usize::MAX && paths.len() > self.core.dav.max_match_results {
+ if query.depth == usize::MAX && paths.len() > self.core.groupware.max_match_results {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
BaseCondition::NumberOfMatchesWithinLimit,
@@ -1025,10 +1025,10 @@ impl PropFindRequestHandler for Server {
(
CardDavProperty::AddressbookDescription,
ArchivedResource::AddressBook(book),
- ) if book.inner.display_name.is_some() => {
+ ) if book.inner.description.is_some() => {
fields.push(DavPropertyValue::new(
property.clone(),
- book.inner.display_name.as_ref().unwrap().to_string(),
+ book.inner.description.as_ref().unwrap().to_string(),
));
}
(
@@ -1061,7 +1061,7 @@ impl PropFindRequestHandler for Server {
(CardDavProperty::MaxResourceSize, ArchivedResource::AddressBook(_)) => {
fields.push(DavPropertyValue::new(
property.clone(),
- self.core.dav.max_vcard_size as u64,
+ self.core.groupware.max_vcard_size as u64,
));
}
(
@@ -1159,7 +1159,7 @@ impl PropFindRequestHandler for Server {
(CalDavProperty::MaxResourceSize, ArchivedResource::Calendar(_)) => {
fields.push(DavPropertyValue::new(
property.clone(),
- self.core.dav.max_ical_size as u64,
+ self.core.groupware.max_ical_size as u64,
));
}
(CalDavProperty::MinDateTime, ArchivedResource::Calendar(_)) => {
@@ -1177,7 +1177,7 @@ impl PropFindRequestHandler for Server {
(CalDavProperty::MaxInstances, ArchivedResource::Calendar(_)) => {
fields.push(DavPropertyValue::new(
property.clone(),
- self.core.dav.max_ical_instances as u64,
+ self.core.groupware.max_ical_instances as u64,
));
}
(
@@ -1186,7 +1186,7 @@ impl PropFindRequestHandler for Server {
) => {
fields.push(DavPropertyValue::new(
property.clone(),
- self.core.dav.max_ical_attendees_per_instance as u64,
+ self.core.groupware.max_ical_attendees_per_instance as u64,
));
}
(
diff --git a/crates/dav/src/common/uri.rs b/crates/dav/src/common/uri.rs
index 1e6718e9..78013881 100644
--- a/crates/dav/src/common/uri.rs
+++ b/crates/dav/src/common/uri.rs
@@ -118,7 +118,8 @@ impl DavUriResource for Server {
.by_name(resource)
{
Ok(Some(DocumentUri {
- collection: if resource.is_container() || uri.collection == Collection::FileNode {
+ collection: if resource.is_container() || uri.collection == Collection::FileNode
+ {
uri.collection
} else if uri.collection == Collection::Calendar {
Collection::CalendarEvent
@@ -143,7 +144,7 @@ impl<'x> UnresolvedUri<'x> {
collection: self.collection,
account_id: self
.account_id
- .ok_or(DavError::Code(StatusCode::NOT_FOUND))?,
+ .ok_or(DavError::Code(StatusCode::FORBIDDEN))?,
resource: self.resource,
})
}
diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs
index 7b05c08e..6c68fe57 100644
--- a/crates/dav/src/file/copy_move.rs
+++ b/crates/dav/src/file/copy_move.rs
@@ -4,21 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
-use std::sync::Arc;
-
-use common::{DavResources, Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
-use dav_proto::{Depth, RequestHeaders};
-use groupware::{DestroyArchive, file::FileNode, hierarchy::DavHierarchy};
-use http_proto::HttpResponse;
-use hyper::StatusCode;
-use jmap_proto::types::{acl::Acl, collection::Collection};
-use store::{
- ahash::AHashMap,
- write::{BatchBuilder, now},
-};
-use trc::AddContext;
-use utils::map::bitmap::Bitmap;
-
+use super::FromDavResource;
use crate::{
DavError, DavMethod,
common::{
@@ -29,8 +15,18 @@ use crate::{
},
file::{DavFileResource, FileItemId},
};
-
-use super::FromDavResource;
+use common::{DavResources, Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
+use dav_proto::{Depth, RequestHeaders};
+use groupware::{DestroyArchive, file::FileNode, hierarchy::DavHierarchy};
+use http_proto::HttpResponse;
+use hyper::StatusCode;
+use jmap_proto::types::{acl::Acl, collection::Collection};
+use std::sync::Arc;
+use store::{
+ ahash::AHashMap,
+ write::{BatchBuilder, now},
+};
+use trc::AddContext;
pub(crate) trait FileCopyMoveRequestHandler: Sync + Send {
fn handle_file_copy_move_request(
diff --git a/crates/dav/src/file/delete.rs b/crates/dav/src/file/delete.rs
index e2004f07..cf3a34f2 100644
--- a/crates/dav/src/file/delete.rs
+++ b/crates/dav/src/file/delete.rs
@@ -56,7 +56,7 @@ impl FileDeleteRequestHandler for Server {
}
// Sort ids descending from the deepest to the root
- ids.sort_unstable_by(|a, b| b.hierarchy_sequence().cmp(&a.hierarchy_sequence()));
+ ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_sequence()));
let document_id = ids.last().map(|a| a.document_id).unwrap();
let mut sorted_ids = Vec::with_capacity(ids.len());
sorted_ids.extend(ids.into_iter().map(|a| a.document_id));
diff --git a/crates/dav/src/file/mkcol.rs b/crates/dav/src/file/mkcol.rs
index 2d1ff3fd..e9c38ae5 100644
--- a/crates/dav/src/file/mkcol.rs
+++ b/crates/dav/src/file/mkcol.rs
@@ -17,7 +17,7 @@ use store::write::{BatchBuilder, now};
use trc::AddContext;
use crate::{
- DavMethod,
+ DavMethod, PropStatBuilder,
common::{
acl::DavAclHandler,
lock::{LockRequestHandler, ResourceState},
@@ -99,10 +99,10 @@ impl FileMkColRequestHandler for Server {
// Apply MKCOL properties
let mut return_prop_stat = None;
if let Some(mkcol) = request {
- let mut prop_stat = Vec::new();
+ let mut prop_stat = PropStatBuilder::default();
if !self.apply_file_properties(&mut node, false, mkcol.props, &mut prop_stat) {
return Ok(HttpResponse::new(StatusCode::FORBIDDEN).with_xml_body(
- MkColResponse::new(prop_stat)
+ MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::Dav)
.to_string(),
));
@@ -129,7 +129,7 @@ impl FileMkColRequestHandler for Server {
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body(
- MkColResponse::new(prop_stat)
+ MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::Dav)
.to_string(),
))
diff --git a/crates/dav/src/file/proppatch.rs b/crates/dav/src/file/proppatch.rs
index 0a39dfa9..2541be05 100644
--- a/crates/dav/src/file/proppatch.rs
+++ b/crates/dav/src/file/proppatch.rs
@@ -10,7 +10,7 @@ use dav_proto::{
schema::{
property::{DavProperty, DavValue, ResourceType, WebDavProperty},
request::{DavPropertyValue, PropertyUpdate},
- response::{BaseCondition, MultiStatus, PropStat, Response},
+ response::{BaseCondition, MultiStatus, Response},
},
};
use groupware::{file::FileNode, hierarchy::DavHierarchy};
@@ -21,7 +21,7 @@ use store::write::BatchBuilder;
use trc::AddContext;
use crate::{
- DavError, DavMethod,
+ DavError, DavMethod, PropStatBuilder,
common::{
ETag, ExtractETag,
lock::{LockRequestHandler, ResourceState},
@@ -43,7 +43,7 @@ pub(crate) trait FilePropPatchRequestHandler: Sync + Send {
file: &mut FileNode,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool;
}
@@ -113,7 +113,7 @@ impl FilePropPatchRequestHandler for Server {
let mut new_node = node.deserialize::().caused_by(trc::location!())?;
// Remove properties
- let mut items = Vec::with_capacity(request.remove.len() + request.set.len());
+ let mut items = PropStatBuilder::default();
if !request.set_first && !request.remove.is_empty() {
remove_file_properties(
&mut new_node,
@@ -151,7 +151,7 @@ impl FilePropPatchRequestHandler for Server {
if headers.ret != Return::Minimal || !is_success {
Ok(HttpResponse::new(StatusCode::MULTI_STATUS)
.with_xml_body(
- MultiStatus::new(vec![Response::new_propstat(uri, items)]).to_string(),
+ MultiStatus::new(vec![Response::new_propstat(uri, items.build())]).to_string(),
)
.with_etag_opt(etag))
} else {
@@ -164,25 +164,23 @@ impl FilePropPatchRequestHandler for Server {
file: &mut FileNode,
is_update: bool,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) -> bool {
let mut has_errors = false;
for property in properties {
- match (property.property, property.value) {
+ match (&property.property, property.value) {
(DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => {
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
file.display_name = Some(name);
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Display name too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
+
has_errors = true;
}
}
@@ -192,17 +190,14 @@ impl FilePropPatchRequestHandler for Server {
(DavProperty::WebDav(WebDavProperty::GetContentType), DavValue::String(name))
if file.file.is_some() =>
{
- if name.len() <= self.core.dav.live_property_size {
+ if name.len() <= self.core.groupware.live_property_size {
file.file.as_mut().unwrap().media_type = Some(name);
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::GetContentType))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::GetContentType))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Content-type is too long"),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
@@ -212,48 +207,42 @@ impl FilePropPatchRequestHandler for Server {
DavValue::ResourceTypes(types),
) if file.file.is_none() => {
if types.0.len() != 1 || types.0.first() != Some(&ResourceType::Collection) {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::ResourceType))
- .with_status(StatusCode::FORBIDDEN)
- .with_error(BaseCondition::ValidResourceType),
+ items.insert_precondition_failed(
+ property.property,
+ StatusCode::FORBIDDEN,
+ BaseCondition::ValidResourceType,
);
has_errors = true;
} else {
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::ResourceType))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
}
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
- if self.core.dav.dead_property_size.is_some() =>
+ if self.core.groupware.dead_property_size.is_some() =>
{
if is_update {
- file.dead_properties.remove_element(&dead);
+ file.dead_properties.remove_element(dead);
}
if file.dead_properties.size() + values.size() + dead.size()
- < self.core.dav.dead_property_size.unwrap()
+ < self.core.groupware.dead_property_size.unwrap()
{
file.dead_properties.add_element(dead.clone(), values.0);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::OK),
- );
+ items.insert_ok(property.property);
} else {
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead))
- .with_status(StatusCode::INSUFFICIENT_STORAGE)
- .with_response_description("Dead property is too large."),
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::INSUFFICIENT_STORAGE,
+ "Property value is too long",
);
has_errors = true;
}
}
- (property, _) => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property.property,
+ StatusCode::CONFLICT,
+ "Property cannot be modified",
);
has_errors = true;
}
@@ -267,35 +256,27 @@ impl FilePropPatchRequestHandler for Server {
fn remove_file_properties(
node: &mut FileNode,
properties: Vec,
- items: &mut Vec,
+ items: &mut PropStatBuilder,
) {
for property in properties {
- match property {
+ match &property {
DavProperty::WebDav(WebDavProperty::DisplayName) => {
node.display_name = None;
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::DisplayName))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::WebDav(WebDavProperty::GetContentType) if node.file.is_some() => {
node.file.as_mut().unwrap().media_type = None;
- items.push(
- PropStat::new(DavProperty::WebDav(WebDavProperty::GetContentType))
- .with_status(StatusCode::OK),
- );
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
DavProperty::DeadProperty(dead) => {
- node.dead_properties.remove_element(&dead);
- items.push(
- PropStat::new(DavProperty::DeadProperty(dead)).with_status(StatusCode::OK),
- );
+ node.dead_properties.remove_element(dead);
+ items.insert_with_status(property, StatusCode::NO_CONTENT);
}
- property => {
- items.push(
- PropStat::new(property)
- .with_status(StatusCode::CONFLICT)
- .with_response_description("Property cannot be modified"),
+ _ => {
+ items.insert_error_with_description(
+ property,
+ StatusCode::CONFLICT,
+ "Property cannot be deleted",
);
}
}
diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs
index 78943b79..5cf7d720 100644
--- a/crates/dav/src/file/update.rs
+++ b/crates/dav/src/file/update.rs
@@ -62,6 +62,10 @@ impl FileUpdateRequestHandler for Server {
.resource
.ok_or(DavError::Code(StatusCode::CONFLICT))?;
+ if bytes.len() > self.core.groupware.max_file_size {
+ return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE));
+ }
+
if let Some(document_id) = files.paths.by_name(resource_name).map(|r| r.document_id) {
// Update
let node_ = self
@@ -162,7 +166,10 @@ impl FileUpdateRequestHandler for Server {
let mut new_node = node.deserialize::().caused_by(trc::location!())?;
let new_file = new_node.file.as_mut().unwrap();
new_file.blob_hash = blob_hash;
- new_file.media_type = headers.content_type.map(|v| v.to_string());
+ new_file.media_type = headers
+ .content_type
+ .filter(|ct| !ct.is_empty() && *ct != "application/octet-stream")
+ .map(|v| v.to_string());
new_file.size = bytes.len() as u32;
new_node.modified = now() as i64;
@@ -203,15 +210,10 @@ impl FileUpdateRequestHandler for Server {
// Verify that parent is a collection
if parent_id > 0
- && self
- .get_archive(account_id, Collection::FileNode, parent_id - 1)
- .await
- .caused_by(trc::location!())?
- .ok_or(DavError::Code(StatusCode::NOT_FOUND))?
- .unarchive::()
- .caused_by(trc::location!())?
- .file
- .is_some()
+ && files
+ .paths
+ .by_id(parent_id - 1)
+ .is_some_and(|r| !r.is_container())
{
return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED));
}
diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs
index 41a74e92..77536c1b 100644
--- a/crates/dav/src/lib.rs
+++ b/crates/dav/src/lib.rs
@@ -11,9 +11,13 @@ pub mod file;
pub mod principal;
pub mod request;
-use dav_proto::schema::response::Condition;
+use dav_proto::schema::{
+ request::DavPropertyValue,
+ response::{Condition, List, Prop, PropStat, ResponseDescription, Status},
+};
use groupware::DavResourceName;
use hyper::{Method, StatusCode};
+use store::ahash::AHashMap;
pub(crate) type Result = std::result::Result;
@@ -117,3 +121,82 @@ impl DavMethod {
)
}
}
+
+#[derive(Debug, Default)]
+pub struct PropStatBuilder {
+ propstats: AHashMap<(StatusCode, Option, Option), Vec>,
+}
+
+impl PropStatBuilder {
+ pub fn insert_ok(&mut self, prop: impl Into) -> &mut Self {
+ self.propstats
+ .entry((StatusCode::OK, None, None))
+ .or_default()
+ .push(prop.into());
+ self
+ }
+
+ pub fn insert_with_status(
+ &mut self,
+ prop: impl Into,
+ status: StatusCode,
+ ) -> &mut Self {
+ self.propstats
+ .entry((status, None, None))
+ .or_default()
+ .push(prop.into());
+ self
+ }
+
+ pub fn insert_error_with_description(
+ &mut self,
+ prop: impl Into,
+ status: StatusCode,
+ description: impl Into,
+ ) -> &mut Self {
+ self.propstats
+ .entry((status, None, Some(description.into())))
+ .or_default()
+ .push(prop.into());
+ self
+ }
+
+ pub fn insert_precondition_failed(
+ &mut self,
+ prop: impl Into,
+ status: StatusCode,
+ condition: impl Into,
+ ) -> &mut Self {
+ self.propstats
+ .entry((status, Some(condition.into()), None))
+ .or_default()
+ .push(prop.into());
+ self
+ }
+
+ pub fn insert_precondition_failed_with_description(
+ &mut self,
+ prop: impl Into,
+ status: StatusCode,
+ condition: impl Into,
+ description: impl Into,
+ ) -> &mut Self {
+ self.propstats
+ .entry((status, Some(condition.into()), Some(description.into())))
+ .or_default()
+ .push(prop.into());
+ self
+ }
+
+ pub fn build(self) -> Vec {
+ self.propstats
+ .into_iter()
+ .map(|((status, condition, description), props)| PropStat {
+ prop: Prop(List(props)),
+ status: Status(status),
+ error: condition,
+ response_description: description.map(ResponseDescription),
+ })
+ .collect()
+ }
+}
diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs
index 806ed9fe..c47d0c58 100644
--- a/crates/dav/src/request.rs
+++ b/crates/dav/src/request.rs
@@ -125,7 +125,8 @@ impl DavRequestDispatcher for Server {
self.handle_file_get_request(
&access_token,
headers,
- !request.headers().contains_key("x-litmus"),
+ matches!(method, DavMethod::HEAD)
+ && !request.headers().contains_key("x-litmus"),
)
.await
}
@@ -429,7 +430,7 @@ impl DavRequestHandler for Server {
if let Some(body) = fetch_body(
&mut request,
if !access_token.has_permission(Permission::UnlimitedUploads) {
- self.core.dav.max_request_size
+ self.core.groupware.max_request_size
} else {
0
},
diff --git a/crates/groupware/src/calendar/dates.rs b/crates/groupware/src/calendar/dates.rs
index 19790d9a..97cbbc4b 100644
--- a/crates/groupware/src/calendar/dates.rs
+++ b/crates/groupware/src/calendar/dates.rs
@@ -4,8 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
-use std::str::FromStr;
-
use super::{ArchivedCalendarEventData, ArchivedTimezone, CalendarEventData, Timezone};
use crate::calendar::ComponentTimeRange;
use calcard::{
@@ -238,7 +236,7 @@ impl ArchivedCalendarEventData {
impl Timezone {
pub fn tz(&self) -> Option {
match self {
- Timezone::IANA(iana) => Tz::from_str(iana).ok(),
+ Timezone::IANA(iana) => Tz::from_id(*iana),
Timezone::Custom(icalendar) => icalendar
.timezones()
.filter_map(|t| t.timezone().map(|x| x.1))
@@ -251,7 +249,7 @@ impl Timezone {
impl ArchivedTimezone {
pub fn tz(&self) -> Option {
match self {
- ArchivedTimezone::IANA(iana) => Tz::from_str(iana).ok(),
+ ArchivedTimezone::IANA(iana) => Tz::from_id(iana.to_native()),
ArchivedTimezone::Custom(icalendar) => icalendar
.timezones()
.filter_map(|t| t.timezone().map(|x| x.1))
diff --git a/crates/groupware/src/calendar/index.rs b/crates/groupware/src/calendar/index.rs
index 3f290a97..c3fd8015 100644
--- a/crates/groupware/src/calendar/index.rs
+++ b/crates/groupware/src/calendar/index.rs
@@ -199,7 +199,7 @@ impl ArchivedCalendarPreferences {
impl Timezone {
pub fn size(&self) -> usize {
match self {
- Timezone::IANA(s) => s.len(),
+ Timezone::IANA(_) => 2,
Timezone::Custom(c) => c.size(),
Timezone::Default => 0,
}
@@ -209,7 +209,7 @@ impl Timezone {
impl ArchivedTimezone {
pub fn size(&self) -> usize {
match self {
- ArchivedTimezone::IANA(s) => s.len(),
+ ArchivedTimezone::IANA(_) => 2,
ArchivedTimezone::Custom(c) => c.size(),
ArchivedTimezone::Default => 0,
}
diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs
index f1bf7cd5..3e7e3e0b 100644
--- a/crates/groupware/src/calendar/mod.rs
+++ b/crates/groupware/src/calendar/mod.rs
@@ -111,7 +111,7 @@ pub struct UserProperties {
rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq,
)]
pub enum Timezone {
- IANA(String),
+ IANA(u16),
Custom(ICalendar),
#[default]
Default,
diff --git a/crates/groupware/src/hierarchy.rs b/crates/groupware/src/hierarchy.rs
index c29c64e1..ddc7a0a1 100644
--- a/crates/groupware/src/hierarchy.rs
+++ b/crates/groupware/src/hierarchy.rs
@@ -115,7 +115,7 @@ impl DavHierarchy for Server {
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
- if let Some(name) = &self.core.dav.default_addressbook_name {
+ if let Some(name) = &self.core.groupware.default_addressbook_name {
let mut batch = BatchBuilder::new();
let document_id = self
.store()
@@ -123,7 +123,7 @@ impl DavHierarchy for Server {
.await?;
AddressBook {
name: name.clone(),
- display_name: self.core.dav.default_addressbook_display_name.clone(),
+ display_name: self.core.groupware.default_addressbook_display_name.clone(),
is_default: true,
..Default::default()
}
@@ -139,7 +139,7 @@ impl DavHierarchy for Server {
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
- if let Some(name) = &self.core.dav.default_calendar_name {
+ if let Some(name) = &self.core.groupware.default_calendar_name {
let mut batch = BatchBuilder::new();
let document_id = self
.store()
@@ -150,7 +150,7 @@ impl DavHierarchy for Server {
preferences: vec![CalendarPreferences {
account_id,
name: name.clone(),
- description: self.core.dav.default_calendar_display_name.clone(),
+ description: self.core.groupware.default_calendar_display_name.clone(),
..Default::default()
}],
..Default::default()
@@ -334,7 +334,7 @@ async fn build_file_hierarchy(server: &Server, account_id: u32) -> trc::Result String {
let mut cert_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
diff --git a/tests/src/webdav/basic.rs b/tests/src/webdav/basic.rs
new file mode 100644
index 00000000..90d64c24
--- /dev/null
+++ b/tests/src/webdav/basic.rs
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+use super::WebDavTest;
+
+pub async fn test(test: &WebDavTest) {
+ println!("Running basic tests...");
+ let client = test.client("john");
+
+ // Test OPTIONS request
+ client
+ .request("OPTIONS", "/dav/file", "")
+ .await
+ .with_header(
+ "dav",
+ "1, 2, 3, access-control, extended-mkcol, calendar-access, addressbook",
+ )
+ .with_header(
+ "allow",
+ concat!(
+ "OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, ",
+ "MKCALENDAR, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL"
+ ),
+ );
+
+ // Test Discovery
+ client
+ .request("PROPFIND", "/.well-known/carddav", "")
+ .await
+ .match_many(
+ "D:multistatus.D:response.D:href",
+ ["/dav/card/", "/dav/card/john/"],
+ );
+ test.client("jane")
+ .request("PROPFIND", "/.well-known/caldav", "")
+ .await
+ .match_many(
+ "D:multistatus.D:response.D:href",
+ [
+ "/dav/cal/",
+ "/dav/cal/jane/",
+ "/dav/cal/support%40example%2Ecom/",
+ ],
+ );
+}
diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs
new file mode 100644
index 00000000..0ca43c4e
--- /dev/null
+++ b/tests/src/webdav/mkcol.rs
@@ -0,0 +1,202 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+use hyper::StatusCode;
+
+use crate::webdav::{TEST_FILE_1, TEST_ICAL_1, TEST_VCARD_1, TEST_VTIMEZONE_1};
+
+use super::WebDavTest;
+
+pub async fn test(test: &WebDavTest) {
+ println!("Running MKCOL tests...");
+ let client = test.client("john");
+
+ // Creating collections in root elements is not allowed
+ for path in [
+ "/dav/file/test",
+ "/dav/card/test",
+ "/dav/cal/test",
+ "/dav/test",
+ ] {
+ client
+ .request("MKCOL", path, "")
+ .await
+ .with_status(StatusCode::NOT_FOUND);
+ }
+
+ // Create collections using MKCOL (empty body)
+ for path in [
+ "/dav/file/john/my-files",
+ "/dav/card/john/my-cards",
+ "/dav/cal/john/my-events",
+ ] {
+ client
+ .request("MKCOL", path, "")
+ .await
+ .with_status(StatusCode::CREATED);
+ }
+
+ // Create resources under the newly created collections
+ for (path, content) in [
+ ("/dav/file/john/my-files/file1.txt", TEST_FILE_1),
+ ("/dav/card/john/my-cards/card1.vcf", TEST_VCARD_1),
+ ("/dav/cal/john/my-events/event1.ics", TEST_ICAL_1),
+ ] {
+ client
+ .request("PUT", path, content)
+ .await
+ .with_status(StatusCode::CREATED);
+ }
+
+ // Creating a collection on a mapped resource should fail
+ for path in [
+ "/dav/file/john/my-files",
+ "/dav/card/john/my-cards",
+ "/dav/cal/john/my-events",
+ "/dav/file/john/my-files/file1.txt",
+ "/dav/card/john/my-cards/card1.vcf",
+ "/dav/cal/john/my-events/event1.ics",
+ ] {
+ client
+ .request("MKCOL", path, "")
+ .await
+ .with_status(StatusCode::METHOD_NOT_ALLOWED);
+ }
+
+ // Creating a sub-collections is allowed in FileDAV but in CalDAV and CardDAV
+ for (path, expected_status) in [
+ ("/dav/file/john/my-files/my-sub-files", StatusCode::CREATED),
+ (
+ "/dav/card/john/my-cards/my-sub-cards",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ (
+ "/dav/cal/john/my-events/my-sub-events",
+ StatusCode::METHOD_NOT_ALLOWED,
+ ),
+ ] {
+ client
+ .request("MKCOL", path, "")
+ .await
+ .with_status(expected_status);
+ }
+
+ // Extended MKCOL with an unsupported resource types should fail
+ for (path, resource_type) in [
+ ("/dav/file/john/my-named-files", "B:addressbook"),
+ ("/dav/card/john/my-named-cards", "A:calendar"),
+ ("/dav/cal/john/my-named-events", "B:addressbook"),
+ ] {
+ client
+ .mkcol("MKCOL", path, ["D:collection", resource_type], [])
+ .await
+ .with_status(StatusCode::FORBIDDEN)
+ .match_one(
+ "D:mkcol-response.D:propstat.D:error.D:valid-resourcetype",
+ "",
+ )
+ .match_one("D:mkcol-response.D:propstat.D:prop.D:resourcetype", "");
+ }
+
+ // Create using extended MKCOL
+ for (path, properties, resource_types) in [
+ (
+ "/dav/file/john/my-named-files/",
+ [("D:displayname", "Named Files")].as_slice(),
+ ["D:collection"].as_slice(),
+ ),
+ (
+ "/dav/card/john/my-named-cards/",
+ [
+ ("D:displayname", "Named Cards"),
+ ("B:addressbook-description", "Some amazing contacts"),
+ ]
+ .as_slice(),
+ ["D:collection", "B:addressbook"].as_slice(),
+ ),
+ (
+ "/dav/cal/john/my-named-events/",
+ [
+ ("D:displayname", "Named Events"),
+ ("A:calendar-description", "Some amazing events"),
+ (
+ "A:calendar-timezone",
+ &TEST_VTIMEZONE_1.replace("\n", "\r\n"),
+ ),
+ ]
+ .as_slice(),
+ ["D:collection", "A:calendar"].as_slice(),
+ ),
+ ] {
+ let response = client
+ .mkcol(
+ "MKCOL",
+ path,
+ resource_types.iter().copied(),
+ properties.iter().copied(),
+ )
+ .await;
+ response
+ .with_status(StatusCode::CREATED)
+ .match_many("D:mkcol-response.D:propstat.D:status", ["HTTP/1.1 200 OK"]);
+ for (property, _) in properties {
+ response.match_one(
+ &format!("D:mkcol-response.D:propstat.D:prop.{property}"),
+ "",
+ );
+ }
+
+ // Check the properties of the created collection
+ let response = client.propfind(path, properties.iter().map(|x| x.0)).await;
+ response
+ .with_status(StatusCode::MULTI_STATUS)
+ .match_one("D:multistatus.D:response.D:href", path)
+ .match_one(
+ "D:multistatus.D:response.D:propstat.D:status",
+ "HTTP/1.1 200 OK",
+ );
+ for (property, value) in properties {
+ response.match_one(
+ &format!("D:multistatus.D:response.D:propstat.D:prop.{property}"),
+ value,
+ );
+ }
+ }
+
+ // Test MKCALENDAR
+ client
+ .mkcol(
+ "MKCALENDAR",
+ "/dav/cal/john/my-named-events2",
+ [],
+ [("D:displayname", "Named Events 2")],
+ )
+ .await
+ .with_status(StatusCode::CREATED)
+ .match_one("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "")
+ .match_many(
+ "A:mkcalendar-response.D:propstat.D:status",
+ ["HTTP/1.1 200 OK"],
+ );
+
+ // Delete everything
+ for path in [
+ "/dav/file/john/my-files",
+ "/dav/card/john/my-cards",
+ "/dav/cal/john/my-events",
+ "/dav/file/john/my-named-files",
+ "/dav/card/john/my-named-cards",
+ "/dav/cal/john/my-named-events",
+ "/dav/cal/john/my-named-events2",
+ ] {
+ client
+ .request("DELETE", path, "")
+ .await
+ .with_status(StatusCode::NO_CONTENT);
+ }
+ client.delete_default_containers().await;
+ test.assert_is_empty().await;
+}
diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs
new file mode 100644
index 00000000..e0caef5d
--- /dev/null
+++ b/tests/src/webdav/mod.rs
@@ -0,0 +1,827 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+use crate::{
+ AssertConfig, add_test_certs, directory::internal::TestInternalDirectory,
+ jmap::assert_is_empty, store::TempDir,
+};
+use ::managesieve::core::ManageSieveSessionManager;
+use ::store::Stores;
+use ahash::AHashMap;
+use base64::{Engine, engine::general_purpose::STANDARD};
+use common::{
+ Caches, Core, Data, DavResource, DavResources, Inner, Server,
+ config::{
+ server::{Listeners, ServerProtocol},
+ telemetry::Telemetry,
+ },
+ core::BuildServer,
+ manager::boot::build_ipc,
+};
+use groupware::hierarchy::DavHierarchy;
+use http::HttpSessionManager;
+use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION};
+use imap::core::ImapSessionManager;
+use jmap_proto::types::collection::Collection;
+use pop3::Pop3SessionManager;
+use quick_xml::Reader;
+use quick_xml::events::Event;
+use services::SpawnServices;
+use smtp::{SpawnQueueManager, core::SmtpSessionManager};
+use std::str;
+use std::{
+ sync::Arc,
+ time::{Duration, Instant},
+};
+use tokio::sync::watch;
+use utils::config::Config;
+
+pub mod basic;
+pub mod mkcol;
+pub mod put_get;
+
+const SERVER: &str = r#"
+[server]
+hostname = "webdav.example.org"
+http.url = "'https://127.0.0.1:8899'"
+
+[server.listener.webdav]
+bind = ["127.0.0.1:8899"]
+protocol = "http"
+max-connections = 81920
+tls.implicit = true
+
+[server.socket]
+reuse-addr = true
+
+[server.tls]
+enable = true
+implicit = false
+certificate = "default"
+
+[session.ehlo]
+reject-non-fqdn = false
+
+[session.rcpt]
+relay = [ { if = "!is_empty(authenticated_as)", then = true },
+ { else = false } ]
+directory = "'{STORE}'"
+
+[session.rcpt.errors]
+total = 5
+wait = "1ms"
+
+[queue]
+path = "{TMP}"
+hash = 64
+
+[report]
+path = "{TMP}"
+hash = 64
+
+[resolver]
+type = "system"
+
+[queue.outbound]
+next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" },
+ { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" },
+ { else = false } ]
+
+[session.data.add-headers]
+delivered-to = false
+
+[session.extensions]
+future-release = [ { if = "!is_empty(authenticated_as)", then = "99999999d"},
+ { else = false } ]
+
+[store."sqlite"]
+type = "sqlite"
+path = "{TMP}/sqlite.db"
+
+[store."rocksdb"]
+type = "rocksdb"
+path = "{TMP}/rocks.db"
+
+[store."foundationdb"]
+type = "foundationdb"
+
+[store."postgresql"]
+type = "postgresql"
+host = "localhost"
+port = 5432
+database = "stalwart"
+user = "postgres"
+password = "mysecretpassword"
+
+[store."psql-replica"]
+type = "sql-read-replica"
+primary = "postgresql"
+replicas = "postgresql"
+
+[store."mysql"]
+type = "mysql"
+host = "localhost"
+port = 3307
+database = "stalwart"
+user = "root"
+password = "password"
+
+[store."elastic"]
+type = "elasticsearch"
+url = "https://localhost:9200"
+user = "elastic"
+password = "RtQ-Lu6+o4rxx=XJplVJ"
+disable = true
+
+[store."elastic".tls]
+allow-invalid-certs = true
+
+[certificate.default]
+cert = "%{file:{CERT}}%"
+private-key = "%{file:{PK}}%"
+
+[storage]
+data = "{STORE}"
+fts = "{STORE}"
+blob = "{STORE}"
+lookup = "{STORE}"
+directory = "{STORE}"
+
+[jmap.protocol]
+set.max-objects = 100000
+
+[jmap.protocol.request]
+max-concurrent = 8
+
+[jmap.protocol.upload]
+max-size = 5000000
+max-concurrent = 4
+ttl = "1m"
+
+[jmap.protocol.upload.quota]
+files = 3
+size = 50000
+
+[jmap.rate-limit]
+account = "1000/1m"
+authentication = "100/2s"
+anonymous = "100/1m"
+
+[store."auth"]
+type = "sqlite"
+path = "{TMP}/auth.db"
+
+[store."auth".query]
+name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true"
+members = "SELECT member_of FROM group_members WHERE name = ?"
+recipients = "SELECT name FROM emails WHERE address = ?"
+emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC"
+verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type = 'primary' ORDER BY address LIMIT 5"
+expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50"
+domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || ? LIMIT 1"
+
+[directory."{STORE}"]
+type = "internal"
+store = "{STORE}"
+
+[oauth]
+key = "parerga_und_paralipomena"
+
+[oauth.auth]
+max-attempts = 1
+
+[oauth.expiry]
+user-code = "1s"
+token = "1s"
+refresh-token = "3s"
+refresh-token-renew = "2s"
+
+[tracer.console]
+type = "console"
+level = "{LEVEL}"
+multiline = false
+ansi = true
+disabled-events = ["network.*"]
+
+"#;
+
+#[allow(dead_code)]
+pub struct WebDavTest {
+ server: Server,
+ clients: AHashMap<&'static str, DummyWebDavClient>,
+ temp_dir: TempDir,
+ shutdown_tx: watch::Sender,
+}
+
+async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest {
+ // Load and parse config
+ let temp_dir = TempDir::new("webdav_tests", delete_if_exists);
+ let mut config = Config::new(
+ add_test_certs(SERVER)
+ .replace("{STORE}", store_id)
+ .replace("{TMP}", &temp_dir.path.display().to_string())
+ .replace(
+ "{LEVEL}",
+ &std::env::var("LOG").unwrap_or_else(|_| "disable".to_string()),
+ ),
+ )
+ .unwrap();
+ config.resolve_all_macros().await;
+
+ // Parse servers
+ let mut servers = Listeners::parse(&mut config);
+
+ // Bind ports and drop privileges
+ servers.bind_and_drop_priv(&mut config);
+
+ // Build stores
+ let stores = Stores::parse_all(&mut config, false).await;
+
+ // Parse core
+ let tracers = Telemetry::parse(&mut config, &stores);
+ let core = Core::parse(&mut config, stores, Default::default()).await;
+ let data = Data::parse(&mut config);
+ let cache = Caches::parse(&mut config);
+
+ let store = core.storage.data.clone();
+ let (ipc, mut ipc_rxs) = build_ipc(&mut config);
+ let inner = Arc::new(Inner {
+ shared_core: core.into_shared(),
+ data,
+ ipc,
+ cache,
+ });
+
+ // Parse acceptors
+ servers.parse_tcp_acceptors(&mut config, inner.clone());
+
+ // Enable tracing
+ tracers.enable(true);
+
+ // Start services
+ config.assert_no_errors();
+ ipc_rxs.spawn_queue_manager(inner.clone());
+ ipc_rxs.spawn_services(inner.clone());
+
+ // Spawn servers
+ let (shutdown_tx, _) = servers.spawn(|server, acceptor, shutdown_rx| {
+ match &server.protocol {
+ ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn(
+ SmtpSessionManager::new(inner.clone()),
+ inner.clone(),
+ acceptor,
+ shutdown_rx,
+ ),
+ ServerProtocol::Http => server.spawn(
+ HttpSessionManager::new(inner.clone()),
+ inner.clone(),
+ acceptor,
+ shutdown_rx,
+ ),
+ ServerProtocol::Imap => server.spawn(
+ ImapSessionManager::new(inner.clone()),
+ inner.clone(),
+ acceptor,
+ shutdown_rx,
+ ),
+ ServerProtocol::Pop3 => server.spawn(
+ Pop3SessionManager::new(inner.clone()),
+ inner.clone(),
+ acceptor,
+ shutdown_rx,
+ ),
+ ServerProtocol::ManageSieve => server.spawn(
+ ManageSieveSessionManager::new(inner.clone()),
+ inner.clone(),
+ acceptor,
+ shutdown_rx,
+ ),
+ };
+ });
+
+ if delete_if_exists {
+ store.destroy().await;
+ }
+
+ // Create test accounts
+ let mut clients = AHashMap::new();
+ for (account, secret, name, email) in [
+ ("admin", "secret1", "Superuser", "admin@example,com"),
+ ("john", "secret2", "John Doe", "jdoe@example.com"),
+ ("jane", "secret3", "Jane Smith", "jane.smith@example.com"),
+ ("bill", "secret4", "Bill Foobar", "bill@example,com"),
+ ("mike", "secret5", "Mile Noquota", "mike@example,com"),
+ ] {
+ let account_id = store
+ .create_test_user(account, secret, name, &[email])
+ .await;
+ clients.insert(
+ account,
+ DummyWebDavClient::new(account_id, account, secret, email),
+ );
+ if account == "mike" {
+ store.set_test_quota(account, 10).await;
+ }
+ }
+ store
+ .create_test_group(
+ "support@example.com",
+ "Support Group",
+ &["support@example.com"],
+ )
+ .await;
+ store.add_to_group("jane", "support@example.com").await;
+
+ WebDavTest {
+ server: inner.build_server(),
+ clients,
+ temp_dir,
+ shutdown_tx,
+ }
+}
+
+#[tokio::test]
+pub async fn webdav_tests() {
+ // Prepare settings
+ let start_time = Instant::now();
+ let delete = true;
+ let handle = init_webdav_tests(
+ &std::env::var("STORE")
+ .expect("Missing store type. Try running `STORE= cargo test`"),
+ delete,
+ )
+ .await;
+
+ //basic::test(&handle).await;
+ //put_get::test(&handle).await;
+ mkcol::test(&handle).await;
+
+ // Print elapsed time
+ let elapsed = start_time.elapsed();
+ println!(
+ "Elapsed: {}.{:03}s",
+ elapsed.as_secs(),
+ elapsed.subsec_millis()
+ );
+
+ // Remove test data
+ if delete {
+ handle.temp_dir.delete();
+ }
+}
+
+impl WebDavTest {
+ pub fn client(&self, name: &'static str) -> &DummyWebDavClient {
+ self.clients.get(name).unwrap()
+ }
+
+ pub async fn resources(&self, name: &'static str, collection: Collection) -> Arc {
+ let account_id = self.client(name).account_id;
+ let access_token = self.server.get_access_token(account_id).await.unwrap();
+ self.server
+ .fetch_dav_resources(&access_token, account_id, collection)
+ .await
+ .unwrap()
+ }
+
+ pub async fn assert_is_empty(&self) {
+ assert_is_empty(self.server.clone()).await;
+ }
+}
+
+#[allow(dead_code)]
+pub struct DummyWebDavClient {
+ account_id: u32,
+ name: &'static str,
+ email: &'static str,
+ credentials: String,
+}
+
+pub struct DavResponse {
+ headers: AHashMap,
+ status: StatusCode,
+ body: Result,
+ xml: Vec<(String, String)>,
+}
+
+impl DummyWebDavClient {
+ pub fn new(
+ account_id: u32,
+ name: &'static str,
+ secret: &'static str,
+ email: &'static str,
+ ) -> Self {
+ Self {
+ account_id,
+ name,
+ email,
+ credentials: format!(
+ "Basic {}",
+ STANDARD.encode(format!("{name}:{secret}").as_bytes())
+ ),
+ }
+ }
+
+ pub async fn request(&self, method: &str, query: &str, body: impl Into) -> DavResponse {
+ self.request_with_headers(method, query, [].into_iter(), body)
+ .await
+ }
+
+ pub async fn request_with_headers(
+ &self,
+ method: &str,
+ query: &str,
+ headers: impl IntoIterator- ,
+ body: impl Into,
+ ) -> DavResponse {
+ let mut request = reqwest::Client::builder()
+ .timeout(Duration::from_millis(500))
+ .danger_accept_invalid_certs(true)
+ .build()
+ .unwrap()
+ .request(
+ Method::from_bytes(method.as_bytes()).unwrap(),
+ format!("https://127.0.0.1:8899{query}"),
+ );
+
+ let body = body.into();
+ if !body.is_empty() {
+ request = request.body(body);
+ }
+
+ let mut request_headers = HeaderMap::new();
+ for (key, value) in headers {
+ request_headers.insert(key, value.parse().unwrap());
+ }
+ request_headers.insert(AUTHORIZATION, self.credentials.parse().unwrap());
+
+ let response = request.headers(request_headers).send().await.unwrap();
+ let status = response.status();
+ let headers = response
+ .headers()
+ .iter()
+ .map(|(k, v)| {
+ (
+ k.to_string().to_lowercase(),
+ v.to_str().unwrap().to_string(),
+ )
+ })
+ .collect();
+ let body = response
+ .bytes()
+ .await
+ .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap())
+ .map_err(|err| err.to_string());
+ let xml = match &body {
+ Ok(body) if body.starts_with(" flatten_xml(body),
+ _ => vec![],
+ };
+
+ DavResponse {
+ headers,
+ status,
+ body,
+ xml,
+ }
+ }
+
+ pub async fn mkcol(
+ &self,
+ method: &str,
+ path: &str,
+ resource_types: impl IntoIterator
- ,
+ properties: impl IntoIterator
- ,
+ ) -> DavResponse {
+ let mut request = concat!(
+ "",
+ "",
+ ""
+ )
+ .to_string();
+
+ for resource_type in resource_types {
+ request.push_str(&format!("<{resource_type}/>"));
+ }
+
+ request.push_str("");
+
+ for (key, value) in properties {
+ request.push_str(&format!("<{key}>{value}{key}>"));
+ }
+ request.push_str("");
+
+ if method == "MKCALENDAR" {
+ request = request.replace("D:mkcol", "A:mkcalendar");
+ }
+
+ self.request(method, path, &request).await
+ }
+
+ pub async fn propfind(
+ &self,
+ path: &str,
+ properties: impl IntoIterator
- ,
+ ) -> DavResponse {
+ let mut request = concat!(
+ "",
+ "",
+ ""
+ )
+ .to_string();
+
+ for property in properties {
+ request.push_str(&format!("<{property}/>"));
+ }
+
+ request.push_str("");
+
+ self.request("PROPFIND", path, &request).await
+ }
+
+ pub async fn delete_default_containers(&self) {
+ for col in ["card", "cal"] {
+ self.request("DELETE", &format!("/dav/{col}/{}/default", self.name), "")
+ .await
+ .with_status(StatusCode::NO_CONTENT);
+ }
+ }
+}
+
+impl DavResponse {
+ pub fn with_status(&self, status: StatusCode) -> &Self {
+ if self.status != status {
+ self.dump_response();
+ panic!("Expected {status} but got {}", self.status)
+ }
+ self
+ }
+
+ pub fn with_redirect_to(&self, url: &str) -> &Self {
+ self.with_status(StatusCode::TEMPORARY_REDIRECT)
+ .with_header("location", url)
+ }
+
+ pub fn with_header(&self, header: &str, value: &str) -> &Self {
+ if self.headers.get(header).is_some_and(|v| v == value) {
+ self
+ } else {
+ self.dump_response();
+ panic!("Header {header}:{value} not found.")
+ }
+ }
+
+ pub fn with_body(&self, expect_body: impl AsRef) -> &Self {
+ let expect_body = expect_body.as_ref();
+ if self.body.is_ok() {
+ let body = self.body.as_ref().unwrap();
+ if body != expect_body {
+ self.dump_response();
+ assert_eq!(body, &expect_body);
+ }
+ self
+ } else {
+ self.dump_response();
+ panic!("Expected body {expect_body:?} but no body was returned.")
+ }
+ }
+
+ pub fn header(&self, header: &str) -> &str {
+ if let Some(value) = self.headers.get(header) {
+ value
+ } else {
+ self.dump_response();
+ panic!("Header {header} not found.")
+ }
+ }
+
+ pub fn etag(&self) -> &str {
+ self.header("etag")
+ }
+
+ fn dump_response(&self) {
+ eprintln!("-------------------------------------");
+ eprintln!("Status: {}", self.status);
+ eprintln!("Headers:");
+ for (key, value) in self.headers.iter() {
+ eprintln!(" {}: {:?}", key, value);
+ }
+ if !self.xml.is_empty() {
+ for (key, value) in self.xml.iter() {
+ eprintln!("{} -> {:?}", key, value);
+ }
+ } else {
+ eprintln!("Body: {:?}", self.body);
+ }
+ }
+
+ fn find_keys(&self, name: &str) -> impl Iterator
- {
+ self.xml
+ .iter()
+ .filter(move |(key, _)| name == key)
+ .map(|(_, value)| value.as_str())
+ }
+
+ // Poor man's XPath
+ pub fn match_one(&self, query: &str, expect: impl AsRef) -> &Self {
+ let expect = expect.as_ref();
+ if let Some(value) = self.find_keys(query).next() {
+ if value != expect {
+ self.dump_response();
+ panic!("Expected {query} = {expect:?} but got {value:?}");
+ }
+ } else {
+ self.dump_response();
+ panic!("Key {query} not found.");
+ }
+ self
+ }
+
+ pub fn match_many(&self, query: &str, expect: I) -> &Self
+ where
+ I: IntoIterator
- ,
+ T: AsRef,
+ {
+ let expect_owned: Vec = expect.into_iter().collect();
+ let expect = expect_owned.iter().map(|s| s.as_ref()).collect::>();
+ let found = self.find_keys(query).collect::>();
+ if expect != found {
+ self.dump_response();
+ panic!("Expected {query} = {expect:?} but got {found:?}");
+ }
+ self
+ }
+
+ pub fn with_failed_precondition(&self, precondition: &str, value: &str) -> &Self {
+ let error = format!("D:error.{precondition}");
+ if self.find_keys(&error).next().is_none_or(|v| v != value) {
+ self.dump_response();
+ panic!("Precondition {precondition} did not match.");
+ }
+ self
+ }
+}
+
+pub trait DavResourcesTest {
+ fn items(&self) -> Vec;
+}
+
+impl DavResourcesTest for DavResources {
+ fn items(&self) -> Vec {
+ self.paths.iter().cloned().collect()
+ }
+}
+
+fn flatten_xml(xml: &str) -> Vec<(String, String)> {
+ let mut reader = Reader::from_str(xml);
+
+ let mut path: Vec = Vec::new();
+ let mut result: Vec<(String, String)> = Vec::new();
+ let mut buf = Vec::new();
+ let mut text_content: Option = None;
+
+ loop {
+ match reader.read_event_into(&mut buf).unwrap() {
+ Event::Start(ref e) => {
+ let name = str::from_utf8(e.name().as_ref()).unwrap().to_string();
+ path.push(name);
+ for attr in e.attributes() {
+ let attr = attr.unwrap();
+ let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string();
+ let value = attr.unescape_value().unwrap();
+ let value_str = value.trim().to_string();
+
+ result.push((format!("{}.[{}]", path.join("."), key), value_str));
+ }
+ text_content = None;
+ }
+ Event::Empty(ref e) => {
+ let name = str::from_utf8(e.name().as_ref()).unwrap().to_string();
+ result.push((format!("{}.{}", path.join("."), name), "".to_string()));
+ }
+ Event::Text(e) => {
+ let text = e.unescape().unwrap();
+ let trimmed = text.trim();
+ if !trimmed.is_empty() {
+ text_content = Some(trimmed.to_string());
+ }
+ }
+ Event::CData(e) => {
+ text_content = Some(std::str::from_utf8(e.as_ref()).unwrap().to_string());
+ }
+ Event::End(_) => {
+ if let Some(text) = text_content.take() {
+ result.push((path.join("."), text));
+ }
+
+ if !path.is_empty() {
+ path.pop();
+ }
+ }
+ Event::Eof => break,
+ _ => {}
+ }
+ buf.clear();
+ }
+
+ result
+}
+
+pub const TEST_VCARD_1: &str = r#"BEGIN:VCARD
+VERSION:4.0
+UID:18F098B5-7383-4FD6-B482-48F2181D73AA
+X-TEST:SEQ1
+N:Coyote;Wile;E.;;
+FN:Wile E. Coyote
+ORG:ACME Inc.;
+END:VCARD
+"#;
+
+pub const TEST_VCARD_2: &str = r#"BEGIN:VCARD
+VERSION:4.0
+UID:6exhjr32bt783wwlr9u0sr8lfqse5x7zqc8y
+X-TEST:SEQ1
+FN:Joe Citizen
+N:Citizen;Joe;;;
+NICKNAME:human_being
+EMAIL;TYPE=pref:jcitizen@foo.com
+REV:20200411T072429Z
+END:VCARD
+"#;
+
+pub const TEST_ICAL_1: &str = r#"BEGIN:VCALENDAR
+SOURCE;VALUE=URI:http://calendar.example.com/event_with_html.ics
+X-TEST:SEQ1
+BEGIN:VEVENT
+UID: 2371c2d9-a136-43b0-bba3-f6ab249ad46e
+SUMMARY:What a nice present: 🎁
+DTSTART;TZID=America/New_York:20190221T170000
+DTEND;TZID=America/New_York:20190221T180000
+LOCATION:Germany
+DESCRIPTION:
Title
Row
+END:VEVENT
+END:VCALENDAR
+"#;
+
+pub const TEST_ICAL_2: &str = r#"BEGIN:VCALENDAR
+X-TEST:SEQ1
+BEGIN:VEVENT
+UID:0000001
+SUMMARY:Treasure Hunting
+DTSTART;TZID=America/Los_Angeles:20150706T120000
+DTEND;TZID=America/Los_Angeles:20150706T130000
+RRULE:FREQ=DAILY;COUNT=10
+EXDATE;TZID=America/Los_Angeles:20150708T120000
+EXDATE;TZID=America/Los_Angeles:20150710T120000
+END:VEVENT
+BEGIN:VEVENT
+UID:0000001
+SUMMARY:More Treasure Hunting
+LOCATION:The other island
+DTSTART;TZID=America/Los_Angeles:20150709T150000
+DTEND;TZID=America/Los_Angeles:20150707T160000
+RECURRENCE-ID;TZID=America/Los_Angeles:20150707T120000
+END:VEVENT
+END:VCALENDAR
+"#;
+
+pub const TEST_FILE_1: &str = r#"this is a test file
+with some text
+and some more text
+
+X-TEST:SEQ1
+"#;
+
+pub const TEST_FILE_2: &str = r#"another test file
+with amazing content
+and some more text
+
+X-TEST:SEQ1
+"#;
+
+pub const TEST_VTIMEZONE_1: &str = r#"BEGIN:VCALENDAR
+PRODID:-//Example Corp.//CalDAV Client//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:US-Eastern
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:Eastern Standard Time (US Canada)
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:Eastern Daylight Time (US Canada)
+END:DAYLIGHT
+END:VTIMEZONE
+END:VCALENDAR
+"#;
diff --git a/tests/src/webdav/put_get.rs b/tests/src/webdav/put_get.rs
new file mode 100644
index 00000000..8ab5f2c1
--- /dev/null
+++ b/tests/src/webdav/put_get.rs
@@ -0,0 +1,403 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+use super::WebDavTest;
+use crate::webdav::*;
+
+pub async fn test(test: &WebDavTest) {
+ println!("Running PUT/GET tests...");
+ let client = test.client("john");
+
+ // Simple PUT
+ let mut files = AHashMap::new();
+ for (path, ct, content) in [
+ ("/dav/file/john/file1.txt", "text/plain", TEST_FILE_1),
+ ("/dav/file/john/file2.txt", "text/x-other", TEST_FILE_2),
+ (
+ "/dav/card/john/default/card1.vcf",
+ "text/vcard; charset=utf-8",
+ TEST_VCARD_1,
+ ),
+ (
+ "/dav/card/john/default/card2.vcf",
+ "text/vcard; charset=utf-8",
+ TEST_VCARD_2,
+ ),
+ (
+ "/dav/cal/john/default/event1.ics",
+ "text/calendar; charset=utf-8",
+ TEST_ICAL_1,
+ ),
+ (
+ "/dav/cal/john/default/event2.ics",
+ "text/calendar; charset=utf-8",
+ TEST_ICAL_2,
+ ),
+ ] {
+ let content = content.replace("\n", "\r\n");
+ let etag = client
+ .request_with_headers("PUT", path, [("content-type", ct)], &content)
+ .await
+ .with_status(StatusCode::CREATED)
+ .etag()
+ .to_string();
+ files.insert(path, (content, ct, etag));
+ }
+
+ // Test GET
+ for (path, (content, ct, etag)) in &files {
+ client
+ .request("GET", path, "")
+ .await
+ .with_status(StatusCode::OK)
+ .with_header("etag", etag)
+ .with_header("content-type", ct)
+ .with_body(content);
+ }
+
+ // PUT under a non-existing parent should fail
+ for (path, contents) in [
+ ("/dav/file/john/foo/file1.txt", TEST_FILE_1),
+ ("/dav/card/john/foo/card1.vcf", TEST_VCARD_1),
+ ("/dav/cal/john/foo/event1.ics", TEST_ICAL_1),
+ ] {
+ client
+ .request("PUT", path, contents)
+ .await
+ .with_status(StatusCode::CONFLICT);
+ }
+
+ // PUT under resources should fail
+ for (path, contents) in [
+ ("/dav/file/john/file1.txt/other-file.txt", TEST_FILE_1),
+ (
+ "/dav/card/john/default/card1.vcf/other-file.vcf",
+ TEST_VCARD_1,
+ ),
+ (
+ "/dav/cal/john/default/event1.ics/other-file.ical",
+ TEST_ICAL_1,
+ ),
+ ] {
+ client
+ .request("PUT", path, contents)
+ .await
+ .with_status(StatusCode::METHOD_NOT_ALLOWED);
+ }
+
+ // PUT a non-vCard/iCalendar file should fail
+ for (path, ct, content, precondition) in [
+ (
+ "/dav/card/john/card3.vcf",
+ "text/vcard; charset=utf-8",
+ TEST_FILE_1,
+ "B:supported-address-data",
+ ),
+ (
+ "/dav/cal/john/event3.ics",
+ "text/calendar; charset=utf-8",
+ TEST_FILE_2,
+ "A:supported-calendar-data",
+ ),
+ ] {
+ client
+ .request_with_headers("PUT", path, [("content-type", ct)], content)
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED)
+ .with_failed_precondition(precondition, "");
+ }
+
+ // Exceeding the configured file limits should fail
+ let conf = &test.server.core.groupware;
+ for (path, contents, max_size, expect) in [
+ (
+ "/dav/file/john/chunky-file1.txt",
+ TEST_FILE_1,
+ conf.max_file_size,
+ None,
+ ),
+ (
+ "/dav/card/john/chunky-card1.vcf",
+ TEST_VCARD_1,
+ conf.max_vcard_size,
+ Some("B:max-resource-size"),
+ ),
+ (
+ "/dav/cal/john/chunky-event1.ics",
+ TEST_ICAL_1,
+ conf.max_ical_size,
+ Some("A:max-resource-size"),
+ ),
+ ] {
+ let mut chunky_contents = String::with_capacity(max_size + contents.len());
+ while chunky_contents.len() < max_size {
+ chunky_contents.push_str(contents);
+ }
+ let response = client.request("PUT", path, chunky_contents).await;
+ response.with_status(
+ expect
+ .map(|_| StatusCode::PRECONDITION_FAILED)
+ .unwrap_or(StatusCode::PAYLOAD_TOO_LARGE),
+ );
+ if let Some(expect) = expect {
+ response.with_failed_precondition(expect, &max_size.to_string());
+ }
+ }
+
+ // PUT requests cannot exceed quota
+ let mike_noquota = test.client("mike");
+ for (path, ct, content) in [
+ ("/dav/file/mike/file1.txt", "text/plain", TEST_FILE_1),
+ (
+ "/dav/card/mike/default/card1.vcf",
+ "text/vcard; charset=utf-8",
+ TEST_VCARD_1,
+ ),
+ (
+ "/dav/cal/mike/default/event1.ics",
+ "text/calendar; charset=utf-8",
+ TEST_ICAL_1,
+ ),
+ ] {
+ mike_noquota
+ .request_with_headers("PUT", path, [("content-type", ct)], content)
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED)
+ .with_failed_precondition("D:quota-not-exceeded", "");
+ }
+
+ // PUT precondition enforcement
+ let modseq = [
+ test.resources("john", Collection::FileNode).await.modseq,
+ test.resources("john", Collection::Calendar).await.modseq,
+ test.resources("john", Collection::AddressBook).await.modseq,
+ ];
+ for (path, ct, content) in [
+ ("/dav/file/john/file1.txt", "text/plain", TEST_FILE_1),
+ (
+ "/dav/card/john/default/card1.vcf",
+ "text/vcard; charset=utf-8",
+ TEST_VCARD_1,
+ ),
+ (
+ "/dav/cal/john/default/event1.ics",
+ "text/calendar; charset=utf-8",
+ TEST_ICAL_1,
+ ),
+ ] {
+ let content = content.replace("\n", "\r\n");
+ client
+ .request_with_headers(
+ "PUT",
+ path,
+ [("content-type", ct), ("if-none-match", "*")],
+ &content,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED);
+
+ client
+ .request_with_headers(
+ "PUT",
+ path,
+ [("content-type", ct), ("overwrite", "F")],
+ &content,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED);
+
+ client
+ .request_with_headers(
+ "PUT",
+ path,
+ [("content-type", ct), ("if", "([\"3827\"])")],
+ &content,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED);
+
+ client
+ .request_with_headers(
+ "PUT",
+ path,
+ [
+ ("content-type", ct),
+ ("if", "([\"3827\"])"),
+ ("prefer", "return=representation"),
+ ],
+ &content,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED)
+ .with_header("preference-applied", "return=representation")
+ .with_body(&content);
+ }
+ assert_eq!(
+ [
+ test.resources("john", Collection::FileNode).await.modseq,
+ test.resources("john", Collection::Calendar).await.modseq,
+ test.resources("john", Collection::AddressBook).await.modseq,
+ ],
+ modseq
+ );
+
+ // Update files using etags
+ for (path, (content, ct, etag)) in &mut files {
+ let condition = format!("([{}])", etag);
+ *content = content.replace("X-TEST:SEQ1", "X-TEST:SEQ2");
+ *etag = client
+ .request_with_headers(
+ "PUT",
+ path,
+ [("content-type", &**ct), ("if", condition.as_str())],
+ content.as_str(),
+ )
+ .await
+ .with_status(StatusCode::NO_CONTENT)
+ .etag()
+ .to_string();
+ }
+
+ // Test GET
+ for (path, (content, ct, etag)) in &files {
+ client
+ .request("GET", path, "")
+ .await
+ .with_status(StatusCode::OK)
+ .with_header("etag", etag)
+ .with_header("content-type", ct)
+ .with_body(content);
+ }
+
+ // PUT requests require unique UIDs
+ for (path, ct, content, precond_key, precond_value) in [
+ (
+ "/dav/card/john/default/card5.vcf",
+ "text/vcard; charset=utf-8",
+ TEST_VCARD_1,
+ "B:no-uid-conflict.D:href",
+ "/dav/card/john/default/card1.vcf",
+ ),
+ (
+ "/dav/cal/john/default/event5.ics",
+ "text/calendar; charset=utf-8",
+ TEST_ICAL_1,
+ "A:no-uid-conflict.D:href",
+ "/dav/cal/john/default/event1.ics",
+ ),
+ ] {
+ client
+ .request_with_headers(
+ "PUT",
+ path,
+ [("content-type", ct), ("if-none-match", "*")],
+ content,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED)
+ .with_failed_precondition(precond_key, precond_value);
+ }
+
+ // iCal containing different component types should fail
+ client
+ .request_with_headers(
+ "PUT",
+ "/dav/cal/john/default/invalid.ics",
+ [
+ ("content-type", "text/calendar; charset=utf-8"),
+ ("if-none-match", "*"),
+ ],
+ r#"BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+UID:1234567890
+SUMMARY:Test Event
+DTSTART;TZID=Europe/London:20231001T120000
+DTEND;TZID=Europe/London:20231001T130000
+END:VEVENT
+BEGIN:VTODO
+UID:1234567890
+SUMMARY:Test Task
+DTSTART;TZID=Europe/London:20231001T120000
+DTEND;TZID=Europe/London:20231001T130000
+END:VTODO
+END:VCALENDAR
+"#,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED)
+ .with_failed_precondition("A:valid-calendar-object-resource", "");
+
+ // iCal referencing more than one UID should fail
+ client
+ .request_with_headers(
+ "PUT",
+ "/dav/cal/john/default/invalid.ics",
+ [
+ ("content-type", "text/calendar; charset=utf-8"),
+ ("if-none-match", "*"),
+ ],
+ r#"BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+UID:1234567890
+SUMMARY:Test Event 1
+DTSTART;TZID=Europe/London:20231001T120000
+DTEND;TZID=Europe/London:20231001T130000
+END:VEVENT
+BEGIN:VEVENT
+UID:1234567891
+SUMMARY:Test Event 2
+DTSTART;TZID=Europe/London:20231001T120000
+DTEND;TZID=Europe/London:20231001T130000
+END:VEVENT
+END:VCALENDAR
+"#,
+ )
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED)
+ .with_failed_precondition("A:valid-calendar-object-resource", "");
+
+ // Deleting unknown/invalid destinations should fail
+ for (path, expect) in [
+ ("/dav/file/john/unknown.txt", StatusCode::NOT_FOUND),
+ ("/dav/card/john/default/unknown.txt", StatusCode::NOT_FOUND),
+ ("/dav/cal/john/default/unknown.txt", StatusCode::NOT_FOUND),
+ ("/dav/file/john", StatusCode::FORBIDDEN),
+ ("/dav/cal/john", StatusCode::FORBIDDEN),
+ ("/dav/card/john", StatusCode::FORBIDDEN),
+ ("/dav/pal/john", StatusCode::METHOD_NOT_ALLOWED),
+ ("/dav/file", StatusCode::FORBIDDEN),
+ ("/dav/cal", StatusCode::FORBIDDEN),
+ ("/dav/card", StatusCode::FORBIDDEN),
+ ("/dav/pal", StatusCode::METHOD_NOT_ALLOWED),
+ ] {
+ client.request("DELETE", path, "").await.with_status(expect);
+ }
+
+ // Delete files
+ for (path, (_, _, etag)) in &files {
+ client
+ .request_with_headers("DELETE", path, [("if", "([\"3827\"])")], "")
+ .await
+ .with_status(StatusCode::PRECONDITION_FAILED);
+
+ let condition = format!("([{}])", etag);
+ client
+ .request_with_headers("DELETE", path, [("if", condition.as_str())], "")
+ .await
+ .with_status(StatusCode::NO_CONTENT);
+
+ client
+ .request("DELETE", path, "")
+ .await
+ .with_status(StatusCode::NOT_FOUND);
+ }
+
+ client.delete_default_containers().await;
+ mike_noquota.delete_default_containers().await;
+ test.assert_is_empty().await;
+}