/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ETag; use super::uri::{DavUriResource, OwnedUri, UriResource, Urn}; use crate::{DavError, DavErrorCondition, DavMethod}; use common::KV_LOCK_DAV; use common::{Server, auth::AccessToken}; use dav_proto::schema::property::{ActiveLock, LockScope, WebDavProperty}; use dav_proto::schema::request::DavPropertyValue; use dav_proto::schema::response::{BaseCondition, List, PropResponse}; use dav_proto::{Condition, Depth, Timeout}; use dav_proto::{RequestHeaders, schema::request::LockInfo}; use groupware::cache::GroupwareCache; use http_proto::HttpResponse; use hyper::StatusCode; use std::collections::HashMap; use store::ValueKey; use store::dispatch::lookup::KeyValue; use store::write::serialize::rkyv_deserialize; use store::write::{AlignedBytes, Archive, Archiver, now}; use store::{Serialize, U32_LEN}; use trc::AddContext; use types::collection::Collection; use types::dead_property::DeadProperty; #[derive(Debug, Default, Clone)] pub struct ResourceState<'x> { pub account_id: u32, pub collection: Collection, pub document_id: Option, pub etag: Option, pub lock_tokens: Vec, pub sync_token: Option, pub path: &'x str, } #[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] pub(crate) struct LockData { locks: HashMap, } #[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] #[repr(transparent)] pub(crate) struct LockItems(Vec); #[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] pub(crate) struct LockItem { lock_id: u64, owner: u32, expires: u64, depth_infinity: bool, exclusive: bool, owner_dav: Option, } 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>, } pub(crate) trait LockRequestHandler: Sync + Send { fn handle_lock_request( &self, access_token: &AccessToken, headers: &RequestHeaders<'_>, lock_info: LockRequest, ) -> impl Future> + Send; fn validate_headers( &self, access_token: &AccessToken, headers: &RequestHeaders<'_>, resources: Vec>, locks: LockCaches<'_>, method: DavMethod, ) -> impl Future> + Send; } pub(crate) enum LockRequest { Lock(LockInfo), Unlock, Refresh, } impl LockRequestHandler for Server { async fn handle_lock_request( &self, access_token: &AccessToken, headers: &RequestHeaders<'_>, lock_info: LockRequest, ) -> crate::Result { let resource = self .validate_uri(access_token, headers.uri) .await? .into_owned_uri()?; let resource_hash = resource.lock_key(); let resource_path = resource .resource .ok_or(DavError::Code(StatusCode::CONFLICT))?; let account_id = resource.account_id; 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, ..Default::default() }]; let mut base_path = None; let is_lock_request = !matches!(lock_info, LockRequest::Unlock); let if_lock_token = headers .if_ .iter() .flat_map(|if_| if_.list.iter()) .find_map(|cond| { if let Condition::StateToken { token, .. } = cond { Urn::parse(token).and_then(|u| u.try_unwrap_lock()) } else { None } }) .unwrap_or_default(); let mut lock_data = if let Some(lock_data) = self .in_memory_store() .key_get::>(resource_hash.as_slice()) .await .caused_by(trc::location!())? { let lock_data = lock_data .unarchive::() .caused_by(trc::location!())?; self.validate_headers( access_token, headers, resources, LockCaches::new_shared(account_id, resource.collection, lock_data), if is_lock_request { DavMethod::LOCK } else { DavMethod::UNLOCK }, ) .await?; if let LockRequest::Lock(lock_info) = &lock_info { let mut failed_locks = Vec::new(); let is_exclusive = matches!(lock_info.lock_scope, LockScope::Exclusive); let is_infinity = matches!(headers.depth, Depth::Infinity); for (lock_path, lock_item) in lock_data.find_locks(resource_path, true) { if if_lock_token != lock_item.lock_id && (lock_item.exclusive || is_exclusive) && (lock_path.len() == resource_path.len() || lock_item.depth_infinity && resource_path.len() > lock_path.len() || is_infinity && lock_path.len() > resource_path.len()) { let base_path = base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default()); failed_locks.push(format!("{base_path}/{lock_path}").into()); } } if !failed_locks.is_empty() { return Err(DavErrorCondition::new( StatusCode::LOCKED, BaseCondition::LockTokenSubmitted(List(failed_locks)), ) .into()); } // Validate lock_info 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.groupware.max_locks_per_user > 0 && lock_data .locks .values() .flat_map(|locks| { locks .0 .iter() .filter(|lock| lock.owner == access_token.account_id()) }) .count() >= self.core.groupware.max_locks_per_user { return Err(DavError::Code(StatusCode::TOO_MANY_REQUESTS)); } } rkyv_deserialize(lock_data).caused_by(trc::location!())? } else if is_lock_request { self.validate_headers( access_token, headers, resources, Default::default(), DavMethod::LOCK, ) .await?; LockData::default() } else { return Err(DavErrorCondition::new( StatusCode::CONFLICT, BaseCondition::LockTokenMatchesRequestUri, ) .into()); }; let now = now(); let response = if is_lock_request { let timeout = if let Timeout::Second(seconds) = headers.timeout { std::cmp::min(seconds, self.core.groupware.max_lock_timeout) } else { self.core.groupware.max_lock_timeout }; let expires = now + timeout; let lock_item = if if_lock_token > 0 { if let Some(lock_item) = lock_data .locks .values_mut() .flat_map(|locks| locks.0.iter_mut()) .find(|lock| lock.lock_id == if_lock_token) { lock_item } else { return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); } } else { let locks = lock_data .locks .entry(resource_path.to_string()) .or_insert_with(Default::default); locks.0.push(LockItem::default()); locks.0.last_mut().unwrap() }; lock_item.expires = expires; if let LockRequest::Lock(lock_info) = lock_info { // Validate lock_info 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)); } lock_item.lock_id = store::rand::random::() ^ expires; lock_item.owner = access_token.account_id(); lock_item.depth_infinity = matches!(headers.depth, Depth::Infinity); lock_item.owner_dav = lock_info.owner; lock_item.exclusive = matches!(lock_info.lock_scope, LockScope::Exclusive); } let base_path = base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default()); let active_lock = lock_item.to_active_lock(format!("{base_path}/{resource_path}")); HttpResponse::new(if if_lock_token == 0 { StatusCode::CREATED } else { StatusCode::OK }) .with_lock_token(&active_lock.lock_token.as_ref().unwrap().0) .with_xml_body( PropResponse::new(vec![DavPropertyValue::new( WebDavProperty::LockDiscovery, vec![active_lock], )]) .to_string(), ) } else { let lock_id = headers .lock_token .and_then(Urn::parse) .and_then(|urn| urn.try_unwrap_lock()) .ok_or(DavError::Code(StatusCode::BAD_REQUEST))?; if lock_data.remove_lock(lock_id) { HttpResponse::new(StatusCode::NO_CONTENT) } else { return Err(DavErrorCondition::new( StatusCode::CONFLICT, BaseCondition::LockTokenMatchesRequestUri, ) .into()); } }; // Remove expired locks let max_expire = lock_data.remove_expired(); if max_expire > 0 { self.in_memory_store() .key_set( KeyValue::new( resource_hash, Archiver::new(lock_data) .untrusted() .serialize() .caused_by(trc::location!())?, ) .expires(max_expire), ) .await .caused_by(trc::location!())?; } else { self.in_memory_store() .key_delete(resource_hash) .await .caused_by(trc::location!())?; } 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 if no_if_headers => { // Return early for GET/HEAD requests without 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() || r.document_id.is_some_and(|id| id != u32::MAX) }) => { 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 for write operations let mut lock_response = Ok(()); if !matches!( method, DavMethod::GET | DavMethod::HEAD | DavMethod::LOCK | DavMethod::UNLOCK ) { let mut base_path = None; 'outer: for (pos, resource) in resources.iter().enumerate() { if pos == 0 && matches!(method, DavMethod::COPY) { continue; } if let Some(idx) = locks.find_cache_pos(self, resource).await? { let mut failed_locks = Vec::new(); for (lock_path, lock_item) in locks.find_locks_by_pos(idx, resource, true)? { let lock_token = lock_item.urn().to_string(); if headers.if_.iter().any(|if_| { if_.resource .is_none_or(|r| { r.trim_end_matches('/').ends_with(lock_path)}) && if_.list.iter().any(|cond| matches!(cond, Condition::StateToken { token, .. } if token == &lock_token)) }) { break 'outer; } else { let base_path = base_path.get_or_insert_with(|| { headers.base_uri() .unwrap_or_default() }); failed_locks.push(format!("{base_path}/{lock_path}").into()); } } if !failed_locks.is_empty() { lock_response = Err(DavErrorCondition::new( StatusCode::LOCKED, BaseCondition::LockTokenSubmitted(List(failed_locks)), ) .into()); break; } } } } // There are no If headers, so we can return early if no_if_headers { return lock_response; } let mut resource_not_found = ResourceState { account_id: u32::MAX, collection: Collection::None, path: "", ..Default::default() }; '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| { let path = r.resource?; Some(ResourceState { account_id: r.account_id?, collection: if !matches!(r.collection, Collection::FileNode) && path.contains('/') { r.collection.child_collection().unwrap_or(r.collection) } else { r.collection }, path, ..Default::default() }) }) { 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_tokens.is_empty() || resource_state.sync_token.is_none()) { let mut needs_lock_token = false; let mut needs_sync_token = false; let mut needs_etag = false; for cond in &if_.list { match cond { Condition::StateToken { token, .. } => { if token.starts_with("urn:inbuxa:davsync:") { needs_sync_token = true; } else { needs_lock_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() { resource_state.document_id = self .map_uri_resource( access_token, UriResource { collection: resource_state.collection, account_id: resource_state.account_id, resource: resource_state.path.into(), }, ) .await .caused_by(trc::location!())? .map(|uri| uri.resource) .unwrap_or(u32::MAX) .into(); } if let Some(document_id) = resource_state.document_id.filter(|&id| id != u32::MAX) && let Some(archive) = self .store() .get_value::>(ValueKey::archive( resource_state.account_id, resource_state.collection, document_id, )) .await .caused_by(trc::location!())? { resource_state.etag = archive.etag().into(); } } // Fetch lock token if needs_lock_token && resource_state.lock_tokens.is_empty() && let Some(idx) = locks.find_cache_pos(self, resource_state).await? { let found_locks = locks .find_locks_by_pos(idx, resource_state, false)? .iter() .map(|(_, lock)| lock.urn().to_string()) .collect::>(); resource_state.lock_tokens = found_locks; } // Fetch sync token if needs_sync_token && resource_state.sync_token.is_none() { let id = self .fetch_dav_resources( access_token.account_id(), resource_state.account_id, resource_state.collection.into(), ) .await .caused_by(trc::location!())? .highest_change_id; resource_state.sync_token = Some(Urn::Sync { id, seq: 0 }.to_string()); } } for cond in &if_.list { match cond { Condition::StateToken { is_not, token } => { if let Some(token) = Urn::try_extract_sync_id(token) { if !((resource_state .sync_token .as_deref() .and_then(Urn::try_extract_sync_id) .is_some_and(|sync_token| sync_token == token)) ^ is_not) { continue 'outer; } } else if !((resource_state.lock_tokens.iter().any(|t| t == 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 lock_response; } Err(DavError::Code( if matches!(method, DavMethod::GET | DavMethod::HEAD) && headers .if_ .iter() .any(|if_| if_.list.iter().any(|cond| cond.is_none_match())) { StatusCode::NOT_MODIFIED } else { StatusCode::PRECONDITION_FAILED }, )) } } impl LockData { pub fn remove_lock(&mut self, lock_id: u64) -> bool { for (lock_path, lock_items) in self.locks.iter_mut() { for (idx, lock_item) in lock_items.0.iter().enumerate() { if lock_item.lock_id == lock_id { lock_items.0.swap_remove(idx); if lock_items.0.is_empty() { let lock_path = lock_path.clone(); self.locks.remove(&lock_path); } return true; } } } false } pub fn remove_expired(&mut self) -> u64 { let mut max_expire = 0; let now = now(); self.locks.retain(|_, locks| { locks.0.retain(|lock| { if lock.expires > now { max_expire = std::cmp::max(max_expire, lock.expires); true } else { false } }); !locks.0.is_empty() }); max_expire } } 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.main_collection() == cache.collection.main_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_locks_by_pos( &'x self, pos: usize, resource_state: &'x ResourceState<'_>, include_children: bool, ) -> trc::Result> { self.caches[pos] .lock_archive .unarchive() .map(|l| l.find_locks(resource_state.path, include_children)) } 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) } } } impl LockItem { pub fn to_active_lock(&self, href: String) -> ActiveLock { ActiveLock::new( href, if self.exclusive { LockScope::Exclusive } else { LockScope::Shared }, ) .with_depth(if self.depth_infinity { Depth::Infinity } else { Depth::Zero }) .with_owner_opt(self.owner_dav.clone()) .with_timeout(self.expires.saturating_sub(now())) .with_lock_token(self.urn().to_string()) } pub fn urn(&self) -> Urn { Urn::Lock(self.lock_id) } } impl ArchivedLockData { pub fn find_locks<'x: 'y, 'y>( &'x self, resource: &'y str, include_children: bool, ) -> Vec<(&'y str, &'x ArchivedLockItem)> { let now = now(); let mut resource_part = resource; let mut found_locks = Vec::new(); loop { if let Some(locks) = self.locks.get(resource_part) { found_locks.extend( locks .0 .iter() .filter(|lock| { lock.expires > now && (resource == resource_part || lock.depth_infinity) }) .map(|lock| (resource_part, lock)), ); } if let Some((resource_part_, _)) = resource_part.rsplit_once('/') { resource_part = resource_part_; } else { break; } } if include_children { let prefix = format!("{}/", resource); for (resource_part, locks) in self.locks.iter() { if resource_part.starts_with(&prefix) { found_locks.extend( locks .0 .iter() .filter(|lock| lock.expires > now) .map(|lock| (resource_part.as_str(), lock)), ); } } } found_locks } } impl ArchivedLockItem { pub fn to_active_lock(&self, href: String) -> ActiveLock { ActiveLock::new( href, if self.exclusive { LockScope::Exclusive } else { LockScope::Shared }, ) .with_depth(if self.depth_infinity { Depth::Infinity } else { Depth::Zero }) .with_owner_opt(self.owner_dav.as_ref().map(Into::into)) .with_timeout(u64::from(self.expires).saturating_sub(now())) .with_lock_token(self.urn().to_string()) } pub fn urn(&self) -> Urn { Urn::Lock(self.lock_id.into()) } } impl OwnedUri<'_> { pub fn lock_key(&self) -> Vec { build_lock_key(self.account_id, self.collection.main_collection()) } } impl ResourceState<'_> { pub fn lock_key(&self) -> Vec { build_lock_key(self.account_id, self.collection.main_collection()) } } pub(crate) fn build_lock_key(account_id: u32, collection: Collection) -> Vec { let mut result = Vec::with_capacity(U32_LEN + 2); result.push(KV_LOCK_DAV); result.extend_from_slice(account_id.to_be_bytes().as_slice()); result.push(u8::from(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<'_> {}