diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs index d952e04e..d14867b9 100644 --- a/crates/dav/src/common/lock.rs +++ b/crates/dav/src/common/lock.rs @@ -11,18 +11,32 @@ use common::{Server, auth::AccessToken}; use dav_proto::schema::property::{ActiveLock, LockScope, WebDavProperty}; use dav_proto::schema::request::{DavPropertyValue, DeadProperty}; use dav_proto::schema::response::{BaseCondition, List, PropResponse}; -use dav_proto::{Depth, ResourceState, Timeout}; +use dav_proto::{Condition, Depth, Timeout}; use dav_proto::{RequestHeaders, schema::request::LockInfo}; +use groupware::file::hierarchy::FileHierarchy; use http_proto::HttpResponse; use hyper::StatusCode; +use jmap_proto::types::collection::Collection; +use jmap_proto::types::property::Property; use store::dispatch::lookup::KeyValue; use store::write::serialize::rkyv_deserialize; use store::write::{Archive, Archiver, now}; use store::{Serialize, U32_LEN}; use trc::AddContext; +use super::ETag; use super::uri::{DavUriResource, UriResource}; -use crate::{DavError, DavErrorCondition}; +use crate::{DavError, DavErrorCondition, DavMethod}; + +#[derive(Debug, Clone)] +pub struct ResourceState<'x> { + pub account_id: u32, + pub collection: Collection, + pub document_id: Option, + pub etag: Option, + pub lock_token: Option, + pub path: &'x str, +} pub(crate) trait LockRequestHandler: Sync + Send { fn handle_lock_request( @@ -31,6 +45,15 @@ pub(crate) trait LockRequestHandler: Sync + Send { headers: RequestHeaders<'_>, lock_info: Option, ) -> impl Future> + Send; + + fn validate_headers( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + resources: Vec>, + locks: LockCaches<'_>, + method: DavMethod, + ) -> impl Future> + Send; } impl LockRequestHandler for Server { @@ -47,10 +70,20 @@ impl LockRequestHandler for Server { let resource_path = resource .resource .ok_or(DavError::Code(StatusCode::CONFLICT))?; - if !access_token.is_member(resource.account_id.unwrap()) { + let account_id = resource.account_id.unwrap(); + if !access_token.is_member(account_id) { return Err(DavError::Code(StatusCode::FORBIDDEN)); } + let resources = vec![ResourceState { + account_id, + collection: resource.collection, + path: resource_path, + document_id: None, + etag: None, + lock_token: None, + }]; + let mut lock_data = if let Some(lock_data) = self .in_memory_store() .key_get::(resource_hash.as_slice()) @@ -60,29 +93,17 @@ impl LockRequestHandler for Server { let lock_data = lock_data .unarchive::() .caused_by(trc::location!())?; - if let Some((lock_path, lock_item)) = lock_data.find_lock(resource_path) { - if !lock_item.is_lock_owner(access_token) { - return Err(DavErrorCondition::new( - StatusCode::LOCKED, - BaseCondition::LockTokenSubmitted(List(vec![ - headers.format_to_base_uri(lock_path).into(), - ])), - ) - .into()); - } else if headers.has_if() - && !headers.eval_if(&[ResourceState { - resource: None, - etag: String::new(), - state_token: lock_item.uuid(), - }]) - { - return Err(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - BaseCondition::LockTokenMatchesRequestUri, - ) - .into()); - } - } else if lock_info.is_some() { + + self.validate_headers( + access_token, + &headers, + resources, + LockCaches::new_shared(account_id, resource.collection, lock_data), + DavMethod::LOCK, + ) + .await?; + + if lock_info.is_some() { if let Some((lock_path, lock_item)) = lock_data.can_lock(resource_path) { if !lock_item.is_lock_owner(access_token) { return Err(DavErrorCondition::new( @@ -92,24 +113,21 @@ impl LockRequestHandler for Server { ])), ) .into()); - } else if headers.has_if() - && !headers.eval_if(&[ResourceState { - resource: None, - etag: String::new(), - state_token: lock_item.uuid(), - }]) - { - return Err(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - BaseCondition::LockTokenMatchesRequestUri, - ) - .into()); } } } rkyv_deserialize(lock_data).caused_by(trc::location!())? } else if lock_info.is_some() { + self.validate_headers( + access_token, + &headers, + resources, + Default::default(), + DavMethod::LOCK, + ) + .await?; + LockData::default() } else { return Err(DavErrorCondition::new( @@ -215,6 +233,338 @@ impl LockRequestHandler for Server { Ok(response) } + + async fn validate_headers( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut resources: Vec>, + mut locks_: LockCaches<'_>, + method: DavMethod, + ) -> crate::Result<()> { + let no_if_headers = headers.if_.is_empty(); + match method { + DavMethod::GET | DavMethod::HEAD => { + // Return early for GET/HEAD requests without If headers + if no_if_headers { + return Ok(()); + } + } + DavMethod::COPY + | DavMethod::MOVE + | DavMethod::POST + | DavMethod::PUT + | DavMethod::PATCH => { + if headers.overwrite_fail && resources.last().is_some_and(|r| r.etag.is_some()) { + return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); + } + } + _ => {} + } + + // Add lock data to the cache + for resource in &resources { + if locks_.is_cached(resource).is_none() { + locks_.insert_lock_data(self, resource).await?; + } + } + + // Unarchive lock data + let mut locks = locks_.to_unarchived().caused_by(trc::location!())?; + + // Validate locks + if !matches!(method, DavMethod::GET | DavMethod::HEAD) { + for resource in &resources { + if let Some(idx) = locks.find_cache_pos(self, resource).await? { + if let Some((lock_path, lock_item)) = locks.find_lock_by_pos(idx, resource)? { + if !lock_item.is_lock_owner(access_token) { + return Err(DavErrorCondition::new( + StatusCode::LOCKED, + BaseCondition::LockTokenSubmitted(List(vec![ + headers.format_to_base_uri(lock_path).into(), + ])), + ) + .into()); + } + } + } + } + } + + // There are no If headers, so we can return early + if no_if_headers { + return Ok(()); + } + + let mut resource_not_found = ResourceState { + account_id: u32::MAX, + collection: Collection::None, + document_id: None, + etag: None, + lock_token: None, + path: "", + }; + + 'outer: for if_ in &headers.if_ { + if if_.list.is_empty() { + continue; + } + + let mut resource_state = &mut resource_not_found; + + if let Some(resource) = if_.resource { + if let Some(resource) = self + .validate_uri(access_token, resource) + .await + .ok() + .and_then(|r| { + Some(ResourceState { + account_id: r.account_id?, + collection: r.collection, + path: r.resource?, + document_id: None, + etag: None, + lock_token: None, + }) + }) + { + if let Some(known_resource) = resources.iter_mut().find(|r| { + r.account_id == resource.account_id + && r.collection == resource.collection + && r.path == resource.path + }) { + resource_state = known_resource; + } else if access_token.has_access(resource.account_id, resource.collection) { + resources.push(resource); + resource_state = resources.last_mut().unwrap(); + } + } + } else if let Some(resource) = resources.first_mut() { + resource_state = resource; + }; + + // Fill missing data for resource + if resource_state.collection != Collection::None + && (resource_state.etag.is_none() || resource_state.lock_token.is_none()) + { + let mut needs_token = false; + let mut needs_etag = false; + + for cond in &if_.list { + match cond { + Condition::StateToken { .. } => { + needs_token = true; + } + Condition::ETag { .. } | Condition::Exists { .. } => { + needs_etag = true; + } + } + } + + // Fetch eTag + if needs_etag && resource_state.etag.is_none() { + if resource_state.document_id.is_none() { + let todo = "map cal, card"; + + resource_state.document_id = match resource_state.collection { + Collection::FileNode => self + .fetch_file_hierarchy(resource_state.account_id) + .await + .caused_by(trc::location!())? + .files + .by_name(resource_state.path) + .map(|f| f.document_id), + Collection::Calendar => todo!(), + Collection::CalendarEvent => todo!(), + Collection::AddressBook => todo!(), + Collection::ContactCard => todo!(), + _ => None, + } + .unwrap_or(u32::MAX) + .into(); + } + + if let Some(document_id) = + resource_state.document_id.filter(|&id| id != u32::MAX) + { + if let Some(archive) = self + .get_property::( + resource_state.account_id, + resource_state.collection, + document_id, + Property::Value, + ) + .await + .caused_by(trc::location!())? + { + resource_state.etag = archive.etag().into(); + } + } + } + + // Fetch lock token + if needs_token && resource_state.lock_token.is_none() { + if let Some(idx) = locks.find_cache_pos(self, resource_state).await? { + if let Some((_, lock)) = locks.find_lock_by_pos(idx, resource_state)? { + resource_state.lock_token = Some(lock.uuid()); + } + } + } + } + + for cond in &if_.list { + match cond { + Condition::StateToken { is_not, token } => { + if !((resource_state + .lock_token + .as_ref() + .is_some_and(|lock_token| lock_token == token)) + ^ is_not) + { + continue 'outer; + } + } + Condition::ETag { is_not, tag } => { + if !((resource_state.etag.as_ref().is_some_and(|etag| etag == tag)) + ^ is_not) + { + continue 'outer; + } + } + Condition::Exists { is_not } => { + if !((resource_state.etag.is_some()) ^ is_not) { + continue 'outer; + } + } + } + } + + return Ok(()); + } + + Err(DavError::Code(StatusCode::PRECONDITION_FAILED)) + } +} + +struct LockCache<'x> { + account_id: u32, + collection: Collection, + lock_archive: LockArchive<'x>, +} + +enum LockArchive<'x> { + Unarchived(&'x ArchivedLockData), + Archived(Archive), +} + +#[derive(Default)] +pub(crate) struct LockCaches<'x> { + caches: Vec>, +} + +impl<'x> LockArchive<'x> { + fn unarchive(&'x self) -> trc::Result<&'x ArchivedLockData> { + match self { + LockArchive::Unarchived(archived_lock_data) => Ok(archived_lock_data), + LockArchive::Archived(archive) => { + archive.unarchive::().caused_by(trc::location!()) + } + } + } +} + +impl<'x> LockCaches<'x> { + pub(self) fn new_shared( + account_id: u32, + collection: Collection, + lock_data: &'x ArchivedLockData, + ) -> Self { + Self { + caches: vec![LockCache { + account_id, + collection, + lock_archive: LockArchive::Unarchived(lock_data), + }], + } + } + + pub fn to_unarchived(&'x self) -> trc::Result> { + let caches = self + .caches + .iter() + .map(|cache| { + Ok(LockCache { + account_id: cache.account_id, + collection: cache.collection, + lock_archive: LockArchive::Unarchived( + cache.lock_archive.unarchive().caused_by(trc::location!())?, + ), + }) + }) + .collect::>>()?; + + Ok(LockCaches { caches }) + } + + #[inline] + pub fn is_cached(&self, resource_state: &ResourceState<'_>) -> Option { + self.caches.iter().position(|cache| { + resource_state.account_id == cache.account_id + && resource_state.collection == cache.collection + }) + } + + pub async fn find_cache_pos( + &mut self, + server: &Server, + resource_state: &ResourceState<'_>, + ) -> trc::Result> { + if let Some(idx) = self.is_cached(resource_state) { + Ok(Some(idx)) + } else if resource_state.collection != Collection::None { + if self.insert_lock_data(server, resource_state).await? { + Ok(Some(self.caches.len() - 1)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + fn find_lock_by_pos<'y>( + &'x self, + pos: usize, + resource_state: &'y ResourceState<'_>, + ) -> trc::Result> { + self.caches[pos] + .lock_archive + .unarchive() + .map(|l| l.find_lock(resource_state.path)) + } + + async fn insert_lock_data( + &mut self, + server: &Server, + resource_state: &ResourceState<'_>, + ) -> trc::Result { + if let Some(lock_archive) = server + .in_memory_store() + .key_get::(resource_state.lock_key().as_slice()) + .await + .caused_by(trc::location!())? + { + self.caches.push(LockCache { + account_id: resource_state.account_id, + collection: resource_state.collection, + lock_archive: LockArchive::Archived(lock_archive), + }); + + Ok(true) + } else { + Ok(false) + } + } } #[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] @@ -353,3 +703,23 @@ impl UriResource> { Some(result) } } + +impl ResourceState<'_> { + pub fn lock_key(&self) -> Vec { + let mut result = Vec::with_capacity(U32_LEN + 2); + result.push(KV_LOCK_DAV); + result.extend_from_slice(self.account_id.to_be_bytes().as_slice()); + result.push(u8::from(self.collection)); + result + } +} + +impl PartialEq for ResourceState<'_> { + fn eq(&self, other: &Self) -> bool { + self.account_id == other.account_id + && self.collection == other.collection + && self.document_id == other.document_id + } +} + +impl Eq for ResourceState<'_> {} diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs index 0ac4d696..1d8fa399 100644 --- a/crates/dav/src/common/mod.rs +++ b/crates/dav/src/common/mod.rs @@ -4,6 +4,54 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use std::fmt::Write; + +use jmap_proto::types::property::Property; +use store::write::{Archive, BatchBuilder, MaybeDynamicValue, Operation, ValueClass, ValueOp}; + pub mod acl; pub mod lock; pub mod uri; + +pub trait ETag { + fn etag(&self) -> String; +} + +pub trait ExtractETag { + fn etag(&self) -> Option; +} + +impl> ETag for T { + fn etag(&self) -> String { + let mut hasher = store::blake3::Hasher::new(); + hasher.update(self.as_ref()); + let hash = hasher.finalize(); + + let mut etag = String::with_capacity(2 + hash.as_bytes().len() * 2); + etag.push('"'); + for byte in hash.as_bytes() { + let _ = write!(&mut etag, "{:02x}", byte); + } + etag.push('"'); + etag + } +} + +impl ExtractETag for BatchBuilder { + fn etag(&self) -> Option { + let p_value = u8::from(Property::Value); + for op in self.ops.iter().rev() { + match op { + Operation::Value { + class: ValueClass::Property(p_id), + op: ValueOp::Set(MaybeDynamicValue::Static(value)), + } if *p_id == p_value => { + return Archive::try_unpack_bytes(value).map(|bytes| bytes.etag()); + } + _ => {} + } + } + + None + } +} diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 459e24d1..6a562ab0 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.rs @@ -22,9 +22,10 @@ use trc::AddContext; use utils::map::bitmap::Bitmap; use crate::{ - DavError, + DavError, DavMethod, common::{ acl::DavAclHandler, + lock::{LockRequestHandler, ResourceState}, uri::{DavUriResource, UriResource}, }, file::{DavFileResource, FileItemId, insert_file_node, update_file_node}, @@ -49,13 +50,13 @@ impl FileCopyMoveRequestHandler for Server { is_move: bool, ) -> crate::Result { // Validate source - let from_resource = self.validate_uri(access_token, headers.uri).await?; - let from_account_id = from_resource.account_id()?; + let from_resource_ = self.validate_uri(access_token, headers.uri).await?; + let from_account_id = from_resource_.account_id()?; let from_files = self .fetch_file_hierarchy(from_account_id) .await .caused_by(trc::location!())?; - let from_resource = from_files.map_resource::(from_resource)?; + let from_resource = from_files.map_resource::(&from_resource_)?; // Validate source ACLs let mut child_acl = Bitmap::new(); @@ -115,7 +116,10 @@ impl FileCopyMoveRequestHandler for Server { }; // Map file item + let mut destination_resource_name = ""; let mut destination = if let Some(resource) = destination.resource { + destination_resource_name = resource; + // Check if the resource exists if let Some(destination) = to_files .files @@ -183,6 +187,33 @@ impl FileCopyMoveRequestHandler for Server { return Err(DavError::Code(StatusCode::FORBIDDEN)); } + // Validate headers + self.validate_headers( + access_token, + &headers, + vec![ + ResourceState { + account_id: from_account_id, + collection: Collection::FileNode, + document_id: Some(from_resource.resource.document_id), + etag: None, + lock_token: None, + path: from_resource_.resource.unwrap(), + }, + ResourceState { + account_id: to_account_id, + collection: Collection::FileNode, + document_id: Some(destination.document_id.unwrap_or(u32::MAX)), + etag: None, + lock_token: None, + path: destination_resource_name, + }, + ], + Default::default(), + DavMethod::MOVE, + ) + .await?; + // Validate quota if !is_move || from_account_id != to_account_id { let res = from_files @@ -298,18 +329,19 @@ async fn move_container( if let Some(new_name) = destination.new_name { new_node.name = new_name; } - update_file_node( + let etag = update_file_node( server, access_token, node, new_node, from_account_id, from_document_id, + true, ) .await .caused_by(trc::location!())?; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } else { copy_container( server, @@ -528,13 +560,14 @@ async fn overwrite_and_delete_item( }; source_node.parent_id = dest_node.inner.parent_id; - update_file_node( + let etag = update_file_node( server, access_token, dest_node, source_node, to_account_id, to_document_id, + true, ) .await .caused_by(trc::location!())?; @@ -549,7 +582,7 @@ async fn overwrite_and_delete_item( .await .caused_by(trc::location!())?; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } // Overwrites the contents of one file with another @@ -598,18 +631,19 @@ async fn overwrite_item( }; source_node.parent_id = dest_node.inner.parent_id; - update_file_node( + let etag = update_file_node( server, access_token, dest_node, source_node, to_account_id, to_document_id, + true, ) .await .caused_by(trc::location!())?; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } // Moves an item under an existing container @@ -642,7 +676,7 @@ async fn move_item( new_node.name = new_name; } - if from_account_id == to_account_id { + let etag = if from_account_id == to_account_id { // Destination is in the same account: just update the parent id update_file_node( server, @@ -651,12 +685,13 @@ async fn move_item( new_node, from_account_id, from_document_id, + true, ) .await - .caused_by(trc::location!())?; + .caused_by(trc::location!())? } else { // Destination is in a different account: insert a new node, then delete the old one - insert_file_node(server, access_token, new_node, to_account_id) + let etag = insert_file_node(server, access_token, new_node, to_account_id, true) .await .caused_by(trc::location!())?; delete_file_node( @@ -668,9 +703,10 @@ async fn move_item( ) .await .caused_by(trc::location!())?; - } + etag + }; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } // Copies an item under an existing container @@ -701,11 +737,11 @@ async fn copy_item( if let Some(new_name) = destination.new_name { node.name = new_name; } - insert_file_node(server, access_token, node, to_account_id) + let etag = insert_file_node(server, access_token, node, to_account_id, true) .await .caused_by(trc::location!())?; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } // Renames an item @@ -734,18 +770,19 @@ async fn rename_item( if let Some(new_name) = destination.new_name { new_node.name = new_name; } - update_file_node( + let etag = update_file_node( server, access_token, node, new_node, from_account_id, from_document_id, + true, ) .await .caused_by(trc::location!())?; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } impl FromFileItem for Destination { diff --git a/crates/dav/src/file/delete.rs b/crates/dav/src/file/delete.rs index 35fbef4a..c5f15714 100644 --- a/crates/dav/src/file/delete.rs +++ b/crates/dav/src/file/delete.rs @@ -17,8 +17,12 @@ use trc::AddContext; use utils::map::bitmap::Bitmap; use crate::{ - DavError, - common::{acl::DavAclHandler, uri::DavUriResource}, + DavError, DavMethod, + common::{ + acl::DavAclHandler, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, }; pub(crate) trait FileDeleteRequestHandler: Sync + Send { @@ -80,6 +84,23 @@ impl FileDeleteRequestHandler for Server { ) .await?; + // Validate headers + self.validate_headers( + access_token, + &headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: document_id.into(), + etag: None, + lock_token: None, + path: delete_path, + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + // Process deletions let mut changes = ChangeLogBuilder::new(); for document_id in sorted_ids { diff --git a/crates/dav/src/file/get.rs b/crates/dav/src/file/get.rs index 6a06cfd6..2819ca97 100644 --- a/crates/dav/src/file/get.rs +++ b/crates/dav/src/file/get.rs @@ -14,8 +14,12 @@ use store::write::Archive; use trc::AddContext; use crate::{ - DavError, - common::uri::DavUriResource, + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, file::{DavFileResource, acl::FileAclRequestHandler}, }; @@ -36,13 +40,13 @@ impl FileGetRequestHandler for Server { is_head: bool, ) -> crate::Result { // Validate URI - let resource = self.validate_uri(access_token, headers.uri).await?; - let account_id = resource.account_id()?; + let resource_ = self.validate_uri(access_token, headers.uri).await?; + let account_id = resource_.account_id()?; let files = self .fetch_file_hierarchy(account_id) .await .caused_by(trc::location!())?; - let resource = files.map_resource(resource)?; + let resource = files.map_resource(&resource_)?; // Fetch node let node_ = self @@ -71,9 +75,27 @@ impl FileGetRequestHandler for Server { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); }; + // Validate headers + let etag = node_.etag(); + self.validate_headers( + access_token, + &headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: resource.resource.into(), + etag: etag.clone().into(), + lock_token: None, + path: resource_.resource.unwrap(), + }], + Default::default(), + DavMethod::GET, + ) + .await?; + let response = HttpResponse::new(StatusCode::OK) .with_content_type(content_type.unwrap_or("application/octet-stream")) - .with_etag(u64::from(node.change_id)) + .with_etag(etag) .with_last_modified(Rfc1123DateTime::new(i64::from(node.modified)).to_string()); if !is_head { diff --git a/crates/dav/src/file/mkcol.rs b/crates/dav/src/file/mkcol.rs index fb122076..3e2fd9e9 100644 --- a/crates/dav/src/file/mkcol.rs +++ b/crates/dav/src/file/mkcol.rs @@ -17,7 +17,12 @@ use store::write::{BatchBuilder, log::LogInsert, now}; use trc::AddContext; use crate::{ - common::{acl::DavAclHandler, uri::DavUriResource}, + DavMethod, + common::{ + acl::DavAclHandler, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, file::DavFileResource, }; @@ -40,15 +45,15 @@ impl FileMkColRequestHandler for Server { request: Option, ) -> crate::Result { // Validate URI - let resource = self.validate_uri(access_token, headers.uri).await?; - let account_id = resource.account_id()?; + let resource_ = self.validate_uri(access_token, headers.uri).await?; + let account_id = resource_.account_id()?; let files = self .fetch_file_hierarchy(account_id) .await .caused_by(trc::location!())?; - let resource = files.map_parent_resource(resource)?; + let resource = files.map_parent_resource(&resource_)?; - // Build collection + // Validate and map parent ACL let parent_id = self .validate_and_map_parent_acl( access_token, @@ -58,6 +63,25 @@ impl FileMkColRequestHandler for Server { Acl::CreateChild, ) .await?; + + // Validate headers + self.validate_headers( + access_token, + &headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + etag: None, + lock_token: None, + path: resource_.resource.unwrap(), + }], + Default::default(), + DavMethod::MKCOL, + ) + .await?; + + // Build file container let change_id = self.generate_snowflake_id().caused_by(trc::location!())?; let now = now(); let mut node = FileNode { @@ -67,7 +91,6 @@ impl FileMkColRequestHandler for Server { file: None, created: now as i64, modified: now as i64, - change_id, dead_properties: Default::default(), acls: Default::default(), }; diff --git a/crates/dav/src/file/mod.rs b/crates/dav/src/file/mod.rs index 301c4946..c8674a43 100644 --- a/crates/dav/src/file/mod.rs +++ b/crates/dav/src/file/mod.rs @@ -17,7 +17,10 @@ use store::write::{ now, }; -use crate::{DavError, common::uri::UriResource}; +use crate::{ + DavError, + common::{ExtractETag, uri::UriResource}, +}; pub mod acl; pub mod changes; @@ -42,7 +45,7 @@ pub(crate) struct FileItemId { pub(crate) trait DavFileResource { fn map_resource( &self, - resource: UriResource>, + resource: &UriResource>, ) -> crate::Result>; fn map_parent<'x, T: FromFileItem>( @@ -52,14 +55,14 @@ pub(crate) trait DavFileResource { fn map_parent_resource<'x, T: FromFileItem>( &self, - resource: UriResource>, + resource: &UriResource>, ) -> crate::Result, Cow<'x, str>)>>; } impl DavFileResource for Files { fn map_resource( &self, - resource: UriResource>, + resource: &UriResource>, ) -> crate::Result> { resource .resource @@ -95,7 +98,7 @@ impl DavFileResource for Files { fn map_parent_resource<'x, T: FromFileItem>( &self, - resource: UriResource>, + resource: &UriResource>, ) -> crate::Result, Cow<'x, str>)>> { if let Some(r) = resource.resource { if self.files.by_name(r).is_none() { @@ -138,14 +141,14 @@ pub(crate) async fn update_file_node( mut new_node: FileNode, account_id: u32, document_id: u32, -) -> trc::Result<()> { + with_etag: bool, +) -> trc::Result> { // Build node new_node.modified = now() as i64; - new_node.change_id = server.generate_snowflake_id()?; + let change_id = server.generate_snowflake_id()?; // Prepare write batch let mut batch = BatchBuilder::new(); - let change_id = new_node.change_id; batch .with_change_id(change_id) .with_account_id(account_id) @@ -158,6 +161,7 @@ pub(crate) async fn update_file_node( .with_changes(new_node) .with_tenant_id(access_token), )?; + let etag = if with_etag { batch.etag() } else { None }; server.store().write(batch).await?; // Broadcast state change @@ -165,7 +169,7 @@ pub(crate) async fn update_file_node( .broadcast_single_state_change(account_id, change_id, DataType::FileNode) .await; - Ok(()) + Ok(etag) } pub(crate) async fn insert_file_node( @@ -173,16 +177,16 @@ pub(crate) async fn insert_file_node( access_token: &AccessToken, mut node: FileNode, account_id: u32, -) -> trc::Result<()> { + with_etag: bool, +) -> trc::Result> { // Build node let now = now() as i64; node.modified = now; node.created = now; - node.change_id = server.generate_snowflake_id()?; // Prepare write batch let mut batch = BatchBuilder::new(); - let change_id = node.change_id; + let change_id = server.generate_snowflake_id()?; batch .with_change_id(change_id) .with_account_id(account_id) @@ -194,6 +198,8 @@ pub(crate) async fn insert_file_node( .with_changes(node) .with_tenant_id(access_token), )?; + let etag = if with_etag { batch.etag() } else { None }; + server.store().write(batch).await?; // Broadcast state change @@ -201,7 +207,7 @@ pub(crate) async fn insert_file_node( .broadcast_single_state_change(account_id, change_id, DataType::FileNode) .await; - Ok(()) + Ok(etag) } pub(crate) async fn delete_file_node( diff --git a/crates/dav/src/file/proppatch.rs b/crates/dav/src/file/proppatch.rs index 7aee32a7..6d129ec4 100644 --- a/crates/dav/src/file/proppatch.rs +++ b/crates/dav/src/file/proppatch.rs @@ -21,8 +21,12 @@ use store::write::{Archive, assert::HashedValue}; use trc::AddContext; use crate::{ - DavError, - common::uri::DavUriResource, + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, file::{DavFileResource, acl::FileAclRequestHandler}, }; @@ -53,14 +57,14 @@ impl FilePropPatchRequestHandler for Server { request: PropertyUpdate, ) -> crate::Result { // Validate URI - let resource = self.validate_uri(access_token, headers.uri).await?; + let resource_ = self.validate_uri(access_token, headers.uri).await?; let uri = headers.uri; - let account_id = resource.account_id()?; + let account_id = resource_.account_id()?; let files = self .fetch_file_hierarchy(account_id) .await .caused_by(trc::location!())?; - let resource = files.map_resource(resource)?; + let resource = files.map_resource(&resource_)?; // Fetch node let node_ = self @@ -86,6 +90,25 @@ impl FilePropPatchRequestHandler for Server { Acl::ModifyItems, ) .await?; + + // Validate headers + self.validate_headers( + access_token, + &headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: resource.resource.into(), + etag: node_.inner.etag().clone().into(), + lock_token: None, + path: resource_.resource.unwrap(), + }], + Default::default(), + DavMethod::PROPPATCH, + ) + .await?; + + // Deserialize let node = node.into_deserialized().caused_by(trc::location!())?; let mut new_node = node.inner.clone(); @@ -126,7 +149,7 @@ impl FilePropPatchRequestHandler for Server { // Set properties self.apply_file_properties(&mut new_node, true, request.set, &mut items); - if new_node != node.inner { + let etag = if new_node != node.inner { update_file_node( self, access_token, @@ -134,13 +157,17 @@ impl FilePropPatchRequestHandler for Server { new_node, account_id, resource.resource, + true, ) .await - .caused_by(trc::location!())?; - } + .caused_by(trc::location!())? + } else { + node_.inner.etag().into() + }; Ok(HttpResponse::new(StatusCode::MULTI_STATUS) - .with_xml_body(MultiStatus::new(vec![Response::new_propstat(uri, items)]).to_string())) + .with_xml_body(MultiStatus::new(vec![Response::new_propstat(uri, items)]).to_string()) + .with_etag_opt(etag)) } fn apply_file_properties( diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs index eb21eb19..19c6f5b6 100644 --- a/crates/dav/src/file/update.rs +++ b/crates/dav/src/file/update.rs @@ -22,8 +22,13 @@ use trc::AddContext; use utils::BlobHash; use crate::{ - DavError, - common::{acl::DavAclHandler, uri::DavUriResource}, + DavError, DavMethod, + common::{ + ETag, ExtractETag, + acl::DavAclHandler, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, file::DavFileResource, }; @@ -85,6 +90,23 @@ impl FileUpdateRequestHandler for Server { ) .await?; + // Validate headers + self.validate_headers( + access_token, + &headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(document_id), + etag: node_archive_.inner.etag().into(), + lock_token: None, + path: resource_name, + }], + Default::default(), + DavMethod::PUT, + ) + .await?; + // Verify that the node is a file if let Some(file) = node.file.as_ref() { if BlobHash::generate(&bytes).as_slice() == file.blob_hash.0.as_slice() { @@ -123,7 +145,6 @@ impl FileUpdateRequestHandler for Server { new_file.media_type = headers.content_type.map(|v| v.to_string()); new_file.size = bytes.len() as u32; new_node.modified = now() as i64; - new_node.change_id = change_id; // Prepare write batch let mut batch = BatchBuilder::new(); @@ -140,6 +161,7 @@ impl FileUpdateRequestHandler for Server { .with_tenant_id(access_token), ) .caused_by(trc::location!())?; + let etag = batch.etag(); self.store() .write(batch) .await @@ -149,9 +171,10 @@ impl FileUpdateRequestHandler for Server { self.broadcast_single_state_change(account_id, change_id, DataType::FileNode) .await; - Ok(HttpResponse::new(StatusCode::OK)) + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) } else { // Insert + let orig_resource_name = resource_name; let (parent_id, resource_name) = files .map_parent(resource_name) .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; @@ -187,6 +210,23 @@ impl FileUpdateRequestHandler for Server { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); } + // Validate headers + self.validate_headers( + access_token, + &headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + etag: None, + lock_token: None, + path: orig_resource_name, + }], + Default::default(), + DavMethod::PUT, + ) + .await?; + // Validate quota if !bytes.is_empty() { self.has_available_quota( @@ -218,7 +258,6 @@ impl FileUpdateRequestHandler for Server { }), created: now as i64, modified: now as i64, - change_id, dead_properties: Default::default(), acls: Default::default(), }; @@ -237,6 +276,7 @@ impl FileUpdateRequestHandler for Server { .with_tenant_id(access_token), ) .caused_by(trc::location!())?; + let etag = batch.etag(); self.store() .write(batch) .await @@ -246,7 +286,7 @@ impl FileUpdateRequestHandler for Server { self.broadcast_single_state_change(account_id, change_id, DataType::FileNode) .await; - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } } } diff --git a/crates/groupware/src/file/mod.rs b/crates/groupware/src/file/mod.rs index 75f8654a..36eed095 100644 --- a/crates/groupware/src/file/mod.rs +++ b/crates/groupware/src/file/mod.rs @@ -22,7 +22,6 @@ pub struct FileNode { pub file: Option, pub created: i64, pub modified: i64, - pub change_id: u64, pub dead_properties: DeadProperty, pub acls: Vec, } diff --git a/crates/http-proto/src/response.rs b/crates/http-proto/src/response.rs index 1e35ba05..c71d193b 100644 --- a/crates/http-proto/src/response.rs +++ b/crates/http-proto/src/response.rs @@ -41,11 +41,19 @@ impl HttpResponse { self } - pub fn with_etag(mut self, etag: u64) -> Self { - self.builder = self.builder.header(header::ETAG, format!("\"{etag}\"")); + pub fn with_etag(mut self, etag: String) -> Self { + self.builder = self.builder.header(header::ETAG, etag); self } + pub fn with_etag_opt(self, etag: Option) -> Self { + if let Some(etag) = etag { + self.with_etag(etag) + } else { + self + } + } + pub fn with_last_modified(mut self, last_modified: String) -> Self { self.builder = self.builder.header(header::LAST_MODIFIED, last_modified); self diff --git a/crates/jmap-proto/src/types/collection.rs b/crates/jmap-proto/src/types/collection.rs index 6134d523..2ad07a62 100644 --- a/crates/jmap-proto/src/types/collection.rs +++ b/crates/jmap-proto/src/types/collection.rs @@ -13,7 +13,7 @@ use utils::map::bitmap::BitmapItem; use super::{property::Property, type_state::DataType}; -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] #[repr(u8)] pub enum Collection { Email = 0, @@ -30,6 +30,7 @@ pub enum Collection { AddressBook = 11, ContactCard = 12, FileNode = 13, + #[default] None = 14, } diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs index e21ffff9..941c697a 100644 --- a/crates/store/src/write/mod.rs +++ b/crates/store/src/write/mod.rs @@ -583,3 +583,9 @@ impl QueueClass { } } } + +impl AsRef<[u8]> for Archive { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} diff --git a/crates/store/src/write/serialize.rs b/crates/store/src/write/serialize.rs index b2a0c5f1..5abe9c6a 100644 --- a/crates/store/src/write/serialize.rs +++ b/crates/store/src/write/serialize.rs @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use std::borrow::Cow; + use rkyv::util::AlignedVec; use crate::{Deserialize, Serialize, SerializeInfallible, U32_LEN, Value}; @@ -127,6 +129,18 @@ where } impl Archive { + pub fn try_unpack_bytes(bytes: &[u8]) -> Option> { + match bytes.split_last() { + Some((&ARCHIVE_UNCOMPRESSED, archive)) => Some(archive.into()), + Some((&ARCHIVE_LZ4_COMPRESSED, archive)) => { + lz4_flex::decompress_size_prepended(archive) + .ok() + .map(Cow::Owned) + } + _ => None, + } + } + #[inline] pub fn as_bytes(&self) -> &[u8] { match self {