diff --git a/Cargo.lock b/Cargo.lock index 7502491..15c4468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -567,9 +567,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "caldata" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ae67018b0b95d01f20a5f6869810e83969baa94b2ff768de7f90a8a9e38efd" +checksum = "b39dd55be55606179c8437278d2df04a0fcfdc2956edcc432b2d54413e3f1522" dependencies = [ "chrono", "chrono-tz", @@ -580,6 +580,7 @@ dependencies = [ "regex", "rrule", "thiserror 2.0.18", + "vtimezones-rs", ] [[package]] @@ -4952,9 +4953,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vtimezones-rs" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6728de8767c8dea44f41b88115a205ed23adc3302e1b4342be59d922934dae5" +checksum = "cd57da82560fa7a80104c812a084968666133f57f386e4b0970932115dbf819a" dependencies = [ "glob", "phf 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index e2ff35f..02fd12d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ strum = "0.27" strum_macros = "0.27" serde_json = { version = "1.0", features = ["raw_value"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] } -caldata = { version = "0.12.1", features = ["chrono-tz"] } +caldata = { version = "0.13.0", features = ["chrono-tz", "vtimezones-rs"] } toml = "0.9" tower = "0.5" tower-http = { version = "0.6", features = [ diff --git a/crates/caldav/src/calendar/service.rs b/crates/caldav/src/calendar/service.rs index 2f35acc..8bda9a3 100644 --- a/crates/caldav/src/calendar/service.rs +++ b/crates/caldav/src/calendar/service.rs @@ -6,7 +6,7 @@ use crate::calendar::methods::report::route_report_calendar; use crate::calendar::resource::CalendarResource; use crate::calendar_object::CalendarObjectResourceService; use crate::calendar_object::resource::CalendarObjectResource; -use crate::{CalDavPrincipalUri, Error}; +use crate::{CalDavConfig, CalDavPrincipalUri, Error}; use async_trait::async_trait; use axum::Router; use axum::extract::Request; @@ -23,6 +23,7 @@ use tower::Service; pub struct CalendarResourceService { pub(crate) cal_store: Arc, pub(crate) sub_store: Arc, + pub(crate) config: Arc, } impl Clone for CalendarResourceService { @@ -30,15 +31,17 @@ impl Clone for CalendarResourceService CalendarResourceService { - pub const fn new(cal_store: Arc, sub_store: Arc) -> Self { + pub const fn new(cal_store: Arc, sub_store: Arc, config: Arc) -> Self { Self { cal_store, sub_store, + config, } } } @@ -112,7 +115,8 @@ impl ResourceService for CalendarResourc Router::new() .nest( "/{object_id}", - CalendarObjectResourceService::new(self.cal_store.clone()).axum_router(), + CalendarObjectResourceService::new(self.cal_store.clone(), self.config.clone()) + .axum_router(), ) .route_service("/", self.axum_service()) } diff --git a/crates/caldav/src/calendar_object/methods.rs b/crates/caldav/src/calendar_object/methods.rs index 873bc59..c75eced 100644 --- a/crates/caldav/src/calendar_object/methods.rs +++ b/crates/caldav/src/calendar_object/methods.rs @@ -5,6 +5,7 @@ use axum::body::Body; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use axum_extra::TypedHeader; +use caldata::parser::ParserOptions; use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; use http::{HeaderMap, HeaderValue, Method, StatusCode}; use rustical_ical::CalendarObject; @@ -20,7 +21,10 @@ pub async fn get_event( calendar_id, object_id, }): Path, - State(CalendarObjectResourceService { cal_store }): State>, + State(CalendarObjectResourceService { + cal_store, + config: _, + }): State>, user: Principal, method: Method, ) -> Result { @@ -57,7 +61,9 @@ pub async fn put_event( calendar_id, object_id, }): Path, - State(CalendarObjectResourceService { cal_store }): State>, + State(CalendarObjectResourceService { cal_store, config }): State< + CalendarObjectResourceService, + >, user: Principal, mut if_none_match: Option>, header_map: HeaderMap, @@ -94,7 +100,12 @@ pub async fn put_event( true }; - let object = match CalendarObject::from_ics(body.clone()) { + let object = match CalendarObject::import( + &body, + Some(ParserOptions { + rfc7809: config.rfc7809, + }), + ) { Ok(object) => object, Err(err) => { warn!("invalid calendar data:\n{body}"); diff --git a/crates/caldav/src/calendar_object/service.rs b/crates/caldav/src/calendar_object/service.rs index 97f1516..7ed9819 100644 --- a/crates/caldav/src/calendar_object/service.rs +++ b/crates/caldav/src/calendar_object/service.rs @@ -1,5 +1,5 @@ use crate::{ - CalDavPrincipalUri, Error, + CalDavConfig, CalDavPrincipalUri, Error, calendar_object::{ methods::{get_event, put_event}, resource::CalendarObjectResource, @@ -24,19 +24,21 @@ pub struct CalendarObjectPathComponents { pub struct CalendarObjectResourceService { pub(crate) cal_store: Arc, + pub(crate) config: Arc, } impl Clone for CalendarObjectResourceService { fn clone(&self) -> Self { Self { cal_store: self.cal_store.clone(), + config: self.config.clone(), } } } impl CalendarObjectResourceService { - pub const fn new(cal_store: Arc) -> Self { - Self { cal_store } + pub const fn new(cal_store: Arc, config: Arc) -> Self { + Self { cal_store, config } } } diff --git a/crates/caldav/src/lib.rs b/crates/caldav/src/lib.rs index 991dfc8..e9551b2 100644 --- a/crates/caldav/src/lib.rs +++ b/crates/caldav/src/lib.rs @@ -8,6 +8,7 @@ use rustical_dav::resources::RootResourceService; use rustical_store::auth::middleware::AuthenticationLayer; use rustical_store::auth::{AuthenticationProvider, Principal}; use rustical_store::{CalendarStore, SubscriptionStore}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; pub mod calendar; @@ -34,6 +35,7 @@ pub fn caldav_router, subscription_store: Arc, simplified_home_set: bool, + config: Arc, ) -> Router { Router::new().nest( prefix, @@ -42,9 +44,27 @@ pub fn caldav_router bool { + true +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields, default)] +pub struct CalDavConfig { + #[serde(default = "default_true")] + rfc7809: bool, +} + +impl Default for CalDavConfig { + fn default() -> Self { + Self { rfc7809: true } + } +} diff --git a/crates/caldav/src/principal/service.rs b/crates/caldav/src/principal/service.rs index e850649..a6d192b 100644 --- a/crates/caldav/src/principal/service.rs +++ b/crates/caldav/src/principal/service.rs @@ -1,7 +1,7 @@ use crate::calendar::CalendarResourceService; use crate::calendar::resource::CalendarResource; use crate::principal::PrincipalResource; -use crate::{CalDavPrincipalUri, Error}; +use crate::{CalDavConfig, CalDavPrincipalUri, Error}; use async_trait::async_trait; use axum::Router; use rustical_dav::resource::{AxumMethods, ResourceService}; @@ -20,6 +20,7 @@ pub struct PrincipalResourceService< 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, + pub(crate) config: Arc, } impl Clone @@ -31,6 +32,7 @@ impl Clone sub_store: self.sub_store.clone(), cal_store: self.cal_store.clone(), simplified_home_set: self.simplified_home_set, + config: self.config.clone(), } } } @@ -84,8 +86,12 @@ impl Resour Router::new() .nest( "/{calendar_id}", - CalendarResourceService::new(self.cal_store.clone(), self.sub_store.clone()) - .axum_router(), + CalendarResourceService::new( + self.cal_store.clone(), + self.sub_store.clone(), + self.config.clone(), + ) + .axum_router(), ) .route_service("/", self.axum_service()) } diff --git a/crates/caldav/src/principal/tests.rs b/crates/caldav/src/principal/tests.rs index 2a1954f..9fa0548 100644 --- a/crates/caldav/src/principal/tests.rs +++ b/crates/caldav/src/principal/tests.rs @@ -27,6 +27,7 @@ async fn test_principal_resource( sub_store: Arc::new(sub_store), auth_provider: Arc::new(auth_provider), simplified_home_set: false, + config: Default::default(), }; // We don't have any calendars here diff --git a/crates/ical/src/address_object.rs b/crates/ical/src/address_object.rs index 66cb13d..586da51 100644 --- a/crates/ical/src/address_object.rs +++ b/crates/ical/src/address_object.rs @@ -17,7 +17,7 @@ use caldata::{ }; use chrono::{NaiveDate, Utc}; use sha2::{Digest, Sha256}; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::str::FromStr; #[derive(Debug, Clone)] @@ -134,7 +134,7 @@ impl AddressObject { .into(), ], inner: Some(CalendarInnerDataBuilder::Event(vec![event])), - vtimezones: HashMap::default(), + vtimezones: BTreeMap::default(), } .build(None)? .into(), diff --git a/crates/ical/src/calendar_object.rs b/crates/ical/src/calendar_object.rs index b5e2a1d..78aedb0 100644 --- a/crates/ical/src/calendar_object.rs +++ b/crates/ical/src/calendar_object.rs @@ -1,8 +1,11 @@ +use std::sync::OnceLock; + use crate::Error; use caldata::{ IcalObjectParser, component::{CalendarInnerData, IcalCalendarObject}, generator::Emitter, + parser::ParserOptions, }; use derive_more::Display; use serde::Deserialize; @@ -65,15 +68,35 @@ impl rustical_xml::ValueDeserialize for CalendarObjectType { #[derive(Debug, Clone)] pub struct CalendarObject { inner: IcalCalendarObject, - ics: String, + ics: OnceLock, } impl CalendarObject { + // This function parses iCalendar data but doesn't cache it + // This is meant for iCalendar data coming from outside that might need to be normalised. + // For example if timezones are omitted this can be fixed by this function. + pub fn import(ics: &str, options: Option) -> Result { + let parser = + IcalObjectParser::from_slice(ics.as_bytes()).with_options(options.unwrap_or_default()); + let inner = parser.expect_one()?; + + Ok(Self { + inner, + ics: OnceLock::new(), + }) + } + + // This function parses iCalendar data and then caches the parsed iCalendar data. + // This function is only meant for loading data from a data store where we know the iCalendar + // is already in the desired form. pub fn from_ics(ics: String) -> Result { let parser = IcalObjectParser::from_slice(ics.as_bytes()); let inner = parser.expect_one()?; - Ok(Self { inner, ics }) + Ok(Self { + inner, + ics: ics.into(), + }) } #[must_use] @@ -96,7 +119,7 @@ impl CalendarObject { #[must_use] pub fn get_ics(&self) -> &str { - &self.ics + self.ics.get_or_init(|| self.inner.generate()) } #[must_use] @@ -114,7 +137,7 @@ impl From for IcalCalendarObject { impl From for CalendarObject { fn from(value: IcalCalendarObject) -> Self { Self { - ics: value.generate(), + ics: value.generate().into(), inner: value, } } diff --git a/src/app.rs b/src/app.rs index 4b2f6a1..44691e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use axum_extra::TypedHeader; use headers::{HeaderMapExt, UserAgent}; use http::header::CONNECTION; use http::{HeaderValue, StatusCode}; -use rustical_caldav::caldav_router; +use rustical_caldav::{CalDavConfig, caldav_router}; use rustical_carddav::carddav_router; use rustical_frontend::nextcloud_login::nextcloud_login_router; use rustical_frontend::{FrontendConfig, frontend_router}; @@ -45,6 +45,7 @@ pub fn make_app< auth_provider: Arc, frontend_config: FrontendConfig, oidc_config: Option, + caldav_config: CalDavConfig, nextcloud_login_config: &NextcloudLoginConfig, dav_push_enabled: bool, session_cookie_samesite_strict: bool, @@ -54,6 +55,8 @@ pub fn make_app< let combined_cal_store = Arc::new(CombinedCalendarStore::new(cal_store).with_store(birthday_store)); + let caldav_config = Arc::new(caldav_config); + let mut router = Router::new() // endpoint to be used by healthcheck to see if rustical is online .route("/ping", axum::routing::get(async || "Pong!")) @@ -63,6 +66,7 @@ pub fn make_app< combined_cal_store.clone(), subscription_store.clone(), false, + caldav_config.clone(), )) .merge(caldav_router( "/caldav-compat", @@ -70,6 +74,7 @@ pub fn make_app< combined_cal_store.clone(), subscription_store.clone(), true, + caldav_config, )) .route( "/.well-known/caldav", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7614232..072bb8c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ use crate::config::{ SqliteDataStoreConfig, TracingConfig, }; use clap::Parser; +use rustical_caldav::CalDavConfig; use rustical_frontend::FrontendConfig; pub mod health; @@ -15,6 +16,7 @@ pub struct GenConfigArgs {} pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { let config = Config { http: HttpConfig::default(), + caldav: CalDavConfig::default(), data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig { db_url: "/var/lib/rustical/db.sqlite3".to_owned(), run_repairs: true, diff --git a/src/config.rs b/src/config.rs index 3a23f66..3e271a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use rustical_caldav::CalDavConfig; use rustical_frontend::FrontendConfig; use rustical_oidc::OidcConfig; use serde::{Deserialize, Serialize}; @@ -97,4 +98,6 @@ pub struct Config { pub dav_push: DavPushConfig, #[serde(default)] pub nextcloud_login: NextcloudLoginConfig, + #[serde(default)] + pub caldav: CalDavConfig, } diff --git a/src/integration_tests/mod.rs b/src/integration_tests/mod.rs index 17c0d1f..18a8514 100644 --- a/src/integration_tests/mod.rs +++ b/src/integration_tests/mod.rs @@ -2,6 +2,7 @@ use crate::{app::make_app, config::NextcloudLoginConfig}; use axum::extract::Request; use axum::{body::Body, response::Response}; use rstest::rstest; +use rustical_caldav::CalDavConfig; use rustical_frontend::FrontendConfig; use rustical_store_sqlite::tests::{TestStoreContext, test_store_context}; use std::sync::Arc; @@ -26,6 +27,7 @@ pub fn get_app(context: TestStoreContext) -> axum::Router { allow_password_login: true, }, None, + CalDavConfig::default(), &NextcloudLoginConfig { enabled: false }, false, true, diff --git a/src/main.rs b/src/main.rs index ae7896d..8743d1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -153,6 +153,7 @@ async fn main() -> Result<()> { principal_store.clone(), config.frontend.clone(), config.oidc.clone(), + config.caldav, &config.nextcloud_login, config.dav_push.enabled, config.http.session_cookie_samesite_strict,