Compare commits

..

12 Commits

Author SHA1 Message Date
Lennart
cc384b6124 Merge pull request #97 from lennart-k/feature/sharing
Fix issues with group collections
2025-07-18 14:14:23 +02:00
Lennart
b6d1899636 carddav: Add full addressbook-home-set 2025-07-18 14:13:34 +02:00
Lennart
81f1767efa docs: Update client documentation for CalDAV 2025-07-18 14:13:11 +02:00
Lennart K
54eb9ddfcc docs: Update notes for Apple Calendar 2025-07-18 12:24:28 +02:00
Lennart K
60a0f16557 frontend: Update Apple profile for caldav-compat 2025-07-18 12:18:55 +02:00
Lennart K
e4f188d299 Update documentation for simplified calendar home set 2025-07-18 12:18:40 +02:00
Lennart K
69163404a1 caldav: Add endpoint with simplified calendar-home-set 2025-07-18 12:18:27 +02:00
Lennart K
0b7cfea79c clippy appeasement 2025-07-18 11:29:03 +02:00
Lennart
455b4c405f version 0.4.13 2025-07-10 21:39:28 +02:00
Lennart
2774d092ac propfind: Implement <include/>
Implements #95
2025-07-10 15:45:54 +02:00
Lennart
32b616fd75 xml serialize_to_string: Enable indentation 2025-07-10 15:45:07 +02:00
Lennart K
b02f7c427a minor refactoring 2025-07-10 10:51:59 +02:00
29 changed files with 180 additions and 110 deletions

22
Cargo.lock generated
View File

@@ -2999,7 +2999,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -3042,7 +3042,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait", "async-trait",
@@ -3080,7 +3080,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3112,7 +3112,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3137,7 +3137,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3163,7 +3163,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -3196,7 +3196,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -3214,7 +3214,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3229,7 +3229,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3263,7 +3263,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3284,7 +3284,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.4.12" version = "0.4.13"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.12", "thiserror 2.0.12",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.4.12" version = "0.4.13"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical" repository = "https://github.com/lennart-k/rustical"

View File

@@ -67,7 +67,7 @@ fn objects_response(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }

View File

@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<C: CalendarStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, &sync_collection.prop, puri, user)?, .propfind(&path, &sync_collection.prop, None, puri, user)?,
); );
} }

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

@@ -69,7 +69,6 @@ impl Resource for CalendarObjectResource {
} }
fn get_displayname(&self) -> Option<&str> { fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None None
} }

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

@@ -47,7 +47,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap()); hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
let filename = format!("{}_{}.vcf", principal, addressbook_id); let filename = format!("{principal}_{addressbook_id}.vcf");
let filename = utf8_percent_encode(&filename, CONTROLS); let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert( hdrs.insert(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,

View File

@@ -88,15 +88,8 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
} }
} }
match addr_store.insert_addressbook(addressbook).await { addr_store.insert_addressbook(addressbook).await?;
// TODO: The spec says we should return a mkcol-response. Ok(StatusCode::CREATED.into_response())
// However, it works without one but breaks on iPadOS when using an empty one :)
Ok(()) => Ok(StatusCode::CREATED.into_response()),
Err(err) => {
dbg!(err.to_string());
Err(err.into())
}
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -81,7 +81,7 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }

View File

@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, &sync_collection.prop, puri, user)?, .propfind(&path, &sync_collection.prop, None, puri, user)?,
); );
} }

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,6 +64,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else { } else {
PropfindElement { PropfindElement {
prop: PropfindType::Allprop, prop: PropfindType::Allprop,
include: None,
} }
}; };
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> = let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
@@ -72,6 +73,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else { } else {
PropfindElement { PropfindElement {
prop: PropfindType::Allprop, prop: PropfindType::Allprop,
include: None,
} }
}; };
@@ -82,13 +84,20 @@ pub(crate) async fn route_propfind<R: ResourceService>(
member_responses.push(member.propfind( member_responses.push(member.propfind(
&format!("{}/{}", path.trim_end_matches('/'), member.get_name()), &format!("{}/{}", path.trim_end_matches('/'), member.get_name()),
&propfind_member.prop, &propfind_member.prop,
propfind_member.include.as_ref(),
puri, puri,
principal, principal,
)?); )?);
} }
} }
let response = resource.propfind(path, &propfind_self.prop, puri, principal)?; let response = resource.propfind(
path,
&propfind_self.prop,
propfind_self.include.as_ref(),
puri,
principal,
)?;
Ok(MultistatusElement { Ok(MultistatusElement {
responses: vec![response], responses: vec![response],

View File

@@ -106,6 +106,7 @@ pub trait Resource: Clone + Send + 'static {
&self, &self,
path: &str, path: &str,
prop: &PropfindType<<Self::Prop as PropName>::Names>, prop: &PropfindType<<Self::Prop as PropName>::Names>,
include: Option<&PropElement<<Self::Prop as PropName>::Names>>,
principal_uri: &impl PrincipalUri, principal_uri: &impl PrincipalUri,
principal: &Self::Principal, principal: &Self::Principal,
) -> Result<ResponseElement<Self::Prop>, Self::Error> { ) -> Result<ResponseElement<Self::Prop>, Self::Error> {
@@ -115,36 +116,40 @@ pub trait Resource: Clone + Send + 'static {
path.push('/'); path.push('/');
} }
// TODO: Support include element let (mut props, mut invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) =
let (props, invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) = match prop match prop {
{ PropfindType::Propname => {
PropfindType::Propname => { let props = Self::list_props()
let props = Self::list_props() .into_iter()
.into_iter() .map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string()))
.map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string())) .collect_vec();
.collect_vec();
return Ok(ResponseElement { return Ok(ResponseElement {
href: path.to_owned(), href: path.to_owned(),
propstat: vec![PropstatWrapper::TagList(PropstatElement { propstat: vec![PropstatWrapper::TagList(PropstatElement {
prop: TagList::from(props), prop: TagList::from(props),
status: StatusCode::OK, status: StatusCode::OK,
})], })],
..Default::default() ..Default::default()
}); });
} }
PropfindType::Allprop => ( PropfindType::Allprop => (
Self::list_props() Self::list_props()
.iter() .iter()
.map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap()) .map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap())
.collect(), .collect(),
vec![], vec![],
), ),
PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => ( PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => (
valid_tags.iter().cloned().collect(), valid_tags.iter().cloned().collect(),
invalid_tags.to_owned(), invalid_tags.to_owned(),
), ),
}; };
if let Some(PropElement(valid_tags, invalid_tags)) = include {
props.extend(valid_tags.clone());
invalid_props.extend(invalid_tags.to_owned());
}
let prop_responses = props let prop_responses = props
.into_iter() .into_iter()

View File

@@ -11,10 +11,11 @@ use rustical_xml::XmlRootTag;
pub struct PropfindElement<PN: XmlDeserialize> { pub struct PropfindElement<PN: XmlDeserialize> {
#[xml(ty = "untagged")] #[xml(ty = "untagged")]
pub prop: PropfindType<PN>, pub prop: PropfindType<PN>,
#[xml(ns = "crate::namespace::NS_DAV")]
pub include: Option<PropElement<PN>>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
// pub struct PropElement<PN: XmlDeserialize = Propname>(#[xml(ty = "untagged", flatten)] pub Vec<PN>);
pub struct PropElement<PN: XmlDeserialize>( pub struct PropElement<PN: XmlDeserialize>(
// valid // valid
pub Vec<PN>, pub Vec<PN>,

View File

@@ -33,7 +33,13 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><resourcetype><displayname xmlns=\"DAV:\"/><calendar-color xmlns=\"http://calendarserver.org/ns/\"/></resourcetype></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<resourcetype>
<displayname xmlns="DAV:"/>
<calendar-color xmlns="http://calendarserver.org/ns/"/>
</resourcetype>
</document>"#
) )
} }
} }

View File

@@ -80,7 +80,6 @@ export class CreateAddressbookForm extends LitElement {
alert("Empty displayname") alert("Empty displayname")
return return
} }
// TODO: Escape user input: There's not really a security risk here but would be nicer
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, { await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, {
data: ` data: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav"> <mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">

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,7 +13,7 @@ pub enum PrincipalType {
Resource, Resource,
Room, Room,
Unknown, Unknown,
// TODO: X-Name, IANA-token // X-Name, IANA-token
} }
impl TryFrom<&str> for PrincipalType { impl TryFrom<&str> for PrincipalType {

View File

@@ -43,7 +43,7 @@ pub trait XmlSerializeRoot {
fn serialize_to_string(&self) -> std::io::Result<String> { fn serialize_to_string(&self) -> std::io::Result<String> {
let mut buf: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into(); let mut buf: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new(&mut buf); let mut writer = quick_xml::Writer::new_with_indent(&mut buf, b' ', 4);
self.serialize_root(&mut writer)?; self.serialize_root(&mut writer)?;
Ok(String::from_utf8_lossy(&buf).to_string()) Ok(String::from_utf8_lossy(&buf).to_string())
} }

View File

@@ -22,6 +22,11 @@ fn test_struct_value_tagged() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<propfind><prop><test>asd</test></prop></propfind>" r#"<?xml version="1.0" encoding="utf-8"?>
<propfind>
<prop>
<test>asd</test>
</prop>
</propfind>"#
); );
} }

View File

@@ -71,7 +71,11 @@ fn test_struct_value_tagged() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><href>okay</href><num>123</num></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>okay</href>
<num>123</num>
</document>"#
); );
} }
@@ -91,7 +95,8 @@ fn test_struct_value_untagged() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document>okays</document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>okays</document>"#
); );
} }
@@ -111,7 +116,11 @@ fn test_struct_vec() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><href>okay</href><href>wow</href></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>okay</href>
<href>wow</href>
</document>"#
); );
} }
@@ -141,7 +150,10 @@ fn test_struct_serialize_with() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><href>OKAY</href></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>OKAY</href>
</document>"#
); );
} }

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(),