Assisted CalDAV/CardDAV shared resource discovery (closes #1691)

This commit is contained in:
mdecimus 2025-06-24 13:28:29 +02:00
parent 020bcaef8b
commit 01f7c8f56d
10 changed files with 772 additions and 528 deletions

View file

@ -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::<Option<usize>>("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::<Duration>("dav.lock.max-timeout")
.map(|d| d.as_secs())

View file

@ -75,6 +75,10 @@ pub(crate) enum DavQueryResource<'x> {
parent_collection: Collection,
items: Vec<PropFindItem>,
},
Discovery {
parent_collection: Collection,
account_ids: Vec<u32>,
},
#[default]
None,
}
@ -299,6 +303,29 @@ impl<'x> DavQuery<'x> {
}
}
pub fn discovery(
propfind: PropFind,
account_ids: Vec<u32>,
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
}

File diff suppressed because it is too large Load diff

View file

@ -178,11 +178,11 @@ impl OwnedUri<'_> {
}
}
impl<A, R> UriResource<A, R> {
/*impl<A, R> UriResource<A, R> {
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> {

View file

@ -3,6 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
#![warn(clippy::large_futures)]
pub mod calendar;
pub mod card;

View file

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

View file

@ -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!(
"{}/{}/",

View file

@ -44,18 +44,21 @@ pub trait GroupwareCache: Sync + Send {
&self,
access_token: &AccessToken,
account_id: u32,
account_name: &str,
) -> impl Future<Output = trc::Result<Option<u32>>> + Send;
fn create_default_calendar(
&self,
access_token: &AccessToken,
account_id: u32,
account_name: &str,
) -> impl Future<Output = trc::Result<Option<u32>>> + Send;
fn get_or_create_default_calendar(
&self,
access_token: &AccessToken,
account_id: u32,
account_name: &str,
) -> impl Future<Output = trc::Result<Option<u32>>> + 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<Option<u32>> {
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<Option<u32>> {
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<Option<u32>> {
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
}
}
}

View file

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

View file

@ -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=<store_type> 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=<store_type> 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"