Compare commits

..

3 Commits

Author SHA1 Message Date
Lennart
b65cca9d17 version 0.11.6 2025-12-27 14:23:56 +01:00
Lennart
55ecbdcd41 carddav: Implement addressbook-query 2025-12-27 14:22:23 +01:00
Lennart
89d3d3b7a4 caldav: Outsource text-match to rustical_dav 2025-12-27 13:45:26 +01:00
17 changed files with 345 additions and 59 deletions

25
Cargo.lock generated
View File

@@ -3261,7 +3261,7 @@ dependencies = [
[[package]]
name = "rustical"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"anyhow",
"argon2",
@@ -3306,7 +3306,7 @@ dependencies = [
[[package]]
name = "rustical_caldav"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"async-std",
"async-trait",
@@ -3348,7 +3348,7 @@ dependencies = [
[[package]]
name = "rustical_carddav"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3381,7 +3381,7 @@ dependencies = [
[[package]]
name = "rustical_dav"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3390,6 +3390,7 @@ dependencies = [
"futures-util",
"headers",
"http",
"ical",
"itertools 0.14.0",
"log",
"matchit 0.9.0",
@@ -3406,7 +3407,7 @@ dependencies = [
[[package]]
name = "rustical_dav_push"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3431,7 +3432,7 @@ dependencies = [
[[package]]
name = "rustical_frontend"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"askama",
"askama_web",
@@ -3467,7 +3468,7 @@ dependencies = [
[[package]]
name = "rustical_ical"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"axum",
"chrono",
@@ -3484,7 +3485,7 @@ dependencies = [
[[package]]
name = "rustical_oidc"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"async-trait",
"axum",
@@ -3500,7 +3501,7 @@ dependencies = [
[[package]]
name = "rustical_store"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"anyhow",
"async-trait",
@@ -3533,7 +3534,7 @@ dependencies = [
[[package]]
name = "rustical_store_sqlite"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"async-trait",
"chrono",
@@ -3556,7 +3557,7 @@ dependencies = [
[[package]]
name = "rustical_xml"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"quick-xml",
"thiserror 2.0.17",
@@ -5378,7 +5379,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "xml_derive"
version = "0.11.5"
version = "0.11.6"
dependencies = [
"darling 0.23.0",
"heck",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.11.5"
version = "0.11.6"
rust-version = "1.91"
edition = "2024"
description = "A CalDAV server"
@@ -36,7 +36,7 @@ opentelemetry = [
debug = 0
[workspace.dependencies]
rustical_dav = { path = "./crates/dav/" }
rustical_dav = { path = "./crates/dav/", features = ["ical"] }
rustical_dav_push = { path = "./crates/dav_push/" }
rustical_store = { path = "./crates/store/" }
rustical_store_sqlite = { path = "./crates/store_sqlite/" }

View File

@@ -29,7 +29,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
chrono-tz.workspace = true

View File

@@ -137,13 +137,11 @@ impl CompFilterable for CalendarObjectComponent {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use rustical_dav::xml::{NegateCondition, TextCollation, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{
CompFilterable, TextMatchElement, TimeRangeElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
CompFilterElement, CompFilterable, PropFilterElement, TimeRangeElement,
};
const ICS: &str = r"BEGIN:VCALENDAR

View File

@@ -1,11 +1,6 @@
use crate::{
calendar::methods::report::calendar_query::{
TextMatchElement,
comp_filter::{CompFilterElement, CompFilterable},
},
calendar_object::CalendarObjectPropWrapperName,
};
use rustical_dav::xml::PropfindType;
use super::comp_filter::{CompFilterElement, CompFilterable};
use crate::calendar_object::CalendarObjectPropWrapperName;
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_store::calendar_store::CalendarQuery;
use rustical_xml::XmlDeserialize;

View File

@@ -5,14 +5,11 @@ use rustical_store::CalendarStore;
mod comp_filter;
mod elements;
mod prop_filter;
pub mod text_match;
#[allow(unused_imports)]
pub use comp_filter::{CompFilterElement, CompFilterable};
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable};
#[allow(unused_imports)]
pub use text_match::TextMatchElement;
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
@@ -31,21 +28,16 @@ pub async fn get_objects_calendar_query<C: CalendarStore>(
#[cfg(test)]
mod tests {
use rustical_dav::xml::PropElement;
use rustical_xml::XmlDocument;
use super::{
CalendarQueryRequest, FilterElement, ParamFilterElement, comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
};
use crate::{
calendar::methods::report::{
ReportRequest,
calendar_query::{
CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
},
},
calendar::methods::report::ReportRequest,
calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName},
};
use rustical_dav::xml::{NegateCondition, PropElement, TextCollation, TextMatchElement};
use rustical_xml::XmlDocument;
#[test]
fn calendar_query_7_8_7() {

View File

@@ -1,5 +1,4 @@
use std::collections::HashMap;
use super::{ParamFilterElement, TimeRangeElement};
use ical::{
generator::{IcalCalendar, IcalEvent},
parser::{
@@ -8,12 +7,10 @@ use ical::{
},
property::Property,
};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectComponent, UtcDateTime};
use rustical_xml::XmlDeserialize;
use crate::calendar::methods::report::calendar_query::{
ParamFilterElement, TextMatchElement, TimeRangeElement,
};
use std::collections::HashMap;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]

View File

@@ -1,10 +1,9 @@
use derive_more::derive::{From, Into};
use rustical_dav::xml::TextCollation;
use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
use crate::calendar::methods::report::calendar_query::text_match::TextCollation;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)]
pub struct SupportedCalendarComponent {
#[xml(ty = "attr")]

View File

@@ -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

View File

@@ -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<UtcDateTime>,
#[xml(ty = "attr")]
pub(crate) end: Option<UtcDateTime>,
}
#[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<TextMatchElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// <!ELEMENT filter (prop-filter*)>
// <!ATTLIST filter test (anyof | allof) "anyof">
// <!-- test value:
// anyof logical OR for prop-filter matches
// allof logical AND for prop-filter matches -->
pub struct FilterElement {
#[xml(ty = "attr")]
pub anyof: Option<String>,
#[xml(ty = "attr")]
pub allof: Option<String>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) prop_filter: Vec<PropFilterElement>,
}
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)]
// <!ELEMENT addressbook-query ((DAV:allprop |
// DAV:propname |
// DAV:prop)?, filter, limit?)>
pub struct AddressbookQueryRequest {
#[xml(ty = "untagged")]
pub prop: PropfindType<AddressObjectPropWrapperName>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) filter: FilterElement,
}

View File

@@ -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<AS: AddressbookStore>(
addr_query: &AddressbookQueryRequest,
principal: &str,
addressbook_id: &str,
store: &AS,
) -> Result<Vec<AddressObject>, Error> {
let mut objects = store.get_objects(principal, addressbook_id).await?;
objects.retain(|object| addr_query.filter.matches(object));
Ok(objects)
}

View File

@@ -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)]
// <!ELEMENT prop-filter (is-not-defined |
// (text-match*, param-filter*))>
//
// <!ATTLIST prop-filter name CDATA #REQUIRED
// test (anyof | allof) "anyof">
// <!-- name value: a vCard property name (e.g., "NICKNAME")
// test value:
// anyof logical OR for text-match/param-filter matches
// allof logical AND for text-match/param-filter matches -->
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<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
#[xml(ty = "attr")]
pub anyof: Option<String>,
#[xml(ty = "attr")]
pub allof: Option<String>,
}
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)
}
}

View File

@@ -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<AddressObjectPropWrapperName>),
}
@@ -29,11 +48,49 @@ impl ReportRequest {
const fn props(&self) -> &PropfindType<AddressObjectPropWrapperName> {
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<AddressObject>,
not_found: Vec<String>,
path: &str,
principal: &str,
puri: &impl PrincipalUri,
user: &Principal,
prop: &PropfindType<AddressObjectPropWrapperName>,
) -> Result<MultistatusElement<AddressObjectPropWrapper, String>, 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<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
@@ -75,13 +132,34 @@ pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore
)
.await?
}
ReportRequest::AddressbookQuery(addr_query) => {
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#"
<?xml version="1.0" encoding="utf-8"?>
<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:d="DAV:">
<d:prop>
<d:getetag/>
</d:prop>
<card:filter>
<card:prop-filter name="FN"/>
</card:filter>
</card:addressbook-query>
"#,
)
.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
}]
}
})
);
}
}

View File

@@ -28,3 +28,7 @@ headers.workspace = true
strum.workspace = true
matchit.workspace = true
matchit-serde.workspace = true
ical = { workspace = true, optional = true }
[features]
ical = ["dep:ical"]

View File

@@ -15,3 +15,7 @@ mod report_set;
pub use report_set::SupportedReportSet;
mod group;
pub use group::*;
#[cfg(feature = "ical")]
mod text_match;
#[cfg(feature = "ical")]
pub use text_match::*;

View File

@@ -64,9 +64,9 @@ pub struct TextMatchElement {
#[xml(ty = "attr", default = "Default::default")]
pub collation: TextCollation,
#[xml(ty = "attr", default = "Default::default")]
pub(crate) negate_condition: NegateCondition,
pub negate_condition: NegateCondition,
#[xml(ty = "text")]
pub(crate) needle: String,
pub needle: String,
}
impl TextMatchElement {
@@ -90,7 +90,7 @@ impl TextMatchElement {
#[cfg(test)]
mod tests {
use crate::calendar::methods::report::calendar_query::text_match::TextCollation;
use super::TextCollation;
#[test]
fn test_collation() {

View File

@@ -179,4 +179,9 @@ END:VCALENDAR",
}
Ok(out)
}
#[must_use]
pub fn get_vcard(&self) -> &VcardContact {
&self.vcard
}
}