From 01f7c8f56dfd451565622425cf40a0ff170853f8 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 24 Jun 2025 13:28:29 +0200 Subject: [PATCH] Assisted CalDAV/CardDAV shared resource discovery (closes #1691) --- crates/common/src/config/groupware.rs | 4 + crates/dav/src/common/mod.rs | 27 + crates/dav/src/common/propfind.rs | 1066 ++++++++++++++----------- crates/dav/src/common/uri.rs | 4 +- crates/dav/src/lib.rs | 1 + crates/dav/src/principal/propfind.rs | 68 +- crates/groupware/src/cache/calcard.rs | 17 +- crates/groupware/src/cache/mod.rs | 25 +- crates/groupware/src/calendar/itip.rs | 2 +- tests/src/webdav/mod.rs | 86 +- 10 files changed, 772 insertions(+), 528 deletions(-) diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 0c87cbcb..d22127e2 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -17,6 +17,7 @@ pub struct GroupwareConfig { pub max_lock_timeout: u64, pub max_locks_per_user: usize, pub max_results: usize, + pub assisted_discovery: bool, // Calendar settings pub max_ical_size: usize, @@ -81,6 +82,9 @@ impl GroupwareConfig { .property_or_default::>("dav.property.max-size.dead", "1024") .unwrap_or(Some(1024)), live_property_size: config.property("dav.property.max-size.live").unwrap_or(250), + assisted_discovery: config + .property("dav.collection.assisted-discovery") + .unwrap_or(false), max_lock_timeout: config .property::("dav.lock.max-timeout") .map(|d| d.as_secs()) diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs index 950dbe25..a7075964 100644 --- a/crates/dav/src/common/mod.rs +++ b/crates/dav/src/common/mod.rs @@ -75,6 +75,10 @@ pub(crate) enum DavQueryResource<'x> { parent_collection: Collection, items: Vec, }, + Discovery { + parent_collection: Collection, + account_ids: Vec, + }, #[default] None, } @@ -299,6 +303,29 @@ impl<'x> DavQuery<'x> { } } + pub fn discovery( + propfind: PropFind, + account_ids: Vec, + collection: Collection, + headers: &RequestHeaders<'x>, + ) -> Self { + Self { + resource: DavQueryResource::Discovery { + parent_collection: collection, + account_ids, + }, + propfind, + depth: 0, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + uri: headers.uri, + sync_type: Default::default(), + limit: Default::default(), + max_vcard_version: headers.max_vcard_version, + expand: false, + } + } + pub fn is_minimal(&self) -> bool { self.ret == Return::Minimal } diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 827dad7c..192b85ef 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -131,11 +131,13 @@ impl PropFindRequestHandler for Server { Depth::Zero => false, Depth::Infinity => match resource.collection { Collection::Principal => true, - Collection::Calendar | Collection::AddressBook | Collection::CalendarScheduling - if resource.account_id.is_some() && resource.resource.is_some() => + Collection::Calendar | Collection::AddressBook + if self.core.groupware.assisted_discovery + || (resource.account_id.is_some() && resource.resource.is_some()) => { true } + Collection::CalendarScheduling if resource.account_id.is_some() => true, _ => { return Err(DavErrorCondition::new( StatusCode::FORBIDDEN, @@ -210,122 +212,45 @@ impl PropFindRequestHandler for Server { } _ => unreachable!(), } + } else if (self.core.groupware.assisted_discovery + || matches!(headers.depth, Depth::Infinity)) + && matches!( + resource.collection, + Collection::Calendar | Collection::AddressBook + ) + { + // Assisted collection discovery + + // Validate permissions + access_token.assert_has_permission(match resource.collection { + Collection::Calendar => Permission::DavCalPropFind, + Collection::AddressBook => Permission::DavCardPropFind, + _ => unreachable!(), + })?; + + self.handle_dav_query( + access_token, + DavQuery::discovery( + request, + access_token + .all_ids_by_collection(resource.collection) + .collect(), + resource.collection, + headers, + ), + ) + .await } else { let mut response = MultiStatus::new(Vec::with_capacity(16)); // Add container info if !headers.depth_no_root { - let properties = match &request { - PropFind::PropName => { - response.add_response(Response::new_propstat( - resource.collection_path(), - vec![PropStat::new_list(vec![ - DavPropertyValue::empty(DavProperty::WebDav( - WebDavProperty::ResourceType, - )), - DavPropertyValue::empty(DavProperty::WebDav( - WebDavProperty::CurrentUserPrincipal, - )), - DavPropertyValue::empty(DavProperty::WebDav( - WebDavProperty::SupportedReportSet, - )), - ])], - )); - &[] - } - PropFind::AllProp(_) => [ - DavProperty::WebDav(WebDavProperty::ResourceType), - DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), - DavProperty::WebDav(WebDavProperty::SupportedReportSet), - ] - .as_slice(), - PropFind::Prop(items) => items, - }; - - if !matches!(request, PropFind::PropName) { - let mut fields = Vec::with_capacity(properties.len()); - let mut fields_not_found = Vec::new(); - - for prop in properties { - match &prop { - DavProperty::WebDav(WebDavProperty::ResourceType) => { - fields.push(DavPropertyValue::new( - prop.clone(), - vec![ResourceType::Collection], - )); - } - DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal) => { - fields.push(DavPropertyValue::new( - prop.clone(), - vec![access_token.current_user_principal()], - )); - } - DavProperty::Principal(PrincipalProperty::CalendarHomeSet) => { - fields.push(DavPropertyValue::new( - prop.clone(), - vec![Href(format!( - "{}/{}/", - DavResourceName::Cal.base_path(), - percent_encoding::utf8_percent_encode( - &access_token.name, - RFC_3986 - ), - ))], - )); - response.set_namespace(Namespace::CalDav); - } - DavProperty::Principal(PrincipalProperty::AddressbookHomeSet) => { - fields.push(DavPropertyValue::new( - prop.clone(), - vec![Href(format!( - "{}/{}/", - DavResourceName::Card.base_path(), - percent_encoding::utf8_percent_encode( - &access_token.name, - RFC_3986 - ), - ))], - )); - response.set_namespace(Namespace::CardDav); - } - DavProperty::WebDav(WebDavProperty::SupportedReportSet) => { - let reports = match resource.collection { - Collection::Principal => ReportSet::principal(), - Collection::Calendar | Collection::CalendarEvent => { - ReportSet::calendar() - } - Collection::AddressBook | Collection::ContactCard => { - ReportSet::addressbook() - } - _ => ReportSet::file(), - }; - - fields.push(DavPropertyValue::new(prop.clone(), reports)); - } - _ => { - response.set_namespace(prop.namespace()); - fields_not_found.push(DavPropertyValue::empty(prop.clone())); - } - } - } - - let mut prop_stat = Vec::with_capacity(2); - - if !fields.is_empty() { - prop_stat.push(PropStat::new_list(fields)); - } - - if !fields_not_found.is_empty() { - prop_stat.push( - PropStat::new_list(fields_not_found).with_status(StatusCode::NOT_FOUND), - ); - } - - response.add_response(Response::new_propstat( - resource.collection_path(), - prop_stat, - )); - } + add_base_collection_response( + &request, + resource.collection, + access_token, + &mut response, + ); } if return_children { @@ -388,7 +313,6 @@ impl PropFindRequestHandler for Server { let collection_container; let collection_children; let sync_collection; - let mut paths; let mut query_filter = None; let mut limit = std::cmp::min( query.limit.unwrap_or(u32::MAX) as usize, @@ -396,378 +320,109 @@ impl PropFindRequestHandler for Server { ); let mut is_sync_limited = false; - //let c = println!("handling DAV query {query:#?}"); - - match std::mem::take(&mut query.resource) { + let paths = match std::mem::take(&mut query.resource) { DavQueryResource::Uri(resource) => { - let account_id = resource.account_id; collection_container = resource.collection; collection_children = collection_container.child_collection().unwrap(); sync_collection = SyncCollection::from(collection_container); - let container_has_children = collection_children != collection_container; - let resources = data - .resources(self, access_token, account_id, sync_collection) - .await - .caused_by(trc::location!())?; - response.set_namespace(collection_container.namespace()); - // Obtain document ids - let mut display_containers = if !access_token.is_member(account_id) { - resources - .shared_containers( - access_token, - [if container_has_children { - Acl::ReadItems - } else { - Acl::Read - }], - true, - ) - .into() - } else { - None - }; - let mut display_children = display_containers - .as_ref() - .filter(|_| container_has_children) - .map(|containers| { - RoaringBitmap::from_iter(resources.resources.iter().filter_map(|r| { - if r.child_names() - .is_some_and(|n| n.iter().any(|n| containers.contains(n.parent_id))) - { - Some(r.document_id) - } else { - None - } - })) - }); + match get( + self, + access_token, + collection_container, + collection_children, + sync_collection, + &query, + &mut data, + &mut response, + resource, + limit, + &mut is_sync_limited, + ) + .await? + { + Some(paths) if paths.is_empty() && query.sync_type.is_none() => { + response.add_response( + Response::new_status([query.uri], StatusCode::NOT_FOUND) + .with_response_description("No resources found"), + ); - // Filter by changelog - match query.sync_type { - SyncType::From { id, seq } => { - let changes = self - .store() - .changes(account_id, sync_collection, Query::Since(id)) - .await - .caused_by(trc::location!())?; - let mut vanished: Vec = Vec::new(); - - // Merge changes - let mut total_changes = 0; - let mut maybe_has_vanished = false; - if container_has_children { - let mut container_changes = RoaringBitmap::new(); - let mut item_changes = RoaringBitmap::new(); - - for change in changes.changes { - match change { - Change::InsertItem(id) => { - item_changes.insert(id as u32); - } - Change::UpdateItem(id) => { - maybe_has_vanished = true; - item_changes.insert(id as u32); - } - Change::InsertContainer(id) => { - container_changes.insert(id as u32); - } - Change::UpdateContainer(id) => { - maybe_has_vanished = true; - container_changes.insert(id as u32); - } - Change::DeleteContainer(_) | Change::DeleteItem(_) => { - maybe_has_vanished = true; - } - Change::UpdateContainerProperty(_) => (), - } - } - - for (document_ids, changes) in [ - (&mut display_containers, container_changes), - (&mut display_children, item_changes), - ] { - if let Some(document_ids) = document_ids { - *document_ids &= changes; - total_changes += document_ids.len() as usize; - } else { - total_changes += changes.len() as usize; - *document_ids = Some(changes); - } - } - } else { - let changes = RoaringBitmap::from_iter( - changes.changes.iter().filter_map(|change| match change { - Change::InsertItem(id) | Change::InsertContainer(id) => { - Some(*id as u32) - } - Change::UpdateItem(id) | Change::UpdateContainer(id) => { - maybe_has_vanished = true; - Some(*id as u32) - } - Change::DeleteContainer(_) | Change::DeleteItem(_) => { - maybe_has_vanished = true; - None - } - _ => None, - }), - ); - if let Some(document_ids) = &mut display_containers { - *document_ids &= changes; - total_changes += document_ids.len() as usize; - } else { - total_changes += changes.len() as usize; - display_containers = Some(changes); - } - } - - if maybe_has_vanished { - if let Some(vanished_collection) = sync_collection.vanished_collection() - { - vanished = self - .store() - .vanished(account_id, vanished_collection, Query::Since(id)) - .await - .caused_by(trc::location!())?; - total_changes += vanished.len(); - } - } - - // Truncate changes - if total_changes > limit { - let mut offset = limit * seq as usize; - let mut total_changes = 0; - - // Add vanished items to response - for item in vanished { - if offset > 0 { - offset -= 1; - } else if total_changes < limit { - response.add_response(Response::new_status( - [item], - StatusCode::NOT_FOUND, - )); - total_changes += 1; - } else { - is_sync_limited = true; - } - } - - // Add items to document set - for document_ids in [&mut display_containers, &mut display_children] - .into_iter() - .flatten() - { - let mut new_document_ids = RoaringBitmap::new(); - for id in document_ids.iter() { - if offset > 0 { - offset -= 1; - } else if total_changes < limit { - new_document_ids.insert(id); - total_changes += 1; - } else { - is_sync_limited = true; - } - } - *document_ids = new_document_ids; - } - - if is_sync_limited { - response.set_sync_token(Urn::Sync { id, seq: seq + 1 }.to_string()); - } - } else { - // Add vanished items to response - for item in vanished { - response.add_response(Response::new_status( - [item], - StatusCode::NOT_FOUND, - )); - } - } - - if !is_sync_limited { - response.set_sync_token(resources.sync_token()); - } - } - SyncType::Initial => { - response.set_sync_token(resources.sync_token()); - } - SyncType::None => (), - } - - paths = if let Some(resource) = resource.resource { - resources - .subtree_with_depth(resource, query.depth) - .filter(|item| { - display_containers.as_ref().is_none_or(|containers| { - if container_has_children { - if item.is_container() { - containers.contains(item.document_id()) - } else { - display_children.as_ref().is_some_and(|children| { - children.contains(item.document_id()) - }) - } - } else { - containers.contains(item.document_id()) - } - }) && (!query.depth_no_root || item.path() != resource) - }) - .map(|item| { - PropFindItem::new(resources.format_resource(item), account_id, item) - }) - .collect::>() - } else { - if !query.depth_no_root && query.sync_type.is_none_or_initial() { - self.prepare_principal_propfind_response( - access_token, - collection_container, - [account_id].into_iter(), - &query.propfind, - &mut response, - ) - .await?; - } - - if query.depth == 0 { return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) .with_xml_body(response.to_string())); } - - resources - .tree_with_depth(query.depth - 1) - .filter(|item| { - display_containers.as_ref().is_none_or(|containers| { - if container_has_children { - if item.is_container() { - containers.contains(item.document_id()) - } else { - display_children.as_ref().is_some_and(|children| { - children.contains(item.document_id()) - }) - } - } else { - containers.contains(item.document_id()) - } - }) - }) - .map(|item| { - PropFindItem::new(resources.format_resource(item), account_id, item) - }) - .collect::>() - }; - - if paths.is_empty() && query.sync_type.is_none() { - response.add_response( - Response::new_status([query.uri], StatusCode::NOT_FOUND) - .with_response_description("No resources found"), - ); - - return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) - .with_xml_body(response.to_string())); + Some(paths) => paths, + None => { + return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body(response.to_string())); + } } } DavQueryResource::Multiget { hrefs, parent_collection, } => { - paths = Vec::with_capacity(hrefs.len()); - let mut shared_folders_by_account: AHashMap> = - AHashMap::with_capacity(3); collection_container = parent_collection; collection_children = collection_container.child_collection().unwrap(); sync_collection = SyncCollection::from(collection_container); response.set_namespace(collection_container.namespace()); - for item in hrefs { - let resource = match self - .validate_uri(access_token, &item) - .await - .and_then(|r| r.into_owned_uri()) - { - Ok(resource) => resource, - Err(DavError::Code(code)) => { - response.add_response(Response::new_status([item], code)); - continue; - } - Err(err) => { - return Err(err); - } - }; - - let account_id = resource.account_id; - let resources = data - .resources(self, access_token, account_id, sync_collection) - .await - .caused_by(trc::location!())?; - - let document_ids = if !access_token.is_member(account_id) { - if let Some(document_ids) = shared_folders_by_account.get(&account_id) { - document_ids.clone().into() - } else { - let document_ids = Arc::new(resources.shared_containers( - access_token, - [if collection_children == collection_container { - Acl::ReadItems - } else { - Acl::Read - }], - true, - )); - shared_folders_by_account.insert(account_id, document_ids.clone()); - document_ids.into() - } - } else { - None - }; - - if let Some(resource) = - resource.resource.and_then(|name| resources.by_path(name)) - { - if !resource.is_container() { - if document_ids - .as_ref() - .is_none_or(|docs| docs.contains(resource.document_id())) - { - paths.push(PropFindItem::new( - resources.format_resource(resource), - account_id, - resource, - )); - } else { - response.add_response( - Response::new_status([item], StatusCode::FORBIDDEN) - .with_response_description( - "Not enough permissions to access this shared resource", - ), - ); - } - } else { - response.add_response( - Response::new_status([item], StatusCode::FORBIDDEN) - .with_response_description( - "Multiget not allowed for collections", - ), - ); - } - } else { - response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); - } - } + multiget( + self, + access_token, + collection_container, + collection_children, + sync_collection, + &mut data, + &mut response, + hrefs, + ) + .await? } DavQueryResource::Query { filter, parent_collection, items, } => { - paths = items; query_filter = Some(filter); collection_container = parent_collection; collection_children = collection_container.child_collection().unwrap(); sync_collection = SyncCollection::from(collection_container); response.set_namespace(collection_container.namespace()); + items + } + DavQueryResource::Discovery { + parent_collection, + account_ids, + } => { + collection_container = parent_collection; + collection_children = collection_container.child_collection().unwrap(); + sync_collection = SyncCollection::from(collection_container); + response.set_namespace(collection_container.namespace()); + + // Add container info + if !query.depth_no_root { + add_base_collection_response( + &query.propfind, + parent_collection, + access_token, + &mut response, + ); + } + + discover_root_paths( + self, + access_token, + collection_container, + sync_collection, + &query, + &mut data, + &mut response, + account_ids, + ) + .await? } DavQueryResource::None => unreachable!(), - } + }; let mut skip_not_found = query.expand; let properties = match &query.propfind { @@ -1519,6 +1174,408 @@ impl PropFindRequestHandler for Server { }) } } +#[allow(clippy::too_many_arguments)] +async fn get( + server: &Server, + access_token: &AccessToken, + collection_container: Collection, + collection_children: Collection, + sync_collection: SyncCollection, + query: &DavQuery<'_>, + data: &mut PropFindData, + response: &mut MultiStatus, + resource: UriResource>, + limit: usize, + is_sync_limited: &mut bool, +) -> crate::Result>> { + let account_id = resource.account_id; + let container_has_children = collection_children != collection_container; + let resources = data + .resources(server, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())?; + response.set_namespace(collection_container.namespace()); + + // Obtain document ids + let mut display_containers = if !access_token.is_member(account_id) { + resources + .shared_containers( + access_token, + [if container_has_children { + Acl::ReadItems + } else { + Acl::Read + }], + true, + ) + .into() + } else { + None + }; + let mut display_children = display_containers + .as_ref() + .filter(|_| container_has_children) + .map(|containers| { + RoaringBitmap::from_iter(resources.resources.iter().filter_map(|r| { + if r.child_names() + .is_some_and(|n| n.iter().any(|n| containers.contains(n.parent_id))) + { + Some(r.document_id) + } else { + None + } + })) + }); + + // Filter by changelog + match query.sync_type { + SyncType::From { id, seq } => { + let changes = server + .store() + .changes(account_id, sync_collection, Query::Since(id)) + .await + .caused_by(trc::location!())?; + let mut vanished: Vec = Vec::new(); + + // Merge changes + let mut total_changes = 0; + let mut maybe_has_vanished = false; + if container_has_children { + let mut container_changes = RoaringBitmap::new(); + let mut item_changes = RoaringBitmap::new(); + + for change in changes.changes { + match change { + Change::InsertItem(id) => { + item_changes.insert(id as u32); + } + Change::UpdateItem(id) => { + maybe_has_vanished = true; + item_changes.insert(id as u32); + } + Change::InsertContainer(id) => { + container_changes.insert(id as u32); + } + Change::UpdateContainer(id) => { + maybe_has_vanished = true; + container_changes.insert(id as u32); + } + Change::DeleteContainer(_) | Change::DeleteItem(_) => { + maybe_has_vanished = true; + } + Change::UpdateContainerProperty(_) => (), + } + } + + for (document_ids, changes) in [ + (&mut display_containers, container_changes), + (&mut display_children, item_changes), + ] { + if let Some(document_ids) = document_ids { + *document_ids &= changes; + total_changes += document_ids.len() as usize; + } else { + total_changes += changes.len() as usize; + *document_ids = Some(changes); + } + } + } else { + let changes = RoaringBitmap::from_iter(changes.changes.iter().filter_map( + |change| match change { + Change::InsertItem(id) | Change::InsertContainer(id) => Some(*id as u32), + Change::UpdateItem(id) | Change::UpdateContainer(id) => { + maybe_has_vanished = true; + Some(*id as u32) + } + Change::DeleteContainer(_) | Change::DeleteItem(_) => { + maybe_has_vanished = true; + None + } + _ => None, + }, + )); + if let Some(document_ids) = &mut display_containers { + *document_ids &= changes; + total_changes += document_ids.len() as usize; + } else { + total_changes += changes.len() as usize; + display_containers = Some(changes); + } + } + + if maybe_has_vanished { + if let Some(vanished_collection) = sync_collection.vanished_collection() { + vanished = server + .store() + .vanished(account_id, vanished_collection, Query::Since(id)) + .await + .caused_by(trc::location!())?; + total_changes += vanished.len(); + } + } + + // Truncate changes + if total_changes > limit { + let mut offset = limit * seq as usize; + let mut total_changes = 0; + + // Add vanished items to response + for item in vanished { + if offset > 0 { + offset -= 1; + } else if total_changes < limit { + response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); + total_changes += 1; + } else { + *is_sync_limited = true; + } + } + + // Add items to document set + for document_ids in [&mut display_containers, &mut display_children] + .into_iter() + .flatten() + { + let mut new_document_ids = RoaringBitmap::new(); + for id in document_ids.iter() { + if offset > 0 { + offset -= 1; + } else if total_changes < limit { + new_document_ids.insert(id); + total_changes += 1; + } else { + *is_sync_limited = true; + } + } + *document_ids = new_document_ids; + } + + if *is_sync_limited { + response.set_sync_token(Urn::Sync { id, seq: seq + 1 }.to_string()); + } + } else { + // Add vanished items to response + for item in vanished { + response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); + } + } + + if !*is_sync_limited { + response.set_sync_token(resources.sync_token()); + } + } + SyncType::Initial => { + response.set_sync_token(resources.sync_token()); + } + SyncType::None => (), + } + + Ok(if let Some(resource) = resource.resource { + Some( + resources + .subtree_with_depth(resource, query.depth) + .filter(|item| { + display_containers.as_ref().is_none_or(|containers| { + if container_has_children { + if item.is_container() { + containers.contains(item.document_id()) + } else { + display_children + .as_ref() + .is_some_and(|children| children.contains(item.document_id())) + } + } else { + containers.contains(item.document_id()) + } + }) && (!query.depth_no_root || item.path() != resource) + }) + .map(|item| PropFindItem::new(resources.format_resource(item), account_id, item)) + .collect::>(), + ) + } else { + if !query.depth_no_root && query.sync_type.is_none_or_initial() { + server + .prepare_principal_propfind_response( + access_token, + collection_container, + [account_id].into_iter(), + &query.propfind, + response, + ) + .await?; + } + + if query.depth != 0 { + Some( + resources + .tree_with_depth(query.depth - 1) + .filter(|item| { + display_containers.as_ref().is_none_or(|containers| { + if container_has_children { + if item.is_container() { + containers.contains(item.document_id()) + } else { + display_children.as_ref().is_some_and(|children| { + children.contains(item.document_id()) + }) + } + } else { + containers.contains(item.document_id()) + } + }) + }) + .map(|item| { + PropFindItem::new(resources.format_resource(item), account_id, item) + }) + .collect::>(), + ) + } else { + None + } + }) +} + +#[allow(clippy::too_many_arguments)] +async fn multiget( + server: &Server, + access_token: &AccessToken, + collection_container: Collection, + collection_children: Collection, + sync_collection: SyncCollection, + data: &mut PropFindData, + response: &mut MultiStatus, + hrefs: Vec, +) -> crate::Result> { + let mut paths = Vec::with_capacity(hrefs.len() * 2); + let mut shared_folders_by_account: AHashMap> = + AHashMap::with_capacity(3); + + for item in hrefs { + let resource = match server + .validate_uri(access_token, &item) + .await + .and_then(|r| r.into_owned_uri()) + { + Ok(resource) => resource, + Err(DavError::Code(code)) => { + response.add_response(Response::new_status([item], code)); + continue; + } + Err(err) => { + return Err(err); + } + }; + + let account_id = resource.account_id; + let resources = data + .resources(server, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())?; + + let document_ids = if !access_token.is_member(account_id) { + if let Some(document_ids) = shared_folders_by_account.get(&account_id) { + document_ids.clone().into() + } else { + let document_ids = Arc::new(resources.shared_containers( + access_token, + [if collection_children == collection_container { + Acl::ReadItems + } else { + Acl::Read + }], + true, + )); + shared_folders_by_account.insert(account_id, document_ids.clone()); + document_ids.into() + } + } else { + None + }; + + if let Some(resource) = resource.resource.and_then(|name| resources.by_path(name)) { + if !resource.is_container() { + if document_ids + .as_ref() + .is_none_or(|docs| docs.contains(resource.document_id())) + { + paths.push(PropFindItem::new( + resources.format_resource(resource), + account_id, + resource, + )); + } else { + response.add_response( + Response::new_status([item], StatusCode::FORBIDDEN) + .with_response_description( + "Not enough permissions to access this shared resource", + ), + ); + } + } else { + response.add_response( + Response::new_status([item], StatusCode::FORBIDDEN) + .with_response_description("Multiget not allowed for collections"), + ); + } + } else { + response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); + } + } + + Ok(paths) +} + +#[allow(clippy::too_many_arguments)] +async fn discover_root_paths( + server: &Server, + access_token: &AccessToken, + collection_container: Collection, + sync_collection: SyncCollection, + query: &DavQuery<'_>, + data: &mut PropFindData, + response: &mut MultiStatus, + account_ids: Vec, +) -> crate::Result> { + let mut paths = Vec::with_capacity(account_ids.len() * 2); + + for account_id in account_ids { + let resources = data + .resources(server, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())?; + server + .prepare_principal_propfind_response( + access_token, + collection_container, + [account_id].into_iter(), + &query.propfind, + response, + ) + .await?; + + // Obtain document ids + let display_containers = if !access_token.is_member(account_id) { + resources + .shared_containers(access_token, [Acl::ReadItems], true) + .into() + } else { + None + }; + paths.extend( + resources + .tree_with_depth(0) + .filter(|item| { + item.is_container() + && display_containers + .as_ref() + .is_none_or(|containers| containers.contains(item.document_id())) + }) + .map(|item| PropFindItem::new(resources.format_resource(item), account_id, item)), + ); + } + + Ok(paths) +} impl PropFindItem { pub fn new(name: String, account_id: u32, resource: DavResourcePath<'_>) -> Self { @@ -1644,3 +1701,106 @@ impl SyncTokenUrn for DavResources { .to_string() } } + +fn add_base_collection_response( + request: &PropFind, + collection: Collection, + access_token: &AccessToken, + response: &mut MultiStatus, +) { + let properties = match request { + PropFind::PropName => { + response.add_response(Response::new_propstat( + DavResourceName::from(collection).collection_path(), + vec![PropStat::new_list(vec![ + DavPropertyValue::empty(DavProperty::WebDav(WebDavProperty::ResourceType)), + DavPropertyValue::empty(DavProperty::WebDav( + WebDavProperty::CurrentUserPrincipal, + )), + DavPropertyValue::empty(DavProperty::WebDav( + WebDavProperty::SupportedReportSet, + )), + ])], + )); + return; + } + PropFind::AllProp(_) => [ + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + ] + .as_slice(), + PropFind::Prop(items) => items, + }; + + let mut fields = Vec::with_capacity(properties.len()); + let mut fields_not_found = Vec::new(); + + for prop in properties { + match &prop { + DavProperty::WebDav(WebDavProperty::ResourceType) => { + fields.push(DavPropertyValue::new( + prop.clone(), + vec![ResourceType::Collection], + )); + } + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal) => { + fields.push(DavPropertyValue::new( + prop.clone(), + vec![access_token.current_user_principal()], + )); + } + DavProperty::Principal(PrincipalProperty::CalendarHomeSet) => { + fields.push(DavPropertyValue::new( + prop.clone(), + vec![Href(format!( + "{}/{}/", + DavResourceName::Cal.base_path(), + percent_encoding::utf8_percent_encode(&access_token.name, RFC_3986), + ))], + )); + response.set_namespace(Namespace::CalDav); + } + DavProperty::Principal(PrincipalProperty::AddressbookHomeSet) => { + fields.push(DavPropertyValue::new( + prop.clone(), + vec![Href(format!( + "{}/{}/", + DavResourceName::Card.base_path(), + percent_encoding::utf8_percent_encode(&access_token.name, RFC_3986), + ))], + )); + response.set_namespace(Namespace::CardDav); + } + DavProperty::WebDav(WebDavProperty::SupportedReportSet) => { + let reports = match collection { + Collection::Principal => ReportSet::principal(), + Collection::Calendar | Collection::CalendarEvent => ReportSet::calendar(), + Collection::AddressBook | Collection::ContactCard => ReportSet::addressbook(), + _ => ReportSet::file(), + }; + + fields.push(DavPropertyValue::new(prop.clone(), reports)); + } + _ => { + response.set_namespace(prop.namespace()); + fields_not_found.push(DavPropertyValue::empty(prop.clone())); + } + } + } + + let mut prop_stat = Vec::with_capacity(2); + + if !fields.is_empty() { + prop_stat.push(PropStat::new_list(fields)); + } + + if !fields_not_found.is_empty() { + prop_stat.push(PropStat::new_list(fields_not_found).with_status(StatusCode::NOT_FOUND)); + } + + response.add_response(Response::new_propstat( + DavResourceName::from(collection).collection_path(), + prop_stat, + )); +} diff --git a/crates/dav/src/common/uri.rs b/crates/dav/src/common/uri.rs index 6e829f15..78bd718f 100644 --- a/crates/dav/src/common/uri.rs +++ b/crates/dav/src/common/uri.rs @@ -178,11 +178,11 @@ impl OwnedUri<'_> { } } -impl UriResource { +/*impl UriResource { pub fn collection_path(&self) -> &'static str { DavResourceName::from(self.collection).collection_path() } -} +}*/ impl Urn { pub fn try_extract_sync_id(token: &str) -> Option<&str> { diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs index 10bebd25..244b59f4 100644 --- a/crates/dav/src/lib.rs +++ b/crates/dav/src/lib.rs @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +#![warn(clippy::large_futures)] pub mod calendar; pub mod card; diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs index 48991600..5549cef4 100644 --- a/crates/dav/src/principal/propfind.rs +++ b/crates/dav/src/principal/propfind.rs @@ -270,28 +270,54 @@ impl PrincipalPropFind for Server { ))], )); } - PrincipalProperty::CalendarHomeSet => { - fields.push(DavPropertyValue::new( - property.clone(), - vec![Href(format!( - "{}/{}/", - DavResourceName::Cal.base_path(), - percent_encoding::utf8_percent_encode(&name, RFC_3986), - ))], - )); - response.set_namespace(Namespace::CalDav); - } - PrincipalProperty::AddressbookHomeSet => { - fields.push(DavPropertyValue::new( - property.clone(), - vec![Href(format!( - "{}/{}/", - DavResourceName::Card.base_path(), - percent_encoding::utf8_percent_encode(&name, RFC_3986), - ))], - )); - response.set_namespace(Namespace::CardDav); + PrincipalProperty::CalendarHomeSet + | PrincipalProperty::AddressbookHomeSet => { + let mut hrefs = Vec::new(); + let (collection, resource_name, namespace) = + if principal_property == &PrincipalProperty::CalendarHomeSet { + ( + Collection::Calendar, + DavResourceName::Cal, + Namespace::CalDav, + ) + } else { + ( + Collection::AddressBook, + DavResourceName::Card, + Namespace::CardDav, + ) + }; + + for account_id in access_token.all_ids_by_collection(collection) { + let href = if account_id == access_token.primary_id() { + format!( + "{}/{}/", + resource_name.base_path(), + percent_encoding::utf8_percent_encode( + &access_token.name, + RFC_3986 + ), + ) + } else { + let name = self + .store() + .get_principal_name(account_id) + .await + .caused_by(trc::location!())? + .unwrap_or_else(|| format!("_{account_id}")); + format!( + "{}/{}/", + resource_name.base_path(), + percent_encoding::utf8_percent_encode(&name, RFC_3986), + ) + }; + hrefs.push(Href(href)); + } + + fields.push(DavPropertyValue::new(property.clone(), hrefs)); + response.set_namespace(namespace); } + PrincipalProperty::PrincipalAddress => { fields_not_found.push(DavPropertyValue::empty(property.clone())); response.set_namespace(Namespace::CardDav); diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index 27d0a82a..655192a9 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.rs @@ -53,14 +53,20 @@ pub(super) async fn build_calcard_resources( .await .caused_by(trc::location!())? .unwrap_or_default(); + let name = server + .store() + .get_principal_name(account_id) + .await + .caused_by(trc::location!())? + .unwrap_or_else(|| format!("_{account_id}")); if container_ids.is_empty() { if is_calendar { server - .create_default_calendar(access_token, account_id) + .create_default_calendar(access_token, account_id, &name) .await?; } else { server - .create_default_addressbook(access_token, account_id) + .create_default_addressbook(access_token, account_id, &name) .await?; } last_change_id = server @@ -84,13 +90,6 @@ pub(super) async fn build_calcard_resources( .caused_by(trc::location!())? .unwrap_or_default(); - let name = server - .store() - .get_principal_name(account_id) - .await - .caused_by(trc::location!())? - .unwrap_or_else(|| format!("_{account_id}")); - let mut cache = DavResources { base_path: format!( "{}/{}/", diff --git a/crates/groupware/src/cache/mod.rs b/crates/groupware/src/cache/mod.rs index a17bcf3f..1ae217bc 100644 --- a/crates/groupware/src/cache/mod.rs +++ b/crates/groupware/src/cache/mod.rs @@ -44,18 +44,21 @@ pub trait GroupwareCache: Sync + Send { &self, access_token: &AccessToken, account_id: u32, + account_name: &str, ) -> impl Future>> + Send; fn create_default_calendar( &self, access_token: &AccessToken, account_id: u32, + account_name: &str, ) -> impl Future>> + Send; fn get_or_create_default_calendar( &self, access_token: &AccessToken, account_id: u32, + account_name: &str, ) -> impl Future>> + Send; fn cached_dav_resources( @@ -337,6 +340,7 @@ impl GroupwareCache for Server { &self, access_token: &AccessToken, account_id: u32, + account_name: &str, ) -> trc::Result> { if let Some(name) = &self.core.groupware.default_addressbook_name { let mut batch = BatchBuilder::new(); @@ -346,7 +350,12 @@ impl GroupwareCache for Server { .await?; AddressBook { name: name.clone(), - display_name: self.core.groupware.default_addressbook_display_name.clone(), + display_name: self + .core + .groupware + .default_addressbook_display_name + .as_ref() + .map(|display| format!("{display} ({account_name})")), is_default: true, ..Default::default() } @@ -362,6 +371,7 @@ impl GroupwareCache for Server { &self, access_token: &AccessToken, account_id: u32, + account_name: &str, ) -> trc::Result> { if let Some(name) = &self.core.groupware.default_calendar_name { let mut batch = BatchBuilder::new(); @@ -377,8 +387,11 @@ impl GroupwareCache for Server { .core .groupware .default_calendar_display_name - .clone() - .unwrap_or_else(|| name.clone()), + .as_ref() + .map_or_else( + || name.clone(), + |display| format!("{display} ({account_name})",), + ), ..Default::default() }], ..Default::default() @@ -395,13 +408,17 @@ impl GroupwareCache for Server { &self, access_token: &AccessToken, account_id: u32, + account_name: &str, ) -> trc::Result> { match self .get_document_ids(account_id, Collection::Calendar) .await { Ok(Some(ids)) if !ids.is_empty() => Ok(ids.iter().next()), - _ => self.create_default_calendar(access_token, account_id).await, + _ => { + self.create_default_calendar(access_token, account_id, account_name) + .await + } } } diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs index 5422d1c9..63fe3aa2 100644 --- a/crates/groupware/src/calendar/itip.rs +++ b/crates/groupware/src/calendar/itip.rs @@ -249,7 +249,7 @@ impl ItipIngest for Server { // Obtain parent calendar let Some(parent_id) = self - .get_or_create_default_calendar(access_token, account_id) + .get_or_create_default_calendar(access_token, account_id, &access_token.name) .await .caused_by(trc::location!())? else { diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 2b5bdfb6..772f0070 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -62,48 +62,55 @@ pub mod prop; pub mod put_get; pub mod sync; -#[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; - +#[test] +fn webdav_tests() { //test_build_itip_templates(&handle.server).await; - basic::test(&handle).await; - put_get::test(&handle).await; - mkcol::test(&handle).await; - copy_move::test(&handle).await; - prop::test(&handle).await; - multiget::test(&handle).await; - sync::test(&handle).await; - lock::test(&handle).await; - principals::test(&handle).await; - acl::test(&handle).await; - card_query::test(&handle).await; - cal_query::test(&handle).await; - cal_alarm::test(&handle).await; - cal_itip::test(); - cal_scheduling::test(&handle).await; + tokio::runtime::Builder::new_multi_thread() + .thread_stack_size(8 * 1024 * 1024) // 8MB stack + .enable_all() + .build() + .unwrap() + .block_on(async { + // 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; - // Print elapsed time - let elapsed = start_time.elapsed(); - println!( - "Elapsed: {}.{:03}s", - elapsed.as_secs(), - elapsed.subsec_millis() - ); + basic::test(&handle).await; + put_get::test(&handle).await; + mkcol::test(&handle).await; + copy_move::test(&handle).await; + prop::test(&handle).await; + multiget::test(&handle).await; + sync::test(&handle).await; + lock::test(&handle).await; + principals::test(&handle).await; + acl::test(&handle).await; + card_query::test(&handle).await; + cal_query::test(&handle).await; + cal_alarm::test(&handle).await; + cal_itip::test(); + cal_scheduling::test(&handle).await; - // Remove test data - if delete { - handle.temp_dir.delete(); - } + // 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(); + } + }); } #[allow(dead_code)] @@ -1160,6 +1167,9 @@ minimum-interval = "1s" [calendar.scheduling.inbound] auto-add = true +[dav.collection] +assisted-discovery = false + [store."auth"] type = "sqlite" path = "{TMP}/auth.db"