From 55ecbdcd4104a52a5496f88cc36e945e3885d8d7 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:22:23 +0100 Subject: [PATCH] carddav: Implement addressbook-query --- crates/carddav/Cargo.toml | 2 +- .../report/addressbook_query/elements.rs | 77 +++++++++++ .../methods/report/addressbook_query/mod.rs | 19 +++ .../report/addressbook_query/prop_filter.rs | 75 ++++++++++ .../src/addressbook/methods/report/mod.rs | 130 +++++++++++++++++- crates/ical/src/address_object.rs | 5 + 6 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 crates/carddav/src/addressbook/methods/report/addressbook_query/elements.rs create mode 100644 crates/carddav/src/addressbook/methods/report/addressbook_query/mod.rs create mode 100644 crates/carddav/src/addressbook/methods/report/addressbook_query/prop_filter.rs diff --git a/crates/carddav/Cargo.toml b/crates/carddav/Cargo.toml index 7fc92eb..8359a12 100644 --- a/crates/carddav/Cargo.toml +++ b/crates/carddav/Cargo.toml @@ -22,7 +22,7 @@ base64.workspace = true serde.workspace = true tokio.workspace = true url.workspace = true -rustical_dav.workspace = true +rustical_dav = { workspace = true, features = ["ical"] } rustical_store.workspace = true chrono.workspace = true rustical_xml.workspace = true diff --git a/crates/carddav/src/addressbook/methods/report/addressbook_query/elements.rs b/crates/carddav/src/addressbook/methods/report/addressbook_query/elements.rs new file mode 100644 index 0000000..bb351bf --- /dev/null +++ b/crates/carddav/src/addressbook/methods/report/addressbook_query/elements.rs @@ -0,0 +1,77 @@ +use crate::{ + address_object::AddressObjectPropWrapperName, + addressbook::methods::report::addressbook_query::PropFilterElement, +}; +use rustical_dav::xml::{PropfindType, TextMatchElement}; +use rustical_ical::{AddressObject, UtcDateTime}; +use rustical_xml::XmlDeserialize; + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +pub struct TimeRangeElement { + #[xml(ty = "attr")] + pub(crate) start: Option, + #[xml(ty = "attr")] + pub(crate) end: Option, +} + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3 +pub struct ParamFilterElement { + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] + pub(crate) is_not_defined: Option<()>, + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] + pub(crate) text_match: Option, + + #[xml(ty = "attr")] + pub(crate) name: String, +} + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +// +// +// +pub struct FilterElement { + #[xml(ty = "attr")] + pub anyof: Option, + #[xml(ty = "attr")] + pub allof: Option, + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)] + pub(crate) prop_filter: Vec, +} + +impl FilterElement { + #[must_use] + pub fn matches(&self, addr_object: &AddressObject) -> bool { + let allof = match (self.allof.is_some(), self.anyof.is_some()) { + (true, false) => true, + (false, _) => false, + (true, true) => panic!("wat"), + }; + let mut results = self + .prop_filter + .iter() + .map(|prop_filter| prop_filter.match_component(addr_object)); + if allof { + results.all(|x| x) + } else { + results.any(|x| x) + } + } +} + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +// +pub struct AddressbookQueryRequest { + #[xml(ty = "untagged")] + pub prop: PropfindType, + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] + pub(crate) filter: FilterElement, +} diff --git a/crates/carddav/src/addressbook/methods/report/addressbook_query/mod.rs b/crates/carddav/src/addressbook/methods/report/addressbook_query/mod.rs new file mode 100644 index 0000000..6e15800 --- /dev/null +++ b/crates/carddav/src/addressbook/methods/report/addressbook_query/mod.rs @@ -0,0 +1,19 @@ +use crate::Error; +mod elements; +mod prop_filter; +pub use elements::*; +#[allow(unused_imports)] +pub use prop_filter::{PropFilterElement, PropFilterable}; +use rustical_ical::AddressObject; +use rustical_store::AddressbookStore; + +pub async fn get_objects_addressbook_query( + addr_query: &AddressbookQueryRequest, + principal: &str, + addressbook_id: &str, + store: &AS, +) -> Result, Error> { + let mut objects = store.get_objects(principal, addressbook_id).await?; + objects.retain(|object| addr_query.filter.matches(object)); + Ok(objects) +} diff --git a/crates/carddav/src/addressbook/methods/report/addressbook_query/prop_filter.rs b/crates/carddav/src/addressbook/methods/report/addressbook_query/prop_filter.rs new file mode 100644 index 0000000..6c947f0 --- /dev/null +++ b/crates/carddav/src/addressbook/methods/report/addressbook_query/prop_filter.rs @@ -0,0 +1,75 @@ +use super::ParamFilterElement; +use ical::{parser::Component, property::Property}; +use rustical_dav::xml::TextMatchElement; +use rustical_ical::AddressObject; +use rustical_xml::XmlDeserialize; + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +// +// +// +// +pub struct PropFilterElement { + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] + pub(crate) is_not_defined: Option<()>, + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)] + pub(crate) text_match: Vec, + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)] + pub(crate) param_filter: Vec, + + #[xml(ty = "attr")] + pub(crate) name: String, + + #[xml(ty = "attr")] + pub anyof: Option, + #[xml(ty = "attr")] + pub allof: Option, +} + +impl PropFilterElement { + pub fn match_component(&self, comp: &impl PropFilterable) -> bool { + let property = comp.get_property(&self.name); + let _property = match (self.is_not_defined.is_some(), property) { + // We are the component that's not supposed to be defined + (true, Some(_)) + // We don't match + | (false, None) => return false, + // We shall not be and indeed we aren't + (true, None) => return true, + (false, Some(property)) => property + }; + + let _allof = match (self.allof.is_some(), self.anyof.is_some()) { + (true, false) => true, + (false, _) => false, + (true, true) => panic!("wat"), + }; + + // TODO: IMPLEMENT + // if let Some(text_match) = &self.text_match + // && !text_match.match_property(property) + // { + // return false; + // } + + // TODO: param-filter + + true + } +} + +pub trait PropFilterable { + fn get_property(&self, name: &str) -> Option<&Property>; +} + +impl PropFilterable for AddressObject { + fn get_property(&self, name: &str) -> Option<&Property> { + self.get_vcard().get_property(name) + } +} diff --git a/crates/carddav/src/addressbook/methods/report/mod.rs b/crates/carddav/src/addressbook/methods/report/mod.rs index feb6aea..5ac0034 100644 --- a/crates/carddav/src/addressbook/methods/report/mod.rs +++ b/crates/carddav/src/addressbook/methods/report/mod.rs @@ -1,6 +1,14 @@ use crate::{ - CardDavPrincipalUri, Error, address_object::AddressObjectPropWrapperName, - addressbook::AddressbookResourceService, + CardDavPrincipalUri, Error, + address_object::{ + AddressObjectPropWrapper, AddressObjectPropWrapperName, resource::AddressObjectResource, + }, + addressbook::{ + AddressbookResourceService, + methods::report::addressbook_query::{ + AddressbookQueryRequest, get_objects_addressbook_query, + }, + }, }; use addressbook_multiget::{AddressbookMultigetRequest, handle_addressbook_multiget}; use axum::{ @@ -8,19 +16,30 @@ use axum::{ extract::{OriginalUri, Path, State}, response::IntoResponse, }; -use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest}; +use http::StatusCode; +use rustical_dav::{ + resource::{PrincipalUri, Resource}, + xml::{ + MultistatusElement, PropfindType, multistatus::ResponseElement, + sync_collection::SyncCollectionRequest, + }, +}; +use rustical_ical::AddressObject; use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal}; use rustical_xml::{XmlDeserialize, XmlDocument}; use sync_collection::handle_sync_collection; use tracing::instrument; mod addressbook_multiget; +mod addressbook_query; mod sync_collection; #[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)] pub(crate) enum ReportRequest { #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] AddressbookMultiget(AddressbookMultigetRequest), + #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] + AddressbookQuery(AddressbookQueryRequest), #[xml(ns = "rustical_dav::namespace::NS_DAV")] SyncCollection(SyncCollectionRequest), } @@ -29,11 +48,49 @@ impl ReportRequest { const fn props(&self) -> &PropfindType { match self { Self::AddressbookMultiget(AddressbookMultigetRequest { prop, .. }) - | Self::SyncCollection(SyncCollectionRequest { prop, .. }) => prop, + | Self::SyncCollection(SyncCollectionRequest { prop, .. }) + | Self::AddressbookQuery(AddressbookQueryRequest { prop, .. }) => prop, } } } +fn objects_response( + objects: Vec, + not_found: Vec, + path: &str, + principal: &str, + puri: &impl PrincipalUri, + user: &Principal, + prop: &PropfindType, +) -> Result, Error> { + let mut responses = Vec::new(); + for object in objects { + let path = format!("{}/{}.vcf", path, object.get_id()); + responses.push( + AddressObjectResource { + object, + principal: principal.to_owned(), + } + .propfind(&path, prop, None, puri, user)?, + ); + } + + let not_found_responses = not_found + .into_iter() + .map(|path| ResponseElement { + href: path, + status: Some(StatusCode::NOT_FOUND), + ..Default::default() + }) + .collect(); + + Ok(MultistatusElement { + responses, + member_responses: not_found_responses, + ..Default::default() + }) +} + #[instrument(skip(addr_store))] pub async fn route_report_addressbook( Path((principal, addressbook_id)): Path<(String, String)>, @@ -75,13 +132,34 @@ pub async fn route_report_addressbook { + let objects = get_objects_addressbook_query( + addr_query, + &principal, + &addressbook_id, + addr_store.as_ref(), + ) + .await?; + objects_response( + objects, + vec![], + uri.path(), + &principal, + &puri, + &user, + &addr_query.prop, + )? + } }) } #[cfg(test)] mod tests { use super::*; - use crate::address_object::AddressObjectPropName; + use crate::{ + address_object::AddressObjectPropName, + addressbook::methods::report::addressbook_query::{FilterElement, PropFilterElement}, + }; use rustical_dav::xml::{PropElement, sync_collection::SyncLevel}; #[test] @@ -144,4 +222,46 @@ mod tests { }) ); } + + #[test] + fn test_xml_addressbook_query() { + let report_request = ReportRequest::parse_str( + r#" + + + + + + + + + + "#, + ) + .unwrap(); + + assert_eq!( + report_request, + ReportRequest::AddressbookQuery(AddressbookQueryRequest { + prop: rustical_dav::xml::PropfindType::Prop(PropElement( + vec![AddressObjectPropWrapperName::AddressObject( + AddressObjectPropName::Getetag + ),], + vec![] + )), + filter: FilterElement { + anyof: None, + allof: None, + prop_filter: vec![PropFilterElement { + name: "FN".to_owned(), + is_not_defined: None, + text_match: vec![], + param_filter: vec![], + allof: None, + anyof: None + }] + } + }) + ); + } } diff --git a/crates/ical/src/address_object.rs b/crates/ical/src/address_object.rs index 09f73d9..9d6f2cc 100644 --- a/crates/ical/src/address_object.rs +++ b/crates/ical/src/address_object.rs @@ -179,4 +179,9 @@ END:VCALENDAR", } Ok(out) } + + #[must_use] + pub fn get_vcard(&self) -> &VcardContact { + &self.vcard + } }