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}")); + } + 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

  • first Row
  • < + i>second 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; +}