Merge pull request #97 from lennart-k/feature/sharing

Fix issues with group collections
This commit is contained in:
Lennart
2025-07-18 14:14:23 +02:00
committed by GitHub
11 changed files with 84 additions and 43 deletions

View File

@@ -201,10 +201,7 @@ impl Resource for CalendarResource {
if let Some(tzid) = &timezone_id { if let Some(tzid) = &timezone_id {
// Validate timezone id // Validate timezone id
chrono_tz::Tz::from_str(tzid).map_err(|_| { chrono_tz::Tz::from_str(tzid).map_err(|_| {
rustical_dav::Error::BadRequest(format!( rustical_dav::Error::BadRequest(format!("Invalid timezone-id: {tzid}"))
"Invalid timezone-id: {}",
tzid
))
})?; })?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice) // TODO: Ensure that timezone is also updated (For now hope that clients play nice)
} }

View File

@@ -1,5 +1,3 @@
use axum::response::Redirect;
use axum::routing::any;
use axum::{Extension, Router}; use axum::{Extension, Router};
use derive_more::Constructor; use derive_more::Constructor;
use principal::PrincipalResourceService; use principal::PrincipalResourceService;
@@ -14,7 +12,6 @@ pub mod calendar;
pub mod calendar_object; pub mod calendar_object;
pub mod error; pub mod error;
pub mod principal; pub mod principal;
pub use error::Error; pub use error::Error;
#[derive(Debug, Clone, Constructor)] #[derive(Debug, Clone, Constructor)]
@@ -34,23 +31,18 @@ pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: Subscripti
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
store: Arc<C>, store: Arc<C>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
simplified_home_set: bool,
) -> Router { ) -> Router {
let principal_service = PrincipalResourceService { Router::new().nest(
auth_provider: auth_provider.clone(), prefix,
sub_store: subscription_store.clone(), RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService {
cal_store: store.clone(), auth_provider: auth_provider.clone(),
}; sub_store: subscription_store.clone(),
cal_store: store.clone(),
Router::new() simplified_home_set,
.nest( })
prefix, .axum_router()
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(principal_service.clone()) .layer(AuthenticationLayer::new(auth_provider))
.axum_router() .layer(Extension(CalDavPrincipalUri(prefix))),
.layer(AuthenticationLayer::new(auth_provider)) )
.layer(Extension(CalDavPrincipalUri(prefix))),
)
.route(
"/.well-known/caldav",
any(async || Redirect::permanent(prefix)),
)
} }

View File

@@ -18,6 +18,8 @@ pub mod tests;
pub struct PrincipalResource { pub struct PrincipalResource {
principal: Principal, principal: Principal,
members: Vec<String>, members: Vec<String>,
// If true only return the principal as the calendar home set, otherwise also groups
simplified_home_set: bool,
} }
impl ResourceName for PrincipalResource { impl ResourceName for PrincipalResource {
@@ -64,9 +66,17 @@ impl Resource for PrincipalResource {
PrincipalPropName::PrincipalUrl => { PrincipalPropName::PrincipalUrl => {
PrincipalProp::PrincipalUrl(principal_url.into()) PrincipalProp::PrincipalUrl(principal_url.into())
} }
PrincipalPropName::CalendarHomeSet => { PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(
PrincipalProp::CalendarHomeSet(principal_url.into()) CalendarHomeSet(if self.simplified_home_set {
} vec![principal_url.into()]
} else {
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect()
}),
),
PrincipalPropName::CalendarUserAddressSet => { PrincipalPropName::CalendarUserAddressSet => {
PrincipalProp::CalendarUserAddressSet(principal_url.into()) PrincipalProp::CalendarUserAddressSet(principal_url.into())
} }

View File

@@ -31,9 +31,12 @@ pub enum PrincipalProp {
// CalDAV (RFC 4791) // CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarHomeSet(HrefElement), CalendarHomeSet(CalendarHomeSet),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {

View File

@@ -18,6 +18,8 @@ pub struct PrincipalResourceService<
pub(crate) auth_provider: Arc<AP>, pub(crate) auth_provider: Arc<AP>,
pub(crate) sub_store: Arc<S>, pub(crate) sub_store: Arc<S>,
pub(crate) cal_store: Arc<CS>, pub(crate) cal_store: Arc<CS>,
// If true only return the principal as the calendar home set, otherwise also groups
pub(crate) simplified_home_set: bool,
} }
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
@@ -28,6 +30,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
auth_provider: self.auth_provider.clone(), auth_provider: self.auth_provider.clone(),
sub_store: self.sub_store.clone(), sub_store: self.sub_store.clone(),
cal_store: self.cal_store.clone(), cal_store: self.cal_store.clone(),
simplified_home_set: self.simplified_home_set,
} }
} }
} }
@@ -58,6 +61,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
Ok(PrincipalResource { Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?, members: self.auth_provider.list_members(&user.id).await?,
principal: user, principal: user,
simplified_home_set: self.simplified_home_set,
}) })
} }

View File

@@ -27,6 +27,7 @@ async fn test_principal_resource(
cal_store: Arc::new(cal_store.await), cal_store: Arc::new(cal_store.await),
sub_store: Arc::new(sub_store.await), sub_store: Arc::new(sub_store.await),
auth_provider: Arc::new(auth_provider.await), auth_provider: Arc::new(auth_provider.await),
simplified_home_set: false,
}; };
assert!(matches!( assert!(matches!(

View File

@@ -53,7 +53,13 @@ impl Resource for PrincipalResource {
PrincipalPropWrapper::Principal(match prop { PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href), PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => { PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(principal_href) PrincipalProp::AddressbookHomeSet(AddressbookHomeSet(
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
} }
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None), PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None),
PrincipalPropName::GroupMembership => { PrincipalPropName::GroupMembership => {

View File

@@ -22,11 +22,14 @@ pub enum PrincipalProp {
// CardDAV (RFC 6352) // CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookHomeSet(HrefElement), AddressbookHomeSet(AddressbookHomeSet),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
PrincipalAddress(Option<HrefElement>), PrincipalAddress(Option<HrefElement>),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {

View File

@@ -64,7 +64,7 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
token_name: name, token_name: name,
account_description: format!("{}@{}", &user.id, &hostname), account_description: format!("{}@{}", &user.id, &hostname),
hostname: hostname.clone(), hostname: hostname.clone(),
caldav_principal_url: format!("https://{hostname}/caldav/principal/{user_id}"), caldav_principal_url: format!("https://{hostname}/caldav-compat/principal/{user_id}"),
carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"), carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"),
user: user.id.to_owned(), user: user.id.to_owned(),
token, token,
@@ -79,13 +79,12 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
hdrs.typed_insert( hdrs.typed_insert(
ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(), ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(),
); );
let filename = format!("rustical-{}.mobileconfig", user_id); let filename = format!("rustical-{user_id}.mobileconfig");
let filename = utf8_percent_encode(&filename, CONTROLS); let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert( hdrs.insert(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!( HeaderValue::from_str(&format!(
"attachement; filename*=UTF-8''{} filename={}", "attachement; filename*=UTF-8''{filename} filename={filename}",
filename, filename
)) ))
.unwrap(), .unwrap(),
); );

View File

@@ -13,6 +13,10 @@ Following resources are available.
# Calendar home # Calendar home
/caldav/principal/<user_id>/<calendar_id> /caldav/principal/<user_id>/<calendar_id>
/caldav/principal/<user_id>/_birthdays_<addressbook_id> /caldav/principal/<user_id>/_birthdays_<addressbook_id>
# CalDAV root
/caldav-compat
/caldav-compat/principal...
``` ```
``` ```
@@ -29,6 +33,14 @@ Following resources are available.
Authenticate with HTTP Basic authentication using your user id and a generated app token. Authenticate with HTTP Basic authentication using your user id and a generated app token.
## `/caldav` vs `/caldav-compat` (relevant for group sharing)
To discover shared calendars the `calendar-home-set` property is used to list all principals the user has access to.
However, some clients don't support `calendar-home-set` containing multiple paths (e.g. Apple Calendar).
As a workaround `/caldav-compat` offers the same endpoints as `/caldav` with the only difference being that it does not return all calendar homes in `calendar-home-set`.
This means that clients under this path will probably not auto-discover group calendars so you can instead add them one-by-one using the principal path `/caldav-compat/principal/<principal_id>`.
## DAVx5 ## DAVx5
You can set up DAVx5 through the Nextcloud login flow. Collections including group collections will automatically be discovered. You can set up DAVx5 through the Nextcloud login flow. Collections including group collections will automatically be discovered.
@@ -37,24 +49,26 @@ You can set up DAVx5 through the Nextcloud login flow. Collections including gro
You can download a configuration profile from the frontend in the app token section. You can download a configuration profile from the frontend in the app token section.
**Limitation**: Group collections are not automatically discovered, for these you need to set up separate CalDAV configurations using the corresponding principal homes (but your own user id). **Note**: Since Apple Calendar does not properly support the `calendar-home-set` property the `/caldav-compat` endpoints should be used.
That also means that Apple Calendar is not able to automatically discover group collections so in that case you'll have to manually add all principals with `/caldav-compat/principal/<principal_id>`.
## Evolution ## Evolution
Set up a collection account in the account settings. Set up a collection account in the account settings.
Evolution correctly uses all calendar homes so group collections work properly.
**Limitation**: Group collections are not discovered. It seems as if currently you have to add each group collection manually.
## Home Assistant CalDAV integration ## Home Assistant CalDAV integration
The underlying library `python-caldav` does not support multiple calendar homes so you should use the `/caldav-compat` endpoints.
As URL specify As URL specify
``` ```
https://<your-host>/.well-known/caldav https://<your-host>/caldav-compat
``` ```
For goup collections explicitly specify For group collections explicitly specify
``` ```
https://<your-host>/caldav/principal/<principal> https://<your-host>/caldav-compat/principal/<principal>
``` ```

View File

@@ -2,8 +2,8 @@ use crate::config::NextcloudLoginConfig;
use axum::Router; use axum::Router;
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use axum::response::Response; use axum::response::{Redirect, Response};
use axum::routing::options; use axum::routing::{any, options};
use headers::{HeaderMapExt, UserAgent}; use headers::{HeaderMapExt, UserAgent};
use http::{HeaderValue, StatusCode}; use http::{HeaderValue, StatusCode};
use rustical_caldav::caldav_router; use rustical_caldav::caldav_router;
@@ -47,7 +47,19 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
auth_provider.clone(), auth_provider.clone(),
combined_cal_store.clone(), combined_cal_store.clone(),
subscription_store.clone(), subscription_store.clone(),
false,
)) ))
.merge(caldav_router(
"/caldav-compat",
auth_provider.clone(),
combined_cal_store.clone(),
subscription_store.clone(),
true,
))
.route(
"/.well-known/caldav",
any(async || Redirect::permanent("/caldav")),
)
.merge(carddav_router( .merge(carddav_router(
"/carddav", "/carddav",
auth_provider.clone(), auth_provider.clone(),