diff --git a/crates/caldav/src/calendar/resource.rs b/crates/caldav/src/calendar/resource.rs index 6a13ac6..aae472c 100644 --- a/crates/caldav/src/calendar/resource.rs +++ b/crates/caldav/src/calendar/resource.rs @@ -201,10 +201,7 @@ impl Resource for CalendarResource { if let Some(tzid) = &timezone_id { // Validate timezone id chrono_tz::Tz::from_str(tzid).map_err(|_| { - rustical_dav::Error::BadRequest(format!( - "Invalid timezone-id: {}", - tzid - )) + rustical_dav::Error::BadRequest(format!("Invalid timezone-id: {tzid}")) })?; // TODO: Ensure that timezone is also updated (For now hope that clients play nice) } diff --git a/crates/caldav/src/lib.rs b/crates/caldav/src/lib.rs index 3198808..72e5071 100644 --- a/crates/caldav/src/lib.rs +++ b/crates/caldav/src/lib.rs @@ -1,5 +1,3 @@ -use axum::response::Redirect; -use axum::routing::any; use axum::{Extension, Router}; use derive_more::Constructor; use principal::PrincipalResourceService; @@ -14,7 +12,6 @@ pub mod calendar; pub mod calendar_object; pub mod error; pub mod principal; - pub use error::Error; #[derive(Debug, Clone, Constructor)] @@ -34,23 +31,18 @@ pub fn caldav_router, store: Arc, subscription_store: Arc, + simplified_home_set: bool, ) -> Router { - let principal_service = PrincipalResourceService { - auth_provider: auth_provider.clone(), - sub_store: subscription_store.clone(), - cal_store: store.clone(), - }; - - Router::new() - .nest( - prefix, - RootResourceService::<_, Principal, CalDavPrincipalUri>::new(principal_service.clone()) - .axum_router() - .layer(AuthenticationLayer::new(auth_provider)) - .layer(Extension(CalDavPrincipalUri(prefix))), - ) - .route( - "/.well-known/caldav", - any(async || Redirect::permanent(prefix)), - ) + Router::new().nest( + prefix, + RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService { + auth_provider: auth_provider.clone(), + sub_store: subscription_store.clone(), + cal_store: store.clone(), + simplified_home_set, + }) + .axum_router() + .layer(AuthenticationLayer::new(auth_provider)) + .layer(Extension(CalDavPrincipalUri(prefix))), + ) } diff --git a/crates/caldav/src/principal/mod.rs b/crates/caldav/src/principal/mod.rs index 434411c..33486db 100644 --- a/crates/caldav/src/principal/mod.rs +++ b/crates/caldav/src/principal/mod.rs @@ -18,6 +18,8 @@ pub mod tests; pub struct PrincipalResource { principal: Principal, members: Vec, + // If true only return the principal as the calendar home set, otherwise also groups + simplified_home_set: bool, } impl ResourceName for PrincipalResource { @@ -64,9 +66,17 @@ impl Resource for PrincipalResource { PrincipalPropName::PrincipalUrl => { PrincipalProp::PrincipalUrl(principal_url.into()) } - PrincipalPropName::CalendarHomeSet => { - PrincipalProp::CalendarHomeSet(principal_url.into()) - } + PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet( + 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 => { PrincipalProp::CalendarUserAddressSet(principal_url.into()) } diff --git a/crates/caldav/src/principal/prop.rs b/crates/caldav/src/principal/prop.rs index 6bd16a5..2073cff 100644 --- a/crates/caldav/src/principal/prop.rs +++ b/crates/caldav/src/principal/prop.rs @@ -31,9 +31,12 @@ pub enum PrincipalProp { // CalDAV (RFC 4791) #[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); + #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] pub enum PrincipalPropWrapper { diff --git a/crates/caldav/src/principal/service.rs b/crates/caldav/src/principal/service.rs index f938ffb..0cc5596 100644 --- a/crates/caldav/src/principal/service.rs +++ b/crates/caldav/src/principal/service.rs @@ -18,6 +18,8 @@ pub struct PrincipalResourceService< pub(crate) auth_provider: Arc, pub(crate) sub_store: Arc, pub(crate) cal_store: Arc, + // If true only return the principal as the calendar home set, otherwise also groups + pub(crate) simplified_home_set: bool, } impl Clone @@ -28,6 +30,7 @@ impl Clone auth_provider: self.auth_provider.clone(), sub_store: self.sub_store.clone(), cal_store: self.cal_store.clone(), + simplified_home_set: self.simplified_home_set, } } } @@ -58,6 +61,7 @@ impl Resour Ok(PrincipalResource { members: self.auth_provider.list_members(&user.id).await?, principal: user, + simplified_home_set: self.simplified_home_set, }) } diff --git a/crates/caldav/src/principal/tests.rs b/crates/caldav/src/principal/tests.rs index c5f68a8..ccdd41a 100644 --- a/crates/caldav/src/principal/tests.rs +++ b/crates/caldav/src/principal/tests.rs @@ -27,6 +27,7 @@ async fn test_principal_resource( cal_store: Arc::new(cal_store.await), sub_store: Arc::new(sub_store.await), auth_provider: Arc::new(auth_provider.await), + simplified_home_set: false, }; assert!(matches!( diff --git a/crates/carddav/src/principal/mod.rs b/crates/carddav/src/principal/mod.rs index 0f0fdf2..39daa7f 100644 --- a/crates/carddav/src/principal/mod.rs +++ b/crates/carddav/src/principal/mod.rs @@ -53,7 +53,13 @@ impl Resource for PrincipalResource { PrincipalPropWrapper::Principal(match prop { PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href), 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::GroupMembership => { diff --git a/crates/carddav/src/principal/prop.rs b/crates/carddav/src/principal/prop.rs index b1d83e5..ebe68e8 100644 --- a/crates/carddav/src/principal/prop.rs +++ b/crates/carddav/src/principal/prop.rs @@ -22,11 +22,14 @@ pub enum PrincipalProp { // CardDAV (RFC 6352) #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] - AddressbookHomeSet(HrefElement), + AddressbookHomeSet(AddressbookHomeSet), #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] PrincipalAddress(Option), } +#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)] +pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec); + #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] pub enum PrincipalPropWrapper { diff --git a/crates/frontend/src/routes/app_token.rs b/crates/frontend/src/routes/app_token.rs index 7fbec2b..248f9ae 100644 --- a/crates/frontend/src/routes/app_token.rs +++ b/crates/frontend/src/routes/app_token.rs @@ -64,7 +64,7 @@ pub async fn route_post_app_token( token_name: name, account_description: format!("{}@{}", &user.id, &hostname), 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}"), user: user.id.to_owned(), token, @@ -79,13 +79,12 @@ pub async fn route_post_app_token( hdrs.typed_insert( 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); hdrs.insert( header::CONTENT_DISPOSITION, HeaderValue::from_str(&format!( - "attachement; filename*=UTF-8''{} filename={}", - filename, filename + "attachement; filename*=UTF-8''{filename} filename={filename}", )) .unwrap(), ); diff --git a/docs/setup/client.md b/docs/setup/client.md index 64b308c..3aa82c1 100644 --- a/docs/setup/client.md +++ b/docs/setup/client.md @@ -13,6 +13,10 @@ Following resources are available. # Calendar home /caldav/principal// /caldav/principal//_birthdays_ + +# 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. +## `/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/`. + ## DAVx5 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. -**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/`. ## Evolution Set up a collection account in the account settings. - -**Limitation**: Group collections are not discovered. It seems as if currently you have to add each group collection manually. +Evolution correctly uses all calendar homes so group collections work properly. ## 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 ``` -https:///.well-known/caldav +https:///caldav-compat ``` -For goup collections explicitly specify +For group collections explicitly specify ``` -https:///caldav/principal/ +https:///caldav-compat/principal/ ``` diff --git a/src/app.rs b/src/app.rs index 8b2a42d..4fd1c9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,8 +2,8 @@ use crate::config::NextcloudLoginConfig; use axum::Router; use axum::body::Body; use axum::extract::Request; -use axum::response::Response; -use axum::routing::options; +use axum::response::{Redirect, Response}; +use axum::routing::{any, options}; use headers::{HeaderMapExt, UserAgent}; use http::{HeaderValue, StatusCode}; use rustical_caldav::caldav_router; @@ -47,7 +47,19 @@ pub fn make_app( auth_provider.clone(), combined_cal_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( "/carddav", auth_provider.clone(),