From a75b9f213a15c5d5407cfb909bec40c786f23c3e Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:06:26 +0200 Subject: [PATCH] Support timezone ids from the Olson database --- Cargo.lock | 76 +++++++++++ Cargo.toml | 1 + crates/caldav/Cargo.toml | 1 + .../calendar/methods/report/calendar_query.rs | 52 ++++---- crates/store/Cargo.toml | 1 + crates/store/src/model/event.rs | 34 +++-- crates/store/src/model/object.rs | 15 ++- crates/store/src/timestamp.rs | 120 ++++++++++++++---- 8 files changed, 224 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 684431c..2326fc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1971,6 +1992,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "password-auth" version = "1.0.0" @@ -2028,6 +2058,44 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -2387,6 +2455,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "chrono", "derive_more 1.0.0", "futures-util", "quick-xml", @@ -2469,6 +2538,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "chrono-tz", "ical", "lazy_static", "password-auth", @@ -2632,6 +2702,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" diff --git a/Cargo.toml b/Cargo.toml index ded4b1c..b118d95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ rustical_store = { path = "./crates/store/" } rustical_caldav = { path = "./crates/caldav/" } rustical_carddav = { path = "./crates/carddav/" } rustical_frontend = { path = "./crates/frontend/" } +chrono-tz = "0.10.0" [dependencies] rustical_store = { workspace = true } diff --git a/crates/caldav/Cargo.toml b/crates/caldav/Cargo.toml index 5559beb..4890e0c 100644 --- a/crates/caldav/Cargo.toml +++ b/crates/caldav/Cargo.toml @@ -25,3 +25,4 @@ roxmltree = { workspace = true } url = { workspace = true } rustical_dav = { workspace = true } rustical_store = { workspace = true } +chrono = { workspace = true } diff --git a/crates/caldav/src/calendar/methods/report/calendar_query.rs b/crates/caldav/src/calendar/methods/report/calendar_query.rs index d77cf43..4ba0df5 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query.rs @@ -1,10 +1,11 @@ use actix_web::HttpRequest; +use chrono::{DateTime, Utc}; use rustical_dav::{ methods::propfind::{PropElement, PropfindType}, resource::Resource, xml::{multistatus::PropstatWrapper, MultistatusElement}, }; -use rustical_store::{model::object::CalendarObject, timestamp::CalDateTime, CalendarStore}; +use rustical_store::{model::object::CalendarObject, CalendarStore}; use serde::Deserialize; use tokio::sync::RwLock; @@ -19,10 +20,18 @@ use crate::{ #[serde(rename_all = "kebab-case")] #[allow(dead_code)] struct TimeRangeElement { - #[serde(rename = "@start")] - start: Option, - #[serde(rename = "@end")] - end: Option, + #[serde( + rename = "@start", + deserialize_with = "rustical_store::timestamp::deserialize_utc_datetime", + default + )] + start: Option>, + #[serde( + rename = "@end", + deserialize_with = "rustical_store::timestamp::deserialize_utc_datetime", + default + )] + end: Option>, } #[derive(Deserialize, Clone, Debug)] @@ -138,31 +147,18 @@ impl CompFilterElement { if let Some(time_range) = &self.time_range { if let Some(start) = &time_range.start { - if let CalDateTime::Utc(range_start) = &start { - if let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) - { - if range_start > &first_occurence.utc() { - return false; - } - }; - } else { - // RFC 4791: 'Both attributes MUST be specified as "date with UTC time" value.' - // TODO: Return Bad Request instead? - return false; - } + if let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) { + if start > &first_occurence.utc() { + return false; + } + }; } if let Some(end) = &time_range.end { - if let CalDateTime::Utc(range_end) = &end { - if let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) { - if range_end < &last_occurence.utc() { - return false; - } - }; - } else { - // RFC 4791: 'Both attributes MUST be specified as "date with UTC time" value.' - // TODO: Return Bad Request instead? - return false; - } + if let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) { + if end < &last_occurence.utc() { + return false; + } + }; } } diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index c54d56d..740fae5 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -27,3 +27,4 @@ actix-web-httpauth = { workspace = true } tracing = { workspace = true } pbkdf2 = { workspace = true } rand_core = { workspace = true } +chrono-tz = { workspace = true } diff --git a/crates/store/src/model/event.rs b/crates/store/src/model/event.rs index 9cbdff3..d433580 100644 --- a/crates/store/src/model/event.rs +++ b/crates/store/src/model/event.rs @@ -1,31 +1,30 @@ -use std::str::FromStr; - -use anyhow::{anyhow, Result}; -use chrono::Duration; -use ical::{generator::IcalEvent, parser::Component, property::Property}; - use crate::{ timestamp::{parse_duration, CalDateTime}, Error, }; +use anyhow::{anyhow, Result}; +use chrono::Duration; +use ical::{ + generator::IcalEvent, + parser::{ical::component::IcalTimeZone, Component}, + property::Property, +}; +use std::collections::HashMap; #[derive(Debug, Clone)] pub struct EventObject { pub(crate) event: IcalEvent, + pub(crate) timezones: HashMap, } impl EventObject { pub fn get_first_occurence(&self) -> Result, Error> { // This is safe since we enforce the event's existance in the constructor - let dtstart = if let Some(Property { - value: Some(value), .. - }) = self.event.get_property("DTSTART") - { - value + if let Some(dtstart) = self.event.get_property("DTSTART") { + CalDateTime::parse_prop(dtstart, &self.timezones) } else { - return Ok(None); - }; - Ok(Some(CalDateTime::from_str(dtstart)?)) + Ok(None) + } } pub fn get_last_occurence(&self) -> Result, Error> { @@ -35,11 +34,8 @@ impl EventObject { return Err(anyhow!("event is recurring, we cannot handle that yet").into()); } - if let Some(Property { - value: Some(dtend), .. - }) = self.event.get_property("DTEND") - { - return Ok(Some(CalDateTime::from_str(dtend)?)); + if let Some(dtend) = self.event.get_property("DTEND") { + return CalDateTime::parse_prop(dtend, &self.timezones); }; let duration = if let Some(Property { diff --git a/crates/store/src/model/object.rs b/crates/store/src/model/object.rs index 3c39d5d..3bce639 100644 --- a/crates/store/src/model/object.rs +++ b/crates/store/src/model/object.rs @@ -1,9 +1,10 @@ use super::{event::EventObject, todo::TodoObject}; use crate::{timestamp::CalDateTime, Error}; use anyhow::Result; +use ical::parser::{ical::component::IcalTimeZone, Component}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::io::BufReader; +use std::{collections::HashMap, io::BufReader}; #[derive(Debug, Clone)] // specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6 @@ -84,12 +85,24 @@ impl CalendarObject { )); } + let timezones: HashMap = cal + .timezones + .clone() + .into_iter() + .filter_map(|timezone| { + let timezone_prop = timezone.get_property("TZID")?.to_owned(); + let tzid = timezone_prop.value?; + Some((tzid, timezone)) + }) + .collect(); + if let Some(event) = cal.events.first() { return Ok(CalendarObject { uid, ics, data: CalendarObjectComponent::Event(EventObject { event: event.clone(), + timezones, }), }); } diff --git a/crates/store/src/timestamp.rs b/crates/store/src/timestamp.rs index 2a102b5..cfc61a5 100644 --- a/crates/store/src/timestamp.rs +++ b/crates/store/src/timestamp.rs @@ -1,10 +1,14 @@ -use std::{ops::Add, str::FromStr}; - use crate::Error; use anyhow::{anyhow, Result}; use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, Utc}; +use chrono_tz::Tz; +use ical::{ + parser::{ical::component::IcalTimeZone, Component}, + property::Property, +}; use lazy_static::lazy_static; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; +use std::{collections::HashMap, ops::Add}; lazy_static! { static ref RE_DURATION: regex::Regex = regex::Regex::new(r"^(?[+-])?P((?P\d+)W)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?$").unwrap(); @@ -21,19 +25,27 @@ pub enum CalDateTime { // Form 2, example: 19980119T070000Z Utc(DateTime), // Form 3, example: TZID=America/New_York:19980119T020000 - // TODO: implement timezone parsing - ExplicitTZ((String, NaiveDateTime)), + // https://en.wikipedia.org/wiki/Tz_database + OlsonTZ(DateTime), Date(NaiveDate), } -impl<'de> Deserialize<'de> for CalDateTime { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - let input = String::deserialize(deserializer)?; - Self::from_str(&input).map_err(|_| serde::de::Error::custom("Invalid datetime format")) - } +pub fn deserialize_utc_datetime<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + type Inner = Option; + Ok(if let Some(input) = Inner::deserialize(deserializer)? { + dbg!(&input); + Some( + NaiveDateTime::parse_from_str(&input, UTC_DATE_TIME) + .map_err(|err| serde::de::Error::custom(err.to_string()))? + .and_utc(), + ) + } else { + dbg!("NONE"); + None + }) } impl Add for CalDateTime { @@ -43,36 +55,95 @@ impl Add for CalDateTime { match self { Self::Local(datetime) => Self::Local(datetime + duration), Self::Utc(datetime) => Self::Utc(datetime + duration), - Self::ExplicitTZ((tz, datetime)) => Self::ExplicitTZ((tz, datetime + duration)), + Self::OlsonTZ(datetime) => Self::OlsonTZ(datetime + duration), Self::Date(date) => Self::Local(date.and_time(NaiveTime::default()) + duration), } } } -impl FromStr for CalDateTime { - type Err = Error; +impl CalDateTime { + pub fn parse_prop( + prop: &Property, + timezones: &HashMap, + ) -> Result, Error> { + let prop_value = if let Some(value) = &prop.value { + value.to_owned() + } else { + return Ok(None); + }; - fn from_str(value: &str) -> Result { + let timezone = if let Some(tzid) = &prop + .params + .clone() + .unwrap_or_default() + .iter() + .filter(|(name, _values)| name == "TZID") + .map(|(_name, values)| values.first()) + .next() + .unwrap_or_default() + { + if let Some(timezone) = timezones.get(tzid.to_owned()) { + // X-LIC-LOCATION is often used to refer to a standardised timezone from the Olson + // database + if let Some(olson_name) = timezone + .get_property("X-LIC-LOCATION") + .map(|prop| prop.value.to_owned()) + .unwrap_or_default() + { + if let Ok(tz) = olson_name.parse::() { + Some(tz) + } else { + // TODO: handle invalid timezone name + None + } + } else { + // No name, we would have to parse it ourselves :( + // TODO: implement + None + } + } else { + // ERROR: invalid timezone specified + // For now just assume naive datetime? + None + } + } else { + None + }; + + Self::parse(&prop_value, timezone).map(Some) + } + + pub fn parse(value: &str, timezone: Option) -> Result { if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATE_TIME) { + if let Some(timezone) = timezone { + let result = datetime.and_local_timezone(timezone); + if let Some(datetime) = result.earliest() { + return Ok(CalDateTime::OlsonTZ(datetime)); + } else { + // This time does not exist because there's a gap in local time + return Err(Error::Other(anyhow!( + "Timestamp doesn't exist because of gap in local time" + ))); + } + } return Ok(CalDateTime::Local(datetime)); } + if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) { return Ok(CalDateTime::Utc(datetime.and_utc())); } if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) { return Ok(CalDateTime::Date(date)); } + Err(Error::Other(anyhow!("Invalid datetime format"))) } -} -impl CalDateTime { pub fn utc(&self) -> DateTime { match &self { CalDateTime::Local(local_datetime) => local_datetime.and_utc(), CalDateTime::Utc(utc_datetime) => utc_datetime.to_owned(), - // TODO: respect timezone! - CalDateTime::ExplicitTZ((_tzid, datetime)) => datetime.and_utc(), + CalDateTime::OlsonTZ(datetime) => datetime.to_utc(), CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(), } } @@ -84,13 +155,6 @@ impl From for DateTime { } } -#[test] -fn test_parse_cal_datetime() { - CalDateTime::from_str("19980118T230000").unwrap(); - CalDateTime::from_str("19980118T230000Z").unwrap(); - CalDateTime::from_str("19980118").unwrap(); -} - pub fn parse_duration(string: &str) -> Result { let captures = RE_DURATION .captures(string)