Compare commits

..

55 Commits

Author SHA1 Message Date
Lennart
0eef4ffabf Add test for uploading invalid calendar object and fix precondition 2026-01-19 13:40:54 +01:00
Lennart
303f9aff68 Remove IcalError from caldav/carddav since it had an ambiguous status code 2026-01-19 12:51:51 +01:00
Lennart
3460a2821e dav: Check Host matching for MV,COPY 2026-01-19 12:37:35 +01:00
Lennart
f73658b32f Re-enable calendar-query test and fix calendar expansion 2026-01-19 12:09:34 +01:00
Lennart K
7e099bcd6e Merge branch 'main' into feat/ical-rewrite 2026-01-16 16:47:17 +01:00
Lennart K
dde05d2f45 Workflow: Publish container images for feature branches too 2026-01-16 16:29:38 +01:00
Lennart K
4adf1818d4 Merge branch 'main' into feat/ical-rewrite 2026-01-16 15:58:17 +01:00
Lennart K
f503bf2bf7 Update quick-xml 2026-01-16 15:47:10 +01:00
Lennart K
7c15976a1a rebase main 2026-01-16 15:41:39 +01:00
Lennart K
669d81aea0 address_object resource: Implement displayname 2026-01-16 15:39:56 +01:00
Lennart K
967d18de95 Fix comp-filter 2026-01-16 15:39:55 +01:00
Lennart K
63373ad525 simplify handling of ical-related errors 2026-01-16 15:39:54 +01:00
Lennart K
2c67890343 Update ical-rs 2026-01-16 15:39:53 +01:00
Lennart K
5ec2787ecf build MVP for birthday calendar 2026-01-16 15:39:53 +01:00
Lennart K
7eecd95757 Remove calendar-query integration test for now 2026-01-16 15:39:52 +01:00
Lennart K
c165e761be update ical-rs 2026-01-16 15:39:51 +01:00
Lennart K
5f68a5ae5c Re-add get_last_occurence for sqlite store 2026-01-16 15:39:50 +01:00
Lennart K
c77b59dcb0 Remove unused code 2026-01-16 15:39:49 +01:00
Lennart K
276fdcacf5 Re-implement calendar imports 2026-01-16 15:39:48 +01:00
Lennart K
43fff63008 Calendar export: Fix PRODID 2026-01-16 15:39:47 +01:00
Lennart K
977fd75500 Re-implement calendar export 2026-01-16 15:39:46 +01:00
Lennart K
5639127782 clean up ical-related stuff 2026-01-16 15:39:44 +01:00
Lennart K
a2255bc7f1 make calendar object id extrinsic 2026-01-16 15:39:34 +01:00
Lennart K
758793a11a Make AddressObject object_id an extrinsic property 2026-01-16 15:39:33 +01:00
Lennart K
a9f3833a32 small fixes 2026-01-16 15:39:30 +01:00
Lennart K
896e934c0a Decrease folder nesting 2026-01-16 15:39:01 +01:00
Lennart K
bb880aa403 incorporate get_first_occurenec 2026-01-16 15:39:00 +01:00
Lennart K
69acde10ba migrate to new ical-rs version 2026-01-16 15:38:57 +01:00
Lennart K
d84158e8ad version 0.11.17 2026-01-16 12:26:43 +01:00
Lennart K
7ef566040a Disable a test that will be fixed in 0.12 2026-01-16 12:16:02 +01:00
Lennart K
1c1f0c6da2 Update ical-rs@dev to fix cargo vendor 2026-01-16 12:10:10 +01:00
Lennart
3fafbd22f4 version 0.11.16 2026-01-15 23:43:00 +01:00
Lennart
e68dc921e6 Now actually fix builds 2026-01-15 23:19:35 +01:00
Lennart
60b45e70ad fix docker builds 2026-01-15 22:31:40 +01:00
Lennart
a0c33c82dd version 0.11.14 2026-01-15 13:32:45 +01:00
Lennart
8ae5e46abf Automatic repair for calendar objects with invalid VERSION:4.0 2026-01-15 13:30:14 +01:00
Lennart
48b2e614a8 Suppress ical invalid version error 2026-01-15 11:15:20 +01:00
Lennart K
f26214abb9 build Docker images for dev branch 2026-01-12 11:15:08 +01:00
Lennart
276e65d41a version 0.11.11 2026-01-10 13:37:24 +01:00
Lennart
7c3e9ecbc1 update ical-rs dev to remove panics 2026-01-10 13:35:14 +01:00
Lennart
53f81a9433 Add a startup test to check whether existing data will be compatible with v0.12 2026-01-10 13:22:49 +01:00
Lennart
55eabfde4a version 0.11.10 2026-01-04 00:06:43 +01:00
Lennart
5d9d6e3fdf update snapshots 2026-01-04 00:06:16 +01:00
Lennart
1567bc64ef addressbook-query: Fix filter matching for empty filters 2026-01-04 00:05:07 +01:00
Lennart
44ae995f29 Some small fixes on recurrence expansion 2025-12-31 19:54:06 +01:00
Lennart
9c1cd24d32 test data fix uid 2025-12-31 19:47:29 +01:00
Lennart
ff0c5697cf caldav expand: accept <prop> 2025-12-31 19:47:13 +01:00
Lennart
6ccb5a67e4 ical: Respect that calendar do not have to contain a main event 2025-12-31 17:55:58 +01:00
Lennart
da718dd290 caldav: Add import test case from RFC Appendix B 2025-12-31 17:55:30 +01:00
Lennart
4112347e24 more integration tests 2025-12-31 17:36:02 +01:00
Lennart
f4fd1cdd21 caldav max-resource-size: Fix namespace 2025-12-31 17:35:51 +01:00
Lennart
5f0ca67e54 docs: Add document to track progress of CardDAV support 2025-12-31 17:00:14 +01:00
Lennart
3aef9abe48 carddav: Add more integration tests 2025-12-31 16:50:55 +01:00
Lennart
9784f2b53f PUT object: Properly check If-None-Match header 2025-12-31 16:50:32 +01:00
Lennart
271fdfd686 PUT object: Return ETag 2025-12-31 16:17:34 +01:00
97 changed files with 3337 additions and 2043 deletions

View File

@@ -2,7 +2,10 @@ name: Docker
on:
push:
branches: ["main"]
branches:
- main
- dev
- feat/*
release:
types: ["published"]
@@ -45,7 +48,8 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_name == 'main' && 'type=ref,event=branch' || '' }}
type=ref,event=branch,prefix=br-
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ site
# Frontend
**/node_modules
**/.vite
**/*.snap.new

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM calendarobjectchangelog WHERE (principal, cal_id, object_id) = (?, ?, ?);",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "146e23ae4e0eaae4d65ac7563c67d4f295ccc2534dcc4b3bd710de773ed137f9"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE calendarobjects SET ics = ? WHERE (principal, cal_id, id) = (?, ?, ?);",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "354decac84758c88280f60fbf0f93dddc6c7ff92ac7b8ba44049d31df3c680e3"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT principal, cal_id, id, ics FROM calendarobjects WHERE ics LIKE '%VERSION:4.0%';",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "cal_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "bdaa4bee8b01d0e3773e34672ed4805d1e71d24888f2227045afd90bf080fc23"
}

467
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.11.9"
version = "0.11.17"
rust-version = "1.92"
edition = "2024"
description = "A CalDAV server"
@@ -73,7 +73,7 @@ tokio = { version = "1.48", features = [
url = "2.5"
base64 = "0.22"
thiserror = "2.0"
quick-xml = { version = "0.38" }
quick-xml = { version = "0.39" }
rust-embed = "8.9"
tower-sessions = "0.14"
futures-core = "0.3"
@@ -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"] }
ical = { git = "https://github.com/lennart-k/ical-rs", features = [
ical = { git = "https://github.com/lennart-k/ical-rs", branch = "dev", features = [
"chrono-tz",
] }
toml = "0.9"
@@ -153,6 +153,7 @@ criterion = { version = "0.8", features = ["async_tokio"] }
rstest.workspace = true
rustical_store_sqlite = { workspace = true, features = ["test"] }
insta.workspace = true
similar-asserts.workspace = true
[dependencies]
rustical_store.workspace = true
@@ -160,6 +161,7 @@ rustical_store_sqlite.workspace = true
rustical_caldav.workspace = true
rustical_carddav.workspace = true
rustical_frontend.workspace = true
ical.workspace = true
toml.workspace = true
serde.workspace = true
tokio.workspace = true

View File

@@ -36,7 +36,7 @@ COPY --from=planner /rustical/recipe.json recipe.json
RUN cargo chef cook --release --target "$(cat /tmp/rust_target)"
COPY . .
RUN cargo install --target "$(cat /tmp/rust_target)" --path .
RUN cargo install --locked --target "$(cat /tmp/rust_target)" --path .
FROM scratch
COPY --from=builder /usr/local/cargo/bin/rustical /usr/local/bin/rustical

View File

@@ -5,13 +5,11 @@ use axum::extract::State;
use axum::{extract::Path, response::Response};
use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, Method, StatusCode, header};
use ical::builder::calendar::IcalCalendarBuilder;
use ical::component::IcalCalendar;
use ical::generator::Emitter;
use ical::property::Property;
use ical::property::ContentLine;
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_ical::{CalendarObjectComponent, EventObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use std::collections::HashMap;
use std::str::FromStr;
use tracing::instrument;
@@ -33,60 +31,45 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
return Err(crate::Error::Unauthorized);
}
let mut vtimezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let objects = cal_store
.get_objects(&principal, &calendar_id)
.await?
.into_iter()
.map(|(_, object)| object.into())
.collect();
let mut props = vec![];
let mut ical_calendar_builder = IcalCalendarBuilder::version("2.0")
.gregorian()
.prodid("RustiCal");
if let Some(displayname) = calendar.meta.displayname {
ical_calendar_builder = ical_calendar_builder.set(Property {
props.push(ContentLine {
name: "X-WR-CALNAME".to_owned(),
value: Some(displayname),
params: vec![],
params: vec![].into(),
});
}
if let Some(description) = calendar.meta.description {
ical_calendar_builder = ical_calendar_builder.set(Property {
props.push(ContentLine {
name: "X-WR-CALDESC".to_owned(),
value: Some(description),
params: vec![],
params: vec![].into(),
});
}
if let Some(color) = calendar.meta.color {
props.push(ContentLine {
name: "X-WR-CALCOLOR".to_owned(),
value: Some(color),
params: vec![].into(),
});
}
if let Some(timezone_id) = calendar.timezone_id {
ical_calendar_builder = ical_calendar_builder.set(Property {
props.push(ContentLine {
name: "X-WR-TIMEZONE".to_owned(),
value: Some(timezone_id),
params: vec![],
params: vec![].into(),
});
}
for object in &objects {
vtimezones.extend(object.get_vtimezones());
match object.get_data() {
CalendarObjectComponent::Event(EventObject { event, .. }, overrides) => {
ical_calendar_builder = ical_calendar_builder
.add_event(event.clone())
.add_events(overrides.iter().map(|ev| ev.event.clone()));
}
CalendarObjectComponent::Todo(todo, overrides) => {
ical_calendar_builder = ical_calendar_builder
.add_todo(todo.clone())
.add_todos(overrides.iter().cloned());
}
CalendarObjectComponent::Journal(journal, overrides) => {
ical_calendar_builder = ical_calendar_builder
.add_journal(journal.clone())
.add_journals(overrides.iter().cloned());
}
}
}
ical_calendar_builder = ical_calendar_builder.add_timezones(vtimezones.into_values().cloned());
let ical_calendar = ical_calendar_builder
.build()
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;
let export_calendar = IcalCalendar::from_objects("RustiCal Export".to_owned(), objects, props);
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
@@ -104,6 +87,6 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
Ok(resp.body(Body::new(export_calendar.generate())).unwrap())
}
}

View File

@@ -5,16 +5,12 @@ use axum::{
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
generator::Emitter,
parser::{Component, ComponentMut},
};
use ical::parser::{Component, ComponentMut};
use rustical_dav::header::Overwrite;
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_ical::CalendarObjectType;
use rustical_store::{
Calendar, CalendarMetadata, CalendarStore, SubscriptionStore, auth::Principal,
};
use std::io::BufReader;
use tracing::instrument;
#[instrument(skip(resource_service))]
@@ -29,18 +25,11 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let mut parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
let mut cal = parser
.next()
.expect("input must contain calendar")
.unwrap()
.mutable();
if parser.next().is_some() {
return Err(rustical_ical::Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
)
.into());
}
let parser = ical::IcalParser::from_slice(body.as_bytes());
let mut cal = match parser.expect_one() {
Ok(cal) => cal.mutable(),
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
};
// Extract calendar metadata
let displayname = cal
@@ -49,14 +38,19 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
let description = cal
.get_property("X-WR-CALDESC")
.and_then(|prop| prop.value.clone());
let color = cal
.get_property("X-WR-CALCOLOR")
.and_then(|prop| prop.value.clone());
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value.clone());
// These properties should not appear in the expanded calendar objects
cal.remove_property("X-WR-CALNAME");
cal.remove_property("X-WR-CALDESC");
cal.remove_property("X-WR-CALCOLOR");
cal.remove_property("X-WR-TIMEZONE");
let cal = cal.verify().unwrap();
let cal = cal.build(None).unwrap();
// Make sure timezone is valid
if let Some(timezone_id) = timezone_id.as_ref() {
assert!(
@@ -64,8 +58,7 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
"Invalid calendar timezone id"
);
}
// Extract necessary component types
// // Extract necessary component types
let mut cal_components = vec![];
if !cal.events.is_empty() {
cal_components.push(CalendarObjectType::Event);
@@ -77,13 +70,10 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
cal_components.push(CalendarObjectType::Todo);
}
let expanded_cals = cal.expand_calendar();
// Janky way to convert between IcalCalendar and CalendarObject
let objects = expanded_cals
.into_iter()
.map(|cal| cal.generate())
.map(|ics| CalendarObject::from_ics(ics, None))
.collect::<Result<Vec<_>, _>>()?;
let objects = match cal.into_objects() {
Ok(objects) => objects.into_iter().map(Into::into).collect(),
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
};
let new_cal = Calendar {
principal,
id: cal_id,
@@ -91,7 +81,7 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
displayname,
order: 0,
description,
color: None,
color,
},
timezone_id,
deleted_at: None,

View File

@@ -87,17 +87,18 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
let calendar = IcalParser::from_slice(tz.as_bytes())
.next()
.ok_or_else(|| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
.map_err(|_| rustical_dav::Error::BadRequest("Error parsing timezone".to_owned()))?;
let timezone = calendar.timezones.first().ok_or_else(|| {
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
let timezone: chrono_tz::Tz = timezone
.try_into()
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
let timezone: Option<chrono_tz::Tz> = timezone.into();
let timezone = timezone.ok_or_else(|| {
rustical_dav::Error::BadRequest("Cannot translate VTIMEZONE into IANA TZID".to_owned())
})?;
Some(timezone.name().to_owned())
} else {

View File

@@ -21,7 +21,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
principal: &str,
cal_id: &str,
store: &C,
) -> Result<(Vec<CalendarObject>, Vec<String>), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>), Error> {
let mut result = vec![];
let mut not_found = vec![];
@@ -32,7 +32,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
let filename = filename.trim_start_matches('/');
if let Some(object_id) = filename.strip_suffix(".ics") {
match store.get_object(principal, cal_id, object_id, false).await {
Ok(object) => result.push(object),
Ok(object) => result.push((object_id.to_owned(), object)),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
Err(err) => return Err(err.into()),
}

View File

@@ -2,8 +2,10 @@ use crate::calendar::methods::report::calendar_query::{
TimeRangeElement,
prop_filter::{PropFilterElement, PropFilterable},
};
use ical::parser::ical::component::IcalTimeZone;
use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType};
use ical::{
component::{CalendarInnerData, IcalAlarm, IcalCalendarObject, IcalEvent, IcalTodo},
parser::{Component, ical::component::IcalTimeZone},
};
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -68,9 +70,98 @@ pub trait CompFilterable: PropFilterable + Sized {
}
}
impl CompFilterable for CalendarObject {
impl CompFilterable for CalendarInnerData {
fn get_comp_name(&self) -> &'static str {
"VCALENDAR"
match self {
Self::Event(main, _) => main.get_comp_name(),
Self::Journal(main, _) => main.get_comp_name(),
Self::Todo(main, _) => main.get_comp_name(),
}
}
fn match_time_range(&self, time_range: &TimeRangeElement) -> bool {
if let Some(start) = &time_range.start
&& let Some(last_end) = self.get_last_occurence()
&& start.to_utc() > last_end.utc()
{
return false;
}
if let Some(end) = &time_range.end
&& let Some(first_start) = self.get_first_occurence()
&& end.to_utc() < first_start.utc()
{
return false;
}
true
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
match self {
Self::Event(main, overrides) => std::iter::once(main)
.chain(overrides.iter())
.flat_map(IcalEvent::get_alarms)
.any(|alarm| alarm.matches(comp_filter)),
Self::Todo(main, overrides) => std::iter::once(main)
.chain(overrides.iter())
.flat_map(IcalTodo::get_alarms)
.any(|alarm| alarm.matches(comp_filter)),
// VJOURNAL has no subcomponents
Self::Journal(_, _) => comp_filter.is_not_defined.is_some(),
}
}
}
impl PropFilterable for IcalAlarm {
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name)
}
}
impl CompFilterable for IcalAlarm {
fn get_comp_name(&self) -> &'static str {
Component::get_comp_name(self)
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
true
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
comp_filter.is_not_defined.is_some()
}
}
impl PropFilterable for CalendarInnerData {
#[allow(refining_impl_trait)]
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> Box<dyn Iterator<Item = &'a ical::property::ContentLine> + 'a> {
// TODO: If we were pedantic, we would have to do recurrence expansion first
// and take into account the overrides :(
match self {
Self::Event(main, _) => Box::new(main.get_named_properties(name)),
Self::Todo(main, _) => Box::new(main.get_named_properties(name)),
Self::Journal(main, _) => Box::new(main.get_named_properties(name)),
}
}
}
impl PropFilterable for IcalCalendarObject {
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name)
}
}
impl CompFilterable for IcalCalendarObject {
fn get_comp_name(&self) -> &'static str {
Component::get_comp_name(self)
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
@@ -83,54 +174,36 @@ impl CompFilterable for CalendarObject {
.get_vtimezones()
.values()
.map(|tz| tz.matches(comp_filter))
.chain([self.get_data().matches(comp_filter)]);
.chain([self.get_inner().matches(comp_filter)]);
if comp_filter.is_not_defined.is_some() {
matches.all(|x| x)
matches.all(|x| !x)
} else {
matches.any(|x| x)
}
}
}
impl PropFilterable for IcalTimeZone {
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name)
}
}
impl CompFilterable for IcalTimeZone {
fn get_comp_name(&self) -> &'static str {
"VTIMEZONE"
Component::get_comp_name(self)
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
false
}
fn match_subcomponents(&self, _comp_filter: &CompFilterElement) -> bool {
true
}
}
impl CompFilterable for CalendarObjectComponent {
fn get_comp_name(&self) -> &'static str {
CalendarObjectType::from(self).as_str()
}
fn match_time_range(&self, time_range: &TimeRangeElement) -> bool {
if let Some(start) = &time_range.start
&& let Some(last_occurence) = self.get_last_occurence().unwrap_or(None)
&& **start > last_occurence.utc()
{
return false;
}
if let Some(end) = &time_range.end
&& let Some(first_occurence) = self.get_first_occurence().unwrap_or(None)
&& **end < first_occurence.utc()
{
return false;
}
true
}
fn match_subcomponents(&self, _comp_filter: &CompFilterElement) -> bool {
// TODO: Properly check subcomponents
true
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
// VTIMEZONE has no subcomponents
comp_filter.is_not_defined.is_some()
}
}
@@ -147,6 +220,7 @@ mod tests {
const ICS: &str = r"BEGIN:VCALENDAR
CALSCALE:GREGORIAN
VERSION:2.0
PRODID:me
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
@@ -166,7 +240,7 @@ END:VCALENDAR";
#[test]
fn test_comp_filter_matching() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: Some(()),
@@ -175,7 +249,10 @@ END:VCALENDAR";
prop_filter: vec![],
comp_filter: vec![],
};
assert!(!object.matches(&comp_filter), "filter: wants no VCALENDAR");
assert!(
!object.get_inner().matches(&comp_filter),
"filter: wants no VCALENDAR"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
@@ -190,7 +267,10 @@ END:VCALENDAR";
comp_filter: vec![],
}],
};
assert!(!object.matches(&comp_filter), "filter matches VTODO");
assert!(
!object.get_inner().matches(&comp_filter),
"filter matches VTODO"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
@@ -205,7 +285,10 @@ END:VCALENDAR";
comp_filter: vec![],
}],
};
assert!(object.matches(&comp_filter), "filter matches VEVENT");
assert!(
object.get_inner().matches(&comp_filter),
"filter matches VEVENT"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
@@ -252,13 +335,13 @@ END:VCALENDAR";
}],
};
assert!(
object.matches(&comp_filter),
object.get_inner().matches(&comp_filter),
"Some prop filters on VCALENDAR and VEVENT"
);
}
#[test]
fn test_comp_filter_time_range() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,
@@ -281,7 +364,7 @@ END:VCALENDAR";
}],
};
assert!(
object.matches(&comp_filter),
object.get_inner().matches(&comp_filter),
"event should lie in time range"
);
@@ -306,14 +389,14 @@ END:VCALENDAR";
}],
};
assert!(
!object.matches(&comp_filter),
!object.get_inner().matches(&comp_filter),
"event should not lie in time range"
);
}
#[test]
fn test_match_timezone() {
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,
@@ -340,7 +423,7 @@ END:VCALENDAR";
}],
};
assert!(
object.matches(&comp_filter),
object.get_inner().matches(&comp_filter),
"Timezone should be Europe/Berlin"
);
}

View File

@@ -1,8 +1,8 @@
use super::comp_filter::{CompFilterElement, CompFilterable};
use crate::calendar_object::CalendarObjectPropWrapperName;
use ical::property::Property;
use ical::{component::IcalCalendarObject, property::ContentLine};
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_ical::UtcDateTime;
use rustical_store::calendar_store::CalendarQuery;
use rustical_xml::{XmlDeserialize, XmlRootTag};
@@ -30,8 +30,8 @@ pub struct ParamFilterElement {
impl ParamFilterElement {
#[must_use]
pub fn match_property(&self, prop: &Property) -> bool {
let Some(param) = prop.get_param(&self.name) else {
pub fn match_property(&self, prop: &ContentLine) -> bool {
let Some(param) = prop.params.get_param(&self.name) else {
return self.is_not_defined.is_some();
};
if self.is_not_defined.is_some() {
@@ -57,7 +57,7 @@ pub struct FilterElement {
impl FilterElement {
#[must_use]
pub fn matches(&self, cal_object: &CalendarObject) -> bool {
pub fn matches(&self, cal_object: &IcalCalendarObject) -> bool {
cal_object.matches(&self.comp_filter)
}
}

View File

@@ -11,19 +11,19 @@ mod tests;
pub use comp_filter::{CompFilterElement, CompFilterable};
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable};
pub use prop_filter::PropFilterElement;
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
principal: &str,
cal_id: &str,
store: &C,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
let mut objects = store
.calendar_query(principal, cal_id, cal_query.into())
.await?;
if let Some(filter) = &cal_query.filter {
objects.retain(|object| filter.matches(object));
objects.retain(|(_id, object)| filter.matches(object.get_inner()));
}
Ok(objects)
}

View File

@@ -1,16 +1,8 @@
use super::{ParamFilterElement, TimeRangeElement};
use ical::{
generator::{IcalCalendar, IcalEvent},
parser::{
Component,
ical::component::{IcalJournal, IcalTimeZone, IcalTodo},
},
property::Property,
};
use ical::{property::ContentLine, types::CalDateTime};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectComponent, UtcDateTime};
use rustical_ical::UtcDateTime;
use rustical_xml::XmlDeserialize;
use std::collections::HashMap;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
@@ -29,12 +21,16 @@ pub struct PropFilterElement {
pub(crate) name: String,
}
pub trait PropFilterable {
fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine>;
}
impl PropFilterElement {
#[must_use]
pub fn match_property(&self, property: &Property) -> bool {
pub fn match_property(&self, property: &ContentLine) -> bool {
if let Some(TimeRangeElement { start, end }) = &self.time_range {
// TODO: Respect timezones
let Ok(timestamp) = CalDateTime::parse_prop(property, &HashMap::default()) else {
let Ok(timestamp) = CalDateTime::parse_prop(property, None) else {
return false;
};
let timestamp = timestamp.utc();
@@ -69,63 +65,13 @@ impl PropFilterElement {
}
pub fn match_component(&self, comp: &impl PropFilterable) -> bool {
let properties = comp.get_named_properties(&self.name);
let mut properties = comp.get_named_properties(&self.name);
if self.is_not_defined.is_some() {
return properties.is_empty();
return properties.next().is_none();
}
// The filter matches when one property instance matches
// Example where this matters: We have multiple attendees and want to match one
properties.iter().any(|prop| self.match_property(prop))
}
}
pub trait PropFilterable {
fn get_named_properties(&self, name: &str) -> Vec<&Property>;
}
impl PropFilterable for CalendarObject {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
Self::get_named_properties(self, name)
}
}
impl PropFilterable for IcalEvent {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
Component::get_named_properties(self, name)
}
}
impl PropFilterable for IcalTodo {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
Component::get_named_properties(self, name)
}
}
impl PropFilterable for IcalJournal {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
Component::get_named_properties(self, name)
}
}
impl PropFilterable for IcalCalendar {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
Component::get_named_properties(self, name)
}
}
impl PropFilterable for IcalTimeZone {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
Component::get_named_properties(self, name)
}
}
impl PropFilterable for CalendarObjectComponent {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
match self {
Self::Event(event, _) => PropFilterable::get_named_properties(&event.event, name),
Self::Todo(todo, _) => PropFilterable::get_named_properties(todo, name),
Self::Journal(journal, _) => PropFilterable::get_named_properties(journal, name),
}
properties.any(|prop| self.match_property(prop))
}
}

View File

@@ -77,7 +77,7 @@ const FILTER_2: &str = r#"
#[case(ICS_1, FILTER_1, true)]
#[case(ICS_1, FILTER_2, false)]
fn yeet(#[case] ics: &str, #[case] filter: &str, #[case] matches: bool) {
let obj = CalendarObject::from_ics(ics.to_owned(), None).unwrap();
let obj = CalendarObject::from_ics(ics.to_owned()).unwrap();
let filter = FilterElement::parse_str(filter).unwrap();
assert_eq!(matches, filter.matches(&obj));
assert_eq!(matches, filter.matches(obj.get_inner()));
}

View File

@@ -51,7 +51,7 @@ impl ReportRequest {
}
fn objects_response(
objects: Vec<CalendarObject>,
objects: Vec<(String, CalendarObject)>,
not_found: Vec<String>,
path: &str,
principal: &str,
@@ -60,11 +60,12 @@ fn objects_response(
prop: &PropfindType<CalendarObjectPropWrapperName>,
) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
let mut responses = Vec::new();
for object in objects {
let path = format!("{}/{}.ics", path, object.get_id());
for (object_id, object) in objects {
let path = format!("{path}/{object_id}.ics");
responses.push(
CalendarObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,
@@ -174,7 +175,7 @@ mod tests {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::CalendarData(
CalendarData { comp: None, expand: Some(ExpandElement {
CalendarData { comp: None, prop: None, expand: Some(ExpandElement {
start: <UtcDateTime as ValueDeserialize>::deserialize("20250426T220000Z").unwrap(),
end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
}), limit_recurrence_set: None, limit_freebusy_set: None }

View File

@@ -32,11 +32,12 @@ pub async fn handle_sync_collection<C: CalendarStore>(
.await?;
let mut responses = Vec::new();
for object in new_objects {
let path = format!("{}/{}.ics", path, object.get_id());
for (object_id, object) in new_objects {
let path = format!("{}/{}.ics", path, &object_id);
responses.push(
CalendarObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, &sync_collection.prop, None, puri, user)?,

View File

@@ -4,6 +4,7 @@ use crate::calendar::prop::{ReportMethod, SupportedCollationSet};
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
use ical::types::CalDateTime;
use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
};
@@ -11,7 +12,6 @@ use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime;
use rustical_store::Calendar;
use rustical_store::auth::Principal;
use rustical_xml::{EnumVariants, PropName};
@@ -42,7 +42,7 @@ pub enum CalendarProp {
SupportedCalendarData(SupportedCalendarData),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCollationSet(SupportedCollationSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
MaxResourceSize(i64),
#[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -202,7 +202,7 @@ impl Resource for CalendarResource {
CalendarProp::CalendarTimezone(timezone) => {
if let Some(tz) = timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
let calendar = IcalParser::from_slice(tz.as_bytes())
.next()
.ok_or_else(|| {
rustical_dav::Error::BadRequest(
@@ -215,13 +215,13 @@ impl Resource for CalendarResource {
)
})?;
let timezone = calendar.timezones.first().ok_or_else(|| {
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
let timezone: Option<chrono_tz::Tz> = timezone.into();
let timezone = timezone.ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
self.cal.timezone_id = Some(timezone.name().to_owned());
}
Ok(())

View File

@@ -78,8 +78,9 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
.get_objects(principal, cal_id)
.await?
.into_iter()
.map(|object| CalendarObjectResource {
.map(|(object_id, object)| CalendarObjectResource {
object,
object_id,
principal: principal.to_owned(),
})
.collect())
@@ -91,7 +92,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
file: Self::Resource,
) -> Result<(), Self::Error> {
self.cal_store
.update_calendar(principal.to_owned(), cal_id.to_owned(), file.into())
.update_calendar(principal, cal_id, file.into())
.await?;
Ok(())
}

View File

@@ -135,7 +135,7 @@ END:VCALENDAR
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;unicode-casemap</supported-collation>
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;octet</supported-collation>
</supported-collation-set>
<max-resource-size xmlns="DAV:">10000000</max-resource-size>
<max-resource-size xmlns="urn:ietf:params:xml:ns:caldav">10000000</max-resource-size>
<supported-report-set xmlns="DAV:">
<supported-report xmlns="DAV:">
<report xmlns="DAV:">

View File

@@ -16,7 +16,7 @@ expression: output
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-calendar-data xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-collation-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<max-resource-size xmlns="DAV:"/>
<max-resource-size xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-report-set xmlns="DAV:"/>
<source xmlns="http://calendarserver.org/ns/"/>
<min-date-time xmlns="urn:ietf:params:xml:ns:caldav"/>

View File

@@ -6,12 +6,12 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader;
use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, Method, StatusCode};
use http::{HeaderMap, HeaderValue, Method, StatusCode};
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
use rustical_store::auth::Principal;
use std::str::FromStr;
use tracing::{debug, instrument};
use tracing::{instrument, warn};
#[instrument(skip(cal_store))]
pub async fn get_event<C: CalendarStore>(
@@ -73,18 +73,44 @@ pub async fn put_event<C: CalendarStore>(
}
let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match {
if_none_match == IfNoneMatch::any()
// TODO: Put into transaction?
let existing = match cal_store
.get_object(&principal, &calendar_id, &object_id, false)
.await
{
Ok(existing) => Some(existing),
Err(rustical_store::Error::NotFound) => None,
Err(err) => Err(err)?,
};
existing.is_none_or(|existing| {
if_none_match.precondition_passes(
&existing
.get_etag()
.parse()
.expect("We only generate valid ETags"),
)
})
} else {
true
};
let Ok(object) = CalendarObject::from_ics(body.clone(), Some(object_id)) else {
debug!("invalid calendar data:\n{body}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
let object = match CalendarObject::from_ics(body.clone()) {
Ok(object) => object,
Err(err) => {
warn!("invalid calendar data:\n{body}");
warn!("{err:#?}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
}
};
let etag = object.get_etag();
cal_store
.put_object(principal, calendar_id, object, overwrite)
.put_object(&principal, &calendar_id, &object_id, object, overwrite)
.await?;
Ok(StatusCode::CREATED.into_response())
let mut headers = HeaderMap::new();
headers.insert(
"ETag",
HeaderValue::from_str(&etag).expect("Contains no invalid characters"),
);
Ok((StatusCode::CREATED, headers).into_response())
}

View File

@@ -1,6 +1,6 @@
use rustical_dav::extensions::CommonPropertiesProp;
use rustical_ical::UtcDateTime;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use rustical_xml::{EnumVariants, PropName, Unparsed, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarObjectPropName")]
@@ -35,7 +35,9 @@ pub struct ExpandElement {
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Default, Eq, Hash)]
pub struct CalendarData {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) comp: Option<()>,
pub(crate) comp: Option<Unparsed>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) prop: Option<Unparsed>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) expand: Option<ExpandElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]

View File

@@ -1,11 +1,10 @@
use std::borrow::Cow;
use super::prop::{
CalendarData, CalendarObjectProp, CalendarObjectPropName, CalendarObjectPropWrapper,
CalendarObjectPropWrapperName,
};
use crate::Error;
use derive_more::derive::{From, Into};
use ical::generator::Emitter;
use rustical_dav::{
extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet,
@@ -14,16 +13,18 @@ use rustical_dav::{
};
use rustical_ical::CalendarObject;
use rustical_store::auth::Principal;
use std::borrow::Cow;
#[derive(Clone, From, Into)]
pub struct CalendarObjectResource {
pub object: CalendarObject,
pub object_id: String,
pub principal: String,
}
impl ResourceName for CalendarObjectResource {
fn get_name(&self) -> Cow<'_, str> {
Cow::from(format!("{}.ics", self.object.get_id()))
Cow::from(format!("{}.ics", self.object_id))
}
}
@@ -53,14 +54,18 @@ impl Resource for CalendarObjectResource {
CalendarObjectProp::Getetag(self.object.get_etag())
}
CalendarObjectPropName::CalendarData(CalendarData { expand, .. }) => {
CalendarObjectProp::CalendarData(if let Some(expand) = expand.as_ref() {
self.object.expand_recurrence(
Some(expand.start.to_utc()),
Some(expand.end.to_utc()),
)?
} else {
self.object.get_ics().to_owned()
})
CalendarObjectProp::CalendarData(expand.as_ref().map_or_else(
|| self.object.get_ics().to_owned(),
|expand| {
self.object
.get_inner()
.expand_recurrence(
Some(expand.start.to_utc()),
Some(expand.end.to_utc()),
)
.generate()
},
))
}
CalendarObjectPropName::Getcontenttype => {
CalendarObjectProp::Getcontenttype("text/calendar;charset=utf-8")

View File

@@ -66,6 +66,7 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
.await?;
Ok(CalendarObjectResource {
object,
object_id: object_id.to_owned(),
principal: principal.to_owned(),
})
}

View File

@@ -52,9 +52,6 @@ pub enum Error {
#[error(transparent)]
XmlDecodeError(#[from] rustical_xml::XmlError),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
#[error(transparent)]
PreconditionFailed(Precondition),
}
@@ -75,7 +72,6 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND,
Self::IcalError(err) => err.status_code(),
Self::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED,
}
}
@@ -83,6 +79,9 @@ impl Error {
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
if let Self::PreconditionFailed(precondition) = self {
return precondition.into_response();
}
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED

View File

@@ -7,6 +7,7 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader;
use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::HeaderValue;
use http::Method;
use http::{HeaderMap, StatusCode};
use rustical_dav::privileges::UserPrivilege;
@@ -81,15 +82,40 @@ pub async fn put_object<AS: AddressbookStore>(
}
let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match {
if_none_match == IfNoneMatch::any()
// TODO: Put into transaction?
let existing = match addr_store
.get_object(&principal, &addressbook_id, &object_id, false)
.await
{
Ok(existing) => Some(existing),
Err(rustical_store::Error::NotFound) => None,
Err(err) => Err(err)?,
};
existing.is_none_or(|existing| {
if_none_match.precondition_passes(
&existing
.get_etag()
.parse()
.expect("We only generate valid ETags"),
)
})
} else {
true
};
let object = AddressObject::from_vcf(object_id, body)?;
let object = match AddressObject::from_vcf(body) {
Ok(object) => object,
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
};
let etag = object.get_etag();
addr_store
.put_object(principal, addressbook_id, object, overwrite)
.put_object(&principal, &addressbook_id, &object_id, object, overwrite)
.await?;
Ok(StatusCode::CREATED.into_response())
let mut headers = HeaderMap::new();
headers.insert(
"ETag",
HeaderValue::from_str(&etag).expect("Contains no invalid characters"),
);
Ok((StatusCode::CREATED, headers).into_response())
}

View File

@@ -8,6 +8,7 @@ use crate::{
},
};
use derive_more::derive::{From, Into};
use ical::parser::VcardFNProperty;
use rustical_dav::{
extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet,
@@ -21,11 +22,12 @@ use rustical_store::auth::Principal;
pub struct AddressObjectResource {
pub object: AddressObject,
pub principal: String,
pub object_id: String,
}
impl ResourceName for AddressObjectResource {
fn get_name(&self) -> Cow<'_, str> {
Cow::from(format!("{}.vcf", self.object.get_id()))
Cow::from(format!("{}.vcf", self.object_id))
}
}
@@ -69,7 +71,11 @@ impl Resource for AddressObjectResource {
}
fn get_displayname(&self) -> Option<&str> {
self.object.get_full_name()
self.object
.get_vcard()
.full_name
.first()
.map(|VcardFNProperty(name, _)| name.as_str())
}
fn get_owner(&self) -> Option<&str> {

View File

@@ -57,6 +57,7 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
.await?;
Ok(AddressObjectResource {
object,
object_id: object_id.to_owned(),
principal: principal.to_owned(),
})
}

View File

@@ -9,7 +9,6 @@ use http::{HeaderValue, Method, StatusCode, header};
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_ical::AddressObject;
use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, SubscriptionStore};
use std::str::FromStr;
@@ -40,7 +39,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
let objects = addr_store.get_objects(&principal, &addressbook_id).await?;
let vcf = objects
.iter()
.map(AddressObject::get_vcf)
.map(|(_id, obj)| obj.get_vcf())
.collect::<Vec<_>>()
.join("\r\n");

View File

@@ -1,5 +1,3 @@
use std::io::BufReader;
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::{
@@ -9,7 +7,7 @@ use axum::{
use http::StatusCode;
use ical::{
parser::{Component, ComponentMut, vcard},
property::Property,
property::ContentLine,
};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument;
@@ -25,7 +23,7 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let parser = vcard::VcardParser::new(BufReader::new(body.as_bytes()));
let parser = vcard::VcardParser::from_slice(body.as_bytes());
let mut objects = vec![];
for res in parser {
@@ -33,15 +31,16 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
let uid = card.get_uid();
if uid.is_none() {
let mut card_mut = card.mutable();
card_mut.set_property(Property {
card_mut.add_content_line(ContentLine {
name: "UID".to_owned(),
value: Some(uuid::Uuid::new_v4().to_string()),
params: vec![],
params: vec![].into(),
});
card = card_mut.verify().unwrap();
card = card_mut.build(None).unwrap();
}
objects.push(card.try_into().unwrap());
// TODO: Make nicer
let uid = card.get_uid().unwrap();
objects.push((uid.to_owned(), card.into()));
}
if objects.is_empty() {

View File

@@ -29,7 +29,7 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
principal: &str,
addressbook_id: &str,
store: &AS,
) -> Result<(Vec<AddressObject>, Vec<String>), Error> {
) -> Result<(Vec<(String, AddressObject)>, Vec<String>), Error> {
let mut result = vec![];
let mut not_found = vec![];
@@ -43,7 +43,7 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
.get_object(principal, addressbook_id, object_id, false)
.await
{
Ok(object) => result.push(object),
Ok(object) => result.push((object_id.to_owned(), object)),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
Err(err) => return Err(err.into()),
}
@@ -74,11 +74,12 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
.await?;
let mut responses = Vec::new();
for object in objects {
let path = format!("{}/{}.vcf", path, object.get_id());
for (object_id, object) in objects {
let path = format!("{path}/{object_id}.vcf");
responses.push(
AddressObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,

View File

@@ -3,7 +3,7 @@ use crate::{
addressbook::methods::report::addressbook_query::PropFilterElement,
};
use derive_more::{From, Into};
use ical::property::Property;
use ical::property::ContentLine;
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{AddressObject, UtcDateTime};
use rustical_xml::{ValueDeserialize, XmlDeserialize, XmlRootTag};
@@ -32,8 +32,8 @@ pub struct ParamFilterElement {
impl ParamFilterElement {
#[must_use]
pub fn match_property(&self, prop: &Property) -> bool {
let Some(param) = prop.get_param(&self.name) else {
pub fn match_property(&self, prop: &ContentLine) -> bool {
let Some(param) = prop.params.get_param(&self.name) else {
return self.is_not_defined.is_some();
};
if self.is_not_defined.is_some() {
@@ -82,6 +82,11 @@ pub struct FilterElement {
impl FilterElement {
#[must_use]
pub fn matches(&self, addr_object: &AddressObject) -> bool {
if self.prop_filter.is_empty() {
// Filter empty
return true;
}
let Allof(allof) = self.test;
let mut results = self
.prop_filter

View File

@@ -15,8 +15,8 @@ pub async fn get_objects_addressbook_query<AS: AddressbookStore>(
principal: &str,
addressbook_id: &str,
store: &AS,
) -> Result<Vec<AddressObject>, Error> {
) -> Result<Vec<(String, AddressObject)>, Error> {
let mut objects = store.get_objects(principal, addressbook_id).await?;
objects.retain(|object| addr_query.filter.matches(object));
objects.retain(|(_id, object)| addr_query.filter.matches(object));
Ok(objects)
}

View File

@@ -1,5 +1,5 @@
use super::{Allof, ParamFilterElement};
use ical::{parser::Component, property::Property};
use ical::{parser::Component, property::ContentLine};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::AddressObject;
use rustical_xml::XmlDeserialize;
@@ -31,7 +31,11 @@ pub struct PropFilterElement {
impl PropFilterElement {
#[must_use]
pub fn match_property(&self, property: &Property) -> bool {
pub fn match_property(&self, property: &ContentLine) -> bool {
if self.param_filter.is_empty() && self.text_match.is_empty() {
// Filter empty
return true;
}
let Allof(allof) = self.test;
let text_matches = self
.text_match
@@ -52,22 +56,22 @@ impl PropFilterElement {
}
pub fn match_component(&self, comp: &impl PropFilterable) -> bool {
let properties = comp.get_named_properties(&self.name);
let mut properties = comp.get_named_properties(&self.name);
if self.is_not_defined.is_some() {
return properties.is_empty();
return properties.next().is_none();
}
// The filter matches when one property instance matches
properties.iter().any(|prop| self.match_property(prop))
properties.any(|prop| self.match_property(prop))
}
}
pub trait PropFilterable {
fn get_named_properties(&self, name: &str) -> Vec<&Property>;
fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine>;
}
impl PropFilterable for AddressObject {
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine> {
self.get_vcard().get_named_properties(name)
}
}

View File

@@ -64,7 +64,7 @@ const FILTER_2: &str = r#"
#[case(VCF_2, FILTER_2, true)]
fn test_filter(#[case] vcf: &str, #[case] filter: &str, #[case] matches: bool) {
dbg!(vcf);
let obj = AddressObject::from_vcf(String::new(), vcf.to_owned()).unwrap();
let obj = AddressObject::from_vcf(vcf.to_owned()).unwrap();
let filter = FilterElement::parse_str(filter).unwrap();
assert_eq!(matches, filter.matches(&obj));
}

View File

@@ -55,7 +55,7 @@ impl ReportRequest {
}
fn objects_response(
objects: Vec<AddressObject>,
objects: Vec<(String, AddressObject)>,
not_found: Vec<String>,
path: &str,
principal: &str,
@@ -64,11 +64,12 @@ fn objects_response(
prop: &PropfindType<AddressObjectPropWrapperName>,
) -> Result<MultistatusElement<AddressObjectPropWrapper, String>, Error> {
let mut responses = Vec::new();
for object in objects {
let path = format!("{}/{}.vcf", path, object.get_id());
for (object_id, object) in objects {
let path = format!("{}/{}.vcf", path, &object_id);
responses.push(
AddressObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,

View File

@@ -32,11 +32,12 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
.await?;
let mut responses = Vec::new();
for object in new_objects {
let path = format!("{}/{}.vcf", path.trim_end_matches('/'), object.get_id());
for (object_id, object) in new_objects {
let path = format!("{}/{}.vcf", path.trim_end_matches('/'), object_id);
responses.push(
AddressObjectResource {
object,
object_id,
principal: principal.to_owned(),
}
.propfind(&path, &sync_collection.prop, None, puri, user)?,

View File

@@ -78,7 +78,8 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
.get_objects(principal, addressbook_id)
.await?
.into_iter()
.map(|object| AddressObjectResource {
.map(|(object_id, object)| AddressObjectResource {
object_id,
object,
principal: principal.to_owned(),
})
@@ -91,7 +92,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
file: Self::Resource,
) -> Result<(), Self::Error> {
self.addr_store
.update_addressbook(principal.to_owned(), addressbook_id.to_owned(), file.into())
.update_addressbook(principal, addressbook_id, file.into())
.await?;
Ok(())
}

View File

@@ -23,9 +23,6 @@ pub enum Error {
#[error(transparent)]
XmlDecodeError(#[from] rustical_xml::XmlError),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
}
impl Error {
@@ -43,7 +40,6 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND,
Self::IcalError(err) => err.status_code(),
}
}
}

View File

@@ -6,12 +6,15 @@ use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use axum_extra::TypedHeader;
use headers::Host;
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(path, resource_service,))]
#[allow(clippy::too_many_arguments)]
pub async fn axum_route_copy<R: ResourceService>(
Path(path): Path<R::PathComponents>,
State(resource_service): State<R>,
@@ -20,6 +23,7 @@ pub async fn axum_route_copy<R: ResourceService>(
Overwrite(overwrite): Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
TypedHeader(host): TypedHeader<Host>,
) -> Result<Response, R::Error> {
let destination = header_map
.get("Destination")
@@ -27,7 +31,11 @@ pub async fn axum_route_copy<R: ResourceService>(
.to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
if let Some(authority) = destination_uri.authority()
&& host != authority.clone().into()
{
return Err(crate::Error::Forbidden.into());
}
let destination = destination_uri.path();
let mut router = matchit::Router::new();

View File

@@ -52,11 +52,11 @@ pub async fn route_delete<R: ResourceService>(
return Err(Error::Unauthorized.into());
}
if let Some(if_match) = if_match {
if !resource.satisfies_if_match(&if_match) {
// Precondition failed
return Err(crate::Error::PreconditionFailed.into());
}
if let Some(if_match) = if_match
&& !resource.satisfies_if_match(&if_match)
{
// Precondition failed
return Err(crate::Error::PreconditionFailed.into());
}
if let Some(if_none_match) = if_none_match
&& resource.satisfies_if_none_match(&if_none_match)

View File

@@ -6,12 +6,15 @@ use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use axum_extra::TypedHeader;
use headers::Host;
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(path, resource_service,))]
#[allow(clippy::too_many_arguments)]
pub async fn axum_route_move<R: ResourceService>(
Path(path): Path<R::PathComponents>,
State(resource_service): State<R>,
@@ -20,6 +23,7 @@ pub async fn axum_route_move<R: ResourceService>(
Overwrite(overwrite): Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
TypedHeader(host): TypedHeader<Host>,
) -> Result<Response, R::Error> {
let destination = header_map
.get("Destination")
@@ -27,7 +31,11 @@ pub async fn axum_route_move<R: ResourceService>(
.to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
if let Some(authority) = destination_uri.authority()
&& host != authority.clone().into()
{
return Err(crate::Error::Forbidden.into());
}
let destination = destination_uri.path();
let mut router = matchit::Router::new();

View File

@@ -45,7 +45,7 @@ impl<PN: XmlDeserialize> XmlDeserialize for PropElement<PN> {
// start of a child element
Event::Start(start) | Event::Empty(start) => {
let empty = matches!(event, Event::Empty(_));
let (ns, name) = reader.resolve_element(start.name());
let (ns, name) = reader.resolver().resolve_element(start.name());
let ns = match ns {
ResolveResult::Bound(ns) => Some(NamespaceOwned::from(ns)),
ResolveResult::Unknown(_ns) => todo!("handle error"),

View File

@@ -1,4 +1,4 @@
use ical::property::Property;
use ical::property::ContentLine;
use rustical_xml::{ValueDeserialize, XmlDeserialize};
use std::borrow::Cow;
@@ -128,7 +128,7 @@ impl TextMatchElement {
negate_condition.0 ^ matches
}
#[must_use]
pub fn match_property(&self, property: &Property) -> bool {
pub fn match_property(&self, property: &ContentLine) -> bool {
let text = property.value.as_deref().unwrap_or("");
self.match_text(text)
}

View File

@@ -21,3 +21,5 @@ rrule.workspace = true
serde.workspace = true
sha2.workspace = true
axum.workspace = true
rstest.workspace = true
similar-asserts.workspace = true

View File

@@ -1,59 +1,45 @@
use crate::{CalDateTime, LOCAL_DATE};
use crate::{CalendarObject, Error};
use chrono::Datelike;
use ical::generator::Emitter;
use ical::parser::{
Component,
vcard::{self, component::VcardContact},
use chrono::{NaiveDate, Utc};
use ical::component::{
CalendarInnerDataBuilder, IcalAlarmBuilder, IcalCalendarObjectBuilder, IcalEventBuilder,
};
use ical::generator::Emitter;
use ical::parser::vcard::{self, component::VcardContact};
use ical::parser::{
Calscale, ComponentMut, IcalCALSCALEProperty, IcalDTENDProperty, IcalDTSTAMPProperty,
IcalDTSTARTProperty, IcalPRODIDProperty, IcalRRULEProperty, IcalSUMMARYProperty,
IcalUIDProperty, IcalVERSIONProperty, IcalVersion, VcardANNIVERSARYProperty, VcardBDAYProperty,
VcardFNProperty,
};
use ical::property::ContentLine;
use ical::types::{CalDate, PartialDate};
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
use std::collections::HashMap;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct AddressObject {
id: String,
vcf: String,
vcard: VcardContact,
}
impl TryFrom<VcardContact> for AddressObject {
type Error = Error;
fn try_from(vcard: VcardContact) -> Result<Self, Self::Error> {
let uid = vcard
.get_uid()
.ok_or_else(|| Error::InvalidData("missing UID".to_owned()))?
.to_owned();
impl From<VcardContact> for AddressObject {
fn from(vcard: VcardContact) -> Self {
let vcf = vcard.generate();
Ok(Self {
vcf,
vcard,
id: uid,
})
Self { vcf, vcard }
}
}
impl AddressObject {
pub fn from_vcf(id: String, vcf: String) -> Result<Self, Error> {
let mut parser = vcard::VcardParser::new(BufReader::new(vcf.as_bytes()));
let vcard = parser.next().ok_or(Error::MissingContact)??;
if parser.next().is_some() {
return Err(Error::InvalidData(
"multiple vcards, only one allowed".to_owned(),
));
}
Ok(Self { id, vcf, vcard })
}
#[must_use]
pub fn get_id(&self) -> &str {
&self.id
pub fn from_vcf(vcf: String) -> Result<Self, Error> {
let parser = vcard::VcardParser::from_slice(vcf.as_bytes());
let vcard = parser.expect_one()?;
Ok(Self { vcf, vcard })
}
#[must_use]
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.get_id());
hasher.update(self.get_vcf());
format!("\"{:x}\"", hasher.finalize())
}
@@ -63,121 +49,115 @@ impl AddressObject {
&self.vcf
}
#[must_use]
pub fn get_anniversary(&self) -> Option<(CalDateTime, bool)> {
let prop = self.vcard.get_property("ANNIVERSARY")?.value.as_deref()?;
CalDateTime::parse_vcard(prop).ok()
}
fn get_significant_date_object(
&self,
date: &PartialDate,
summary_prefix: &str,
suffix: &str,
) -> Result<Option<CalendarObject>, Error> {
let Some(uid) = self.vcard.get_uid() else {
return Ok(None);
};
let uid = format!("{uid}{suffix}");
let year = date.get_year();
let year_suffix = year.map(|year| format!(" {year}")).unwrap_or_default();
let Some(month) = date.get_month() else {
return Ok(None);
};
let Some(day) = date.get_day() else {
return Ok(None);
};
let Some(dtstart) = NaiveDate::from_ymd_opt(year.unwrap_or(1900), month, day) else {
return Ok(None);
};
let start_date = CalDate(dtstart, ical::types::Timezone::Local);
let Some(end_date) = start_date.succ_opt() else {
// start_date is MAX_DATE, this should never happen but FAPP also not raise an error
return Ok(None);
};
let Some(VcardFNProperty(fullname, _)) = self.vcard.full_name.first() else {
return Ok(None);
};
let summary = format!("{summary_prefix} {fullname}{year_suffix}");
#[must_use]
pub fn get_birthday(&self) -> Option<(CalDateTime, bool)> {
let prop = self.vcard.get_property("BDAY")?.value.as_deref()?;
CalDateTime::parse_vcard(prop).ok()
}
let event = IcalEventBuilder {
properties: vec![
IcalDTSTAMPProperty(Utc::now().into(), vec![].into()).into(),
IcalDTSTARTProperty(start_date.into(), vec![].into()).into(),
IcalDTENDProperty(end_date.into(), vec![].into()).into(),
IcalUIDProperty(uid, vec![].into()).into(),
IcalRRULEProperty(
rrule::RRule::from_str("FREQ=YEARLY").unwrap(),
vec![].into(),
)
.into(),
IcalSUMMARYProperty(summary.clone(), vec![].into()).into(),
ContentLine {
name: "TRANSP".to_owned(),
value: Some("TRANSPARENT".to_owned()),
..Default::default()
},
],
alarms: vec![IcalAlarmBuilder {
properties: vec![
ContentLine {
name: "TRIGGER".to_owned(),
value: Some("-PT0M".to_owned()),
params: vec![("VALUE".to_owned(), vec!["DURATION".to_owned()])].into(),
},
ContentLine {
name: "ACTION".to_owned(),
value: Some("DISPLAY".to_owned()),
..Default::default()
},
ContentLine {
name: "DESCRIPTION".to_owned(),
value: Some(summary),
..Default::default()
},
],
}],
};
#[must_use]
pub fn get_full_name(&self) -> Option<&str> {
let prop = self.vcard.get_property("FN")?;
prop.value.as_deref()
Ok(Some(
IcalCalendarObjectBuilder {
properties: vec![
IcalVERSIONProperty(IcalVersion::Version2_0, vec![].into()).into(),
IcalCALSCALEProperty(Calscale::Gregorian, vec![].into()).into(),
IcalPRODIDProperty(
"-//github.com/lennart-k/rustical birthday calendar//EN".to_owned(),
vec![].into(),
)
.into(),
],
inner: Some(CalendarInnerDataBuilder::Event(vec![event])),
vtimezones: HashMap::default(),
}
.build(None)?
.into(),
))
}
pub fn get_anniversary_object(&self) -> Result<Option<CalendarObject>, Error> {
Ok(
if let Some((anniversary, contains_year)) = self.get_anniversary() {
let Some(fullname) = self.get_full_name() else {
return Ok(None);
};
let anniversary = anniversary.date();
let year = contains_year.then_some(anniversary.year());
let anniversary_start = anniversary.format(LOCAL_DATE);
let anniversary_end = anniversary
.succ_opt()
.unwrap_or(anniversary)
.format(LOCAL_DATE);
let uid = format!("{}-anniversary", self.get_id());
let Some(VcardANNIVERSARYProperty(anniversary, _)) = &self.vcard.anniversary else {
return Ok(None);
};
let Some(date) = &anniversary.date else {
return Ok(None);
};
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
format!(
r"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
BEGIN:VEVENT
DTSTART;VALUE=DATE:{anniversary_start}
DTEND;VALUE=DATE:{anniversary_end}
UID:{uid}
RRULE:FREQ=YEARLY
SUMMARY:💍 {fullname}{year_suffix}
TRANSP:TRANSPARENT
BEGIN:VALARM
TRIGGER;VALUE=DURATION:-PT0M
ACTION:DISPLAY
DESCRIPTION:💍 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR",
),
None,
)?)
} else {
None
},
)
self.get_significant_date_object(date, "💍", "-anniversary")
}
pub fn get_birthday_object(&self) -> Result<Option<CalendarObject>, Error> {
Ok(
if let Some((birthday, contains_year)) = self.get_birthday() {
let Some(fullname) = self.get_full_name() else {
return Ok(None);
};
let birthday = birthday.date();
let year = contains_year.then_some(birthday.year());
let birthday_start = birthday.format(LOCAL_DATE);
let birthday_end = birthday.succ_opt().unwrap_or(birthday).format(LOCAL_DATE);
let uid = format!("{}-birthday", self.get_id());
let Some(VcardBDAYProperty(bday, _)) = &self.vcard.birthday else {
return Ok(None);
};
let Some(date) = &bday.date else {
return Ok(None);
};
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
format!(
r"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
BEGIN:VEVENT
DTSTART;VALUE=DATE:{birthday_start}
DTEND;VALUE=DATE:{birthday_end}
UID:{uid}
RRULE:FREQ=YEARLY
SUMMARY:🎂 {fullname}{year_suffix}
TRANSP:TRANSPARENT
BEGIN:VALARM
TRIGGER;VALUE=DURATION:-PT0M
ACTION:DISPLAY
DESCRIPTION:🎂 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR",
),
None,
)?)
} else {
None
},
)
}
/// Get significant dates associated with this address object
pub fn get_significant_dates(&self) -> Result<HashMap<&'static str, CalendarObject>, Error> {
let mut out = HashMap::new();
if let Some(birthday) = self.get_birthday_object()? {
out.insert("birthday", birthday);
}
if let Some(anniversary) = self.get_anniversary_object()? {
out.insert("anniversary", anniversary);
}
Ok(out)
self.get_significant_date_object(date, "🎂", "-birthday")
}
#[must_use]

View File

@@ -0,0 +1,120 @@
use crate::Error;
use derive_more::Display;
use ical::IcalObjectParser;
use ical::component::CalendarInnerData;
use ical::component::IcalCalendarObject;
use ical::generator::Emitter;
use serde::Deserialize;
use serde::Serialize;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
Event = 0,
#[serde(rename = "VTODO")]
Todo = 1,
#[serde(rename = "VJOURNAL")]
Journal = 2,
}
impl From<&IcalCalendarObject> for CalendarObjectType {
fn from(value: &IcalCalendarObject) -> Self {
match value.get_inner() {
CalendarInnerData::Event(_, _) => Self::Event,
CalendarInnerData::Todo(_, _) => Self::Todo,
CalendarInnerData::Journal(_, _) => Self::Journal,
}
}
}
impl CalendarObjectType {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Event => "VEVENT",
Self::Todo => "VTODO",
Self::Journal => "VJOURNAL",
}
}
}
impl rustical_xml::ValueSerialize for CalendarObjectType {
fn serialize(&self) -> String {
self.as_str().to_owned()
}
}
impl rustical_xml::ValueDeserialize for CalendarObjectType {
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
"VEVENT" => Ok(Self::Event),
"VTODO" => Ok(Self::Todo),
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
)),
)),
}
}
}
#[derive(Debug, Clone)]
pub struct CalendarObject {
inner: IcalCalendarObject,
ics: String,
}
impl CalendarObject {
pub fn from_ics(ics: String) -> Result<Self, Error> {
let parser = IcalObjectParser::from_slice(ics.as_bytes());
let inner = parser.expect_one()?;
Ok(Self { inner, ics })
}
#[must_use]
pub const fn get_inner(&self) -> &IcalCalendarObject {
&self.inner
}
#[must_use]
pub fn get_uid(&self) -> &str {
self.inner.get_uid()
}
#[must_use]
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.get_uid());
hasher.update(self.get_ics());
format!("\"{:x}\"", hasher.finalize())
}
#[must_use]
pub fn get_ics(&self) -> &str {
&self.ics
}
#[must_use]
pub fn get_object_type(&self) -> CalendarObjectType {
(&self.inner).into()
}
}
impl From<CalendarObject> for IcalCalendarObject {
fn from(value: CalendarObject) -> Self {
value.inner
}
}
impl From<IcalCalendarObject> for CalendarObject {
fn from(value: IcalCalendarObject) -> Self {
Self {
ics: value.generate(),
inner: value,
}
}
}

View File

@@ -1,42 +0,0 @@
use axum::{http::StatusCode, response::IntoResponse};
use crate::CalDateTimeError;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
#[error("Invalid ics/vcf input: {0}")]
InvalidData(String),
#[error("Missing calendar")]
MissingCalendar,
#[error("Missing contact")]
MissingContact,
#[error(transparent)]
ParserError(#[from] ical::parser::ParserError),
#[error(transparent)]
CalDateTimeError(#[from] CalDateTimeError),
#[error(transparent)]
RRuleError(#[from] rrule::RRuleError),
}
impl Error {
#[must_use]
pub const fn status_code(&self) -> StatusCode {
match self {
Self::InvalidData(_) | Self::MissingCalendar | Self::MissingContact => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
(self.status_code(), self.to_string()).into_response()
}
}

View File

@@ -1,268 +0,0 @@
use crate::CalDateTime;
use crate::Error;
use chrono::{DateTime, Duration, Utc};
use ical::parser::ComponentMut;
use ical::{generator::IcalEvent, parser::Component, property::Property};
use rrule::{RRule, RRuleSet};
use std::{collections::HashMap, str::FromStr};
#[derive(Debug, Clone, Default)]
pub struct EventObject {
pub event: IcalEvent,
// If a timezone is None that means that in the VCALENDAR object there's a timezone defined
// with that name but its not from the Olson DB
pub timezones: HashMap<String, Option<chrono_tz::Tz>>,
}
impl EventObject {
#[must_use]
pub fn get_uid(&self) -> &str {
self.event.get_uid()
}
pub fn get_dtstart(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtstart) = self.event.get_dtstart() {
Ok(Some(CalDateTime::parse_prop(dtstart, &self.timezones)?))
} else {
Ok(None)
}
}
pub fn get_dtend(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtend) = self.event.get_dtend() {
Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?))
} else {
Ok(None)
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
if self.event.get_rrule().is_some() {
// TODO: understand recurrence rules
return Ok(None);
}
if let Some(dtend) = self.get_dtend()? {
return Ok(Some(dtend));
}
let duration = self.event.get_duration().unwrap_or(Duration::days(1));
let first_occurence = self.get_dtstart()?;
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
}
pub fn recurrence_ruleset(&self) -> Result<Option<rrule::RRuleSet>, Error> {
let dtstart: DateTime<rrule::Tz> = if let Some(dtstart) = self.get_dtstart()? {
if let Some(dtend) = self.get_dtend()? {
// DTSTART and DTEND MUST have the same timezone
assert_eq!(dtstart.timezone(), dtend.timezone());
}
dtstart
.as_datetime()
.with_timezone(&dtstart.timezone().into())
} else {
return Ok(None);
};
let mut rrule_set = RRuleSet::new(dtstart);
for prop in &self.event.properties {
rrule_set = match prop.name.as_str() {
"RRULE" => {
let rrule = RRule::from_str(prop.value.as_ref().ok_or_else(|| {
Error::RRuleError(rrule::ParseError::MissingDateGenerationRules.into())
})?)?
.validate(dtstart)
.unwrap();
rrule_set.rrule(rrule)
}
"RDATE" => {
let rdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
rrule_set.rdate(rdate)
}
"EXDATE" => {
let exdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
rrule_set.exdate(exdate)
}
_ => rrule_set,
}
}
Ok(Some(rrule_set))
}
pub fn expand_recurrence(
&self,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
overrides: &[Self],
) -> Result<Vec<IcalEvent>, Error> {
let Some(mut rrule_set) = self.recurrence_ruleset()? else {
return Ok(vec![self.event.clone()]);
};
if let Some(start) = start {
rrule_set = rrule_set.after(start.with_timezone(&rrule::Tz::UTC));
}
if let Some(end) = end {
rrule_set = rrule_set.before(end.with_timezone(&rrule::Tz::UTC));
}
let mut events = vec![];
let dates = rrule_set.all(2048).dates;
let dtstart = self.get_dtstart()?.expect("We must have a DTSTART here");
let computed_duration = self
.get_dtend()?
.map(|dtend| dtend.as_datetime().into_owned() - dtstart.as_datetime().as_ref());
'recurrence: for date in dates {
let date = CalDateTime::from(date);
let dateformat = if dtstart.is_date() {
date.format_date()
} else {
date.format()
};
for ev_override in overrides {
if let Some(override_id) = &ev_override
.event
.get_recurrence_id()
.as_ref()
.expect("overrides have a recurrence id")
.value
&& override_id == &dateformat
{
// We have an override for this occurence
//
events.push(ev_override.event.clone());
continue 'recurrence;
}
}
let mut ev = self.event.clone().mutable();
ev.remove_property("RRULE");
ev.remove_property("RDATE");
ev.remove_property("EXDATE");
ev.remove_property("EXRULE");
let dtstart_prop = ev
.get_property("DTSTART")
.expect("We must have a DTSTART here")
.clone();
ev.remove_property("DTSTART");
ev.remove_property("DTEND");
ev.set_property(Property {
name: "RECURRENCE-ID".to_string(),
value: Some(dateformat.clone()),
params: vec![],
});
ev.set_property(Property {
name: "DTSTART".to_string(),
value: Some(dateformat),
params: dtstart_prop.params.clone(),
});
if let Some(duration) = computed_duration {
let dtend = date + duration;
let dtendformat = if dtstart.is_date() {
dtend.format_date()
} else {
dtend.format()
};
ev.set_property(Property {
name: "DTEND".to_string(),
value: Some(dtendformat),
params: dtstart_prop.params,
});
}
events.push(ev.verify()?);
}
Ok(events)
}
}
#[cfg(test)]
mod tests {
use crate::CalendarObject;
use ical::generator::Emitter;
const ICS: &str = r"BEGIN:VCALENDAR
CALSCALE:GREGORIAN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
END:VTIMEZONE
BEGIN:VEVENT
UID:318ec6503573d9576818daf93dac07317058d95c
DTSTAMP:20250502T132758Z
DTSTART;TZID=Europe/Berlin:20250506T090000
DTEND;TZID=Europe/Berlin:20250506T092500
SEQUENCE:2
SUMMARY:weekly stuff
TRANSP:OPAQUE
RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU
END:VEVENT
END:VCALENDAR";
const EXPANDED: [&str; 4] = [
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250506T090000\r
DTSTART;TZID=Europe/Berlin:20250506T090000\r
DTEND;TZID=Europe/Berlin:20250506T092500\r
END:VEVENT\r\n",
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250508T090000\r
DTSTART;TZID=Europe/Berlin:20250508T090000\r
DTEND;TZID=Europe/Berlin:20250508T092500\r
END:VEVENT\r\n",
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250511T090000\r
DTSTART;TZID=Europe/Berlin:20250511T090000\r
DTEND;TZID=Europe/Berlin:20250511T092500\r
END:VEVENT\r\n",
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250520T090000\r
DTSTART;TZID=Europe/Berlin:20250520T090000\r
DTEND;TZID=Europe/Berlin:20250520T092500\r
END:VEVENT\r\n",
];
#[test]
fn test_expand_recurrence() {
let event = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
let crate::CalendarObjectComponent::Event(event, overrides) = event.get_data() else {
panic!()
};
let events: Vec<String> = event
.expand_recurrence(None, None, overrides)
.unwrap()
.into_iter()
.map(|event| Emitter::generate(&event))
.collect();
assert_eq!(events.as_slice()[0], EXPANDED[0]);
assert_eq!(events.as_slice(), &EXPANDED);
}
}

View File

@@ -1,5 +0,0 @@
mod event;
mod object;
pub use event::*;
pub use object::*;

View File

@@ -1,339 +0,0 @@
use super::EventObject;
use crate::CalDateTime;
use crate::Error;
use chrono::DateTime;
use chrono::Utc;
use derive_more::Display;
use ical::generator::{Emitter, IcalCalendar};
use ical::parser::ical::component::IcalJournal;
use ical::parser::ical::component::IcalTimeZone;
use ical::parser::ical::component::IcalTodo;
use ical::property::Property;
use serde::Deserialize;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
Event = 0,
#[serde(rename = "VTODO")]
Todo = 1,
#[serde(rename = "VJOURNAL")]
Journal = 2,
}
impl CalendarObjectType {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Event => "VEVENT",
Self::Todo => "VTODO",
Self::Journal => "VJOURNAL",
}
}
}
impl rustical_xml::ValueSerialize for CalendarObjectType {
fn serialize(&self) -> String {
self.as_str().to_owned()
}
}
impl rustical_xml::ValueDeserialize for CalendarObjectType {
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
"VEVENT" => Ok(Self::Event),
"VTODO" => Ok(Self::Todo),
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
)),
)),
}
}
}
#[derive(Debug, Clone)]
pub enum CalendarObjectComponent {
Event(EventObject, Vec<EventObject>),
Todo(IcalTodo, Vec<IcalTodo>),
Journal(IcalJournal, Vec<IcalJournal>),
}
impl CalendarObjectComponent {
#[must_use]
pub fn get_uid(&self) -> &str {
match &self {
// We've made sure before that the first component exists and all components share the
// same UID
Self::Todo(todo, _) => todo.get_uid(),
Self::Event(event, _) => event.event.get_uid(),
Self::Journal(journal, _) => journal.get_uid(),
}
}
}
impl From<&CalendarObjectComponent> for CalendarObjectType {
fn from(value: &CalendarObjectComponent) -> Self {
match value {
CalendarObjectComponent::Event(..) => Self::Event,
CalendarObjectComponent::Todo(..) => Self::Todo,
CalendarObjectComponent::Journal(..) => Self::Journal,
}
}
}
impl CalendarObjectComponent {
fn from_events(mut events: Vec<EventObject>) -> Result<Self, Error> {
let main_event = events
.extract_if(.., |event| event.event.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = events;
for event in &overrides {
if event.get_uid() != main_event.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if event.event.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Event(main_event, overrides))
}
fn from_todos(mut todos: Vec<IcalTodo>) -> Result<Self, Error> {
let main_todo = todos
.extract_if(.., |todo| todo.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = todos;
for todo in &overrides {
if todo.get_uid() != main_todo.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if todo.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Todo(main_todo, overrides))
}
fn from_journals(mut journals: Vec<IcalJournal>) -> Result<Self, Error> {
let main_journal = journals
.extract_if(.., |journal| journal.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = journals;
for journal in &overrides {
if journal.get_uid() != main_journal.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if journal.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Journal(main_journal, overrides))
}
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self {
Self::Event(main_event, overrides) => Ok(overrides
.iter()
.chain(std::iter::once(main_event))
.map(super::event::EventObject::get_dtstart)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.min()),
_ => Ok(None),
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self {
Self::Event(main_event, overrides) => Ok(overrides
.iter()
.chain(std::iter::once(main_event))
.map(super::event::EventObject::get_last_occurence)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.max()),
_ => Ok(None),
}
}
}
#[derive(Debug, Clone)]
pub struct CalendarObject {
data: CalendarObjectComponent,
properties: Vec<Property>,
id: String,
ics: String,
vtimezones: HashMap<String, IcalTimeZone>,
}
impl CalendarObject {
pub fn from_ics(ics: String, id: Option<String>) -> Result<Self, Error> {
let mut parser = ical::IcalParser::new(BufReader::new(ics.as_bytes()));
let cal = parser.next().ok_or(Error::MissingCalendar)??;
if parser.next().is_some() {
return Err(Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
));
}
if u8::from(!cal.events.is_empty())
+ u8::from(!cal.todos.is_empty())
+ u8::from(!cal.journals.is_empty())
+ u8::from(!cal.free_busys.is_empty())
!= 1
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-4.1
return Err(Error::InvalidData(
"iCalendar object must have exactly one component type".to_owned(),
));
}
let timezones: HashMap<String, Option<chrono_tz::Tz>> = cal
.timezones
.clone()
.into_iter()
.map(|timezone| (timezone.get_tzid().to_owned(), (&timezone).try_into().ok()))
.collect();
let vtimezones = cal
.timezones
.clone()
.into_iter()
.map(|timezone| (timezone.get_tzid().to_owned(), timezone))
.collect();
let data = if !cal.events.is_empty() {
CalendarObjectComponent::from_events(
cal.events
.into_iter()
.map(|event| EventObject {
event,
timezones: timezones.clone(),
})
.collect(),
)?
} else if !cal.todos.is_empty() {
CalendarObjectComponent::from_todos(cal.todos)?
} else if !cal.journals.is_empty() {
CalendarObjectComponent::from_journals(cal.journals)?
} else {
return Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
));
};
Ok(Self {
id: id.unwrap_or_else(|| data.get_uid().to_owned()),
data,
properties: cal.properties,
ics,
vtimezones,
})
}
#[must_use]
pub const fn get_vtimezones(&self) -> &HashMap<String, IcalTimeZone> {
&self.vtimezones
}
#[must_use]
pub const fn get_data(&self) -> &CalendarObjectComponent {
&self.data
}
#[must_use]
pub fn get_uid(&self) -> &str {
self.data.get_uid()
}
#[must_use]
pub fn get_id(&self) -> &str {
&self.id
}
#[must_use]
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.get_uid());
hasher.update(self.get_ics());
format!("\"{:x}\"", hasher.finalize())
}
#[must_use]
pub fn get_ics(&self) -> &str {
&self.ics
}
#[must_use]
pub fn get_component_name(&self) -> &str {
self.get_object_type().as_str()
}
#[must_use]
pub fn get_object_type(&self) -> CalendarObjectType {
(&self.data).into()
}
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
self.data.get_first_occurence()
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
self.data.get_last_occurence()
}
pub fn expand_recurrence(
&self,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
) -> Result<String, Error> {
// Only events can be expanded
match &self.data {
CalendarObjectComponent::Event(main_event, overrides) => {
let cal = IcalCalendar {
properties: self.properties.clone(),
events: main_event.expand_recurrence(start, end, overrides)?,
..Default::default()
};
Ok(cal.generate())
}
_ => Ok(self.get_ics().to_string()),
}
}
#[must_use]
pub fn get_property(&self, name: &str) -> Option<&Property> {
self.properties
.iter()
.find(|property| property.name == name)
}
#[must_use]
pub fn get_named_properties(&self, name: &str) -> Vec<&Property> {
self.properties
.iter()
.filter(|property| property.name == name)
.collect()
}
}

View File

@@ -1,15 +1,13 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
mod timestamp;
mod timezone;
use ical::parser::ParserError;
pub use timestamp::*;
pub use timezone::*;
mod icalendar;
pub use icalendar::*;
mod error;
pub use error::Error;
mod calendar_object;
pub use calendar_object::*;
mod address_object;
pub use address_object::AddressObject;
pub type Error = ParserError;

View File

@@ -1,35 +1,8 @@
use super::timezone::ICalTimezone;
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz;
use chrono::{DateTime, NaiveDateTime, Utc};
use derive_more::derive::Deref;
use ical::property::Property;
use rustical_xml::{ValueDeserialize, ValueSerialize};
use std::{borrow::Cow, collections::HashMap, ops::Add, sync::LazyLock};
static RE_VCARD_DATE_MM_DD: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^--(?<m>\d{2})(?<d>\d{2})$").unwrap());
const LOCAL_DATE_TIME: &str = "%Y%m%dT%H%M%S";
const UTC_DATE_TIME: &str = "%Y%m%dT%H%M%SZ";
pub const LOCAL_DATE: &str = "%Y%m%d";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum CalDateTimeError {
#[error(
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {0} is invalid"
)]
InvalidOlson(String),
#[error("TZID {0} does not refer to a valid timezone")]
InvalidTZID(String),
#[error("Timestamp doesn't exist because of gap in local time")]
LocalTimeGap,
#[error("Datetime string {0} has an invalid format")]
InvalidDatetimeFormat(String),
#[error("Could not parse datetime {0}")]
ParseError(String),
#[error("Duration string {0} has an invalid format")]
InvalidDurationFormat(String),
}
#[derive(Debug, Clone, Deref, PartialEq, Eq, Hash)]
pub struct UtcDateTime(pub DateTime<Utc>);
@@ -54,367 +27,3 @@ impl ValueSerialize for UtcDateTime {
format!("{}", self.0.format(UTC_DATE_TIME))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CalDateTime {
// Form 1, example: 19980118T230000 -> Local
// Form 2, example: 19980119T070000Z -> UTC
// Form 3, example: TZID=America/New_York:19980119T020000 -> Olson
// https://en.wikipedia.org/wiki/Tz_database
DateTime(DateTime<ICalTimezone>),
Date(NaiveDate, ICalTimezone),
}
impl From<CalDateTime> for DateTime<rrule::Tz> {
fn from(value: CalDateTime) -> Self {
value
.as_datetime()
.into_owned()
.with_timezone(&value.timezone().into())
}
}
impl From<DateTime<rrule::Tz>> for CalDateTime {
fn from(value: DateTime<rrule::Tz>) -> Self {
Self::DateTime(value.with_timezone(&value.timezone().into()))
}
}
impl PartialOrd for CalDateTime {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CalDateTime {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (&self, &other) {
(Self::DateTime(a), Self::DateTime(b)) => a.cmp(b),
(Self::DateTime(a), Self::Date(..)) => a.cmp(&other.as_datetime()),
(Self::Date(..), Self::DateTime(b)) => self.as_datetime().as_ref().cmp(b),
(Self::Date(..), Self::Date(..)) => self.as_datetime().cmp(&other.as_datetime()),
}
}
}
impl From<DateTime<Local>> for CalDateTime {
fn from(value: DateTime<Local>) -> Self {
Self::DateTime(value.with_timezone(&ICalTimezone::Local))
}
}
impl From<DateTime<Utc>> for CalDateTime {
fn from(value: DateTime<Utc>) -> Self {
Self::DateTime(value.with_timezone(&ICalTimezone::Olson(chrono_tz::UTC)))
}
}
impl Add<Duration> for CalDateTime {
type Output = Self;
fn add(self, duration: Duration) -> Self::Output {
match self {
Self::DateTime(datetime) => Self::DateTime(datetime + duration),
Self::Date(date, tz) => Self::DateTime(
date.and_time(NaiveTime::default())
.and_local_timezone(tz)
.earliest()
.expect("Local timezone has constant offset")
+ duration,
),
}
}
}
impl CalDateTime {
pub fn parse_prop(
prop: &Property,
timezones: &HashMap<String, Option<chrono_tz::Tz>>,
) -> Result<Self, CalDateTimeError> {
let prop_value = prop
.value
.as_ref()
.ok_or_else(|| CalDateTimeError::InvalidDatetimeFormat("empty property".into()))?;
let timezone = if let Some(tzid) = prop.get_param("TZID") {
if let Some(timezone) = timezones.get(tzid) {
timezone.to_owned()
} else {
// TZID refers to timezone that does not exist
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
}
} else {
// No explicit timezone specified.
// This is valid and will be localtime or UTC depending on the value
// We will stick to this default as documented in https://github.com/lennart-k/rustical/issues/102
None
};
Self::parse(prop_value, timezone)
}
#[must_use]
pub fn format(&self) -> String {
match self {
Self::DateTime(datetime) => match datetime.timezone() {
ICalTimezone::Olson(chrono_tz::UTC) => datetime.format(UTC_DATE_TIME).to_string(),
_ => datetime.format(LOCAL_DATE_TIME).to_string(),
},
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
}
}
#[must_use]
pub fn format_date(&self) -> String {
match self {
Self::DateTime(datetime) => datetime.format(LOCAL_DATE).to_string(),
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
}
}
#[must_use]
pub fn date(&self) -> NaiveDate {
match self {
Self::DateTime(datetime) => datetime.date_naive(),
Self::Date(date, _) => date.to_owned(),
}
}
#[must_use]
pub const fn is_date(&self) -> bool {
matches!(&self, Self::Date(_, _))
}
#[must_use]
pub fn as_datetime(&self) -> Cow<'_, DateTime<ICalTimezone>> {
match self {
Self::DateTime(datetime) => Cow::Borrowed(datetime),
Self::Date(date, tz) => Cow::Owned(
date.and_time(NaiveTime::default())
.and_local_timezone(tz.to_owned())
.earliest()
.expect("Midnight always exists"),
),
}
}
pub fn parse(value: &str, timezone: Option<Tz>) -> Result<Self, CalDateTimeError> {
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATE_TIME) {
if let Some(timezone) = timezone {
return Ok(Self::DateTime(
datetime
.and_local_timezone(timezone.into())
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
return Ok(Self::DateTime(
datetime
.and_local_timezone(ICalTimezone::Local)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) {
return Ok(datetime.and_utc().into());
}
let timezone = timezone.map_or(ICalTimezone::Local, ICalTimezone::Olson);
if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) {
return Ok(Self::Date(date, timezone));
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
return Ok(Self::Date(date, timezone));
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") {
return Ok(Self::Date(date, timezone));
}
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
}
// Also returns whether the date contains a year
pub fn parse_vcard(value: &str) -> Result<(Self, bool), CalDateTimeError> {
if let Ok(datetime) = Self::parse(value, None) {
return Ok((datetime, true));
}
if let Some(captures) = RE_VCARD_DATE_MM_DD.captures(value) {
// Because 1972 is a leap year
let year = 1972;
// Cannot fail because of the regex
let month = captures.name("m").unwrap().as_str().parse().ok().unwrap();
let day = captures.name("d").unwrap().as_str().parse().ok().unwrap();
return Ok((
Self::Date(
NaiveDate::from_ymd_opt(year, month, day)
.ok_or_else(|| CalDateTimeError::ParseError(value.to_string()))?,
ICalTimezone::Local,
),
false,
));
}
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
}
#[must_use]
pub fn utc(&self) -> DateTime<Utc> {
self.as_datetime().to_utc()
}
#[must_use]
pub fn timezone(&self) -> ICalTimezone {
match &self {
Self::DateTime(datetime) => datetime.timezone(),
Self::Date(_, tz) => tz.to_owned(),
}
}
}
impl From<CalDateTime> for DateTime<Utc> {
fn from(value: CalDateTime) -> Self {
value.utc()
}
}
impl Datelike for CalDateTime {
fn year(&self) -> i32 {
match &self {
Self::DateTime(datetime) => datetime.year(),
Self::Date(date, _) => date.year(),
}
}
fn month(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.month(),
Self::Date(date, _) => date.month(),
}
}
fn month0(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.month0(),
Self::Date(date, _) => date.month0(),
}
}
fn day(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.day(),
Self::Date(date, _) => date.day(),
}
}
fn day0(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.day0(),
Self::Date(date, _) => date.day0(),
}
}
fn ordinal(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.ordinal(),
Self::Date(date, _) => date.ordinal(),
}
}
fn ordinal0(&self) -> u32 {
match &self {
Self::DateTime(datetime) => datetime.ordinal0(),
Self::Date(date, _) => date.ordinal0(),
}
}
fn weekday(&self) -> chrono::Weekday {
match &self {
Self::DateTime(datetime) => datetime.weekday(),
Self::Date(date, _) => date.weekday(),
}
}
fn iso_week(&self) -> chrono::IsoWeek {
match &self {
Self::DateTime(datetime) => datetime.iso_week(),
Self::Date(date, _) => date.iso_week(),
}
}
fn with_year(&self, year: i32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_year(year)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_year(year)?, tz.to_owned())),
}
}
fn with_month(&self, month: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_month(month)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_month(month)?, tz.to_owned())),
}
}
fn with_month0(&self, month0: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_month0(month0)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_month0(month0)?, tz.to_owned())),
}
}
fn with_day(&self, day: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_day(day)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_day(day)?, tz.to_owned())),
}
}
fn with_day0(&self, day0: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_day0(day0)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_day0(day0)?, tz.to_owned())),
}
}
fn with_ordinal(&self, ordinal: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_ordinal(ordinal)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_ordinal(ordinal)?, tz.to_owned())),
}
}
fn with_ordinal0(&self, ordinal0: u32) -> Option<Self> {
match &self {
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_ordinal0(ordinal0)?)),
Self::Date(date, tz) => Some(Self::Date(date.with_ordinal0(ordinal0)?, tz.to_owned())),
}
}
}
#[cfg(test)]
mod tests {
use crate::CalDateTime;
use chrono::NaiveDate;
#[test]
fn test_vcard_date() {
assert_eq!(
CalDateTime::parse_vcard("19850412").unwrap(),
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
crate::ICalTimezone::Local
),
true
)
);
assert_eq!(
CalDateTime::parse_vcard("1985-04-12").unwrap(),
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
crate::ICalTimezone::Local
),
true
)
);
assert_eq!(
CalDateTime::parse_vcard("--0412").unwrap(),
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1972, 4, 12).unwrap(),
crate::ICalTimezone::Local
),
false
)
);
}
}

View File

@@ -1,92 +0,0 @@
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use derive_more::{Display, From};
#[derive(Debug, Clone, From, PartialEq, Eq)]
pub enum ICalTimezone {
Local,
Olson(Tz),
}
impl From<ICalTimezone> for rrule::Tz {
fn from(value: ICalTimezone) -> Self {
match value {
ICalTimezone::Local => Self::LOCAL,
ICalTimezone::Olson(tz) => Self::Tz(tz),
}
}
}
impl From<rrule::Tz> for ICalTimezone {
fn from(value: rrule::Tz) -> Self {
match value {
rrule::Tz::Local(_) => Self::Local,
rrule::Tz::Tz(tz) => Self::Olson(tz),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Display)]
pub enum CalTimezoneOffset {
Local(chrono::FixedOffset),
Olson(chrono_tz::TzOffset),
}
impl chrono::Offset for CalTimezoneOffset {
fn fix(&self) -> chrono::FixedOffset {
match self {
Self::Local(local) => local.fix(),
Self::Olson(olson) => olson.fix(),
}
}
}
impl TimeZone for ICalTimezone {
type Offset = CalTimezoneOffset;
fn from_offset(offset: &Self::Offset) -> Self {
match offset {
CalTimezoneOffset::Local(_) => Self::Local,
CalTimezoneOffset::Olson(offset) => Self::Olson(Tz::from_offset(offset)),
}
}
fn offset_from_local_date(&self, local: &NaiveDate) -> chrono::MappedLocalTime<Self::Offset> {
match self {
Self::Local => Local
.offset_from_local_date(local)
.map(CalTimezoneOffset::Local),
Self::Olson(tz) => tz
.offset_from_local_date(local)
.map(CalTimezoneOffset::Olson),
}
}
fn offset_from_local_datetime(
&self,
local: &NaiveDateTime,
) -> chrono::MappedLocalTime<Self::Offset> {
match self {
Self::Local => Local
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Local),
Self::Olson(tz) => tz
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Olson),
}
}
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_datetime(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_datetime(utc)),
}
}
fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_date(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_date(utc)),
}
}
}

View File

@@ -25,6 +25,6 @@ END:VCALENDAR
#[test]
fn parse_calendar_object() {
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string(), None).unwrap();
object.expand_recurrence(None, None).unwrap();
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string()).unwrap();
object.get_inner().expand_recurrence(None, None);
}

View File

@@ -15,8 +15,8 @@ pub trait AddressbookStore: Send + Sync + 'static {
async fn update_addressbook(
&self,
principal: String,
id: String,
principal: &str,
id: &str,
addressbook: Addressbook,
) -> Result<(), Error>;
async fn insert_addressbook(&self, addressbook: Addressbook) -> Result<(), Error>;
@@ -33,7 +33,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
principal: &str,
addressbook_id: &str,
synctoken: i64,
) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>;
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), Error>;
async fn addressbook_metadata(
&self,
@@ -45,7 +45,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
&self,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<AddressObject>, Error>;
) -> Result<Vec<(String, AddressObject)>, Error>;
async fn get_object(
&self,
principal: &str,
@@ -55,8 +55,9 @@ pub trait AddressbookStore: Send + Sync + 'static {
) -> Result<AddressObject, Error>;
async fn put_object(
&self,
principal: String,
addressbook_id: String,
principal: &str,
addressbook_id: &str,
object_id: &str,
object: AddressObject,
overwrite: bool,
) -> Result<(), Error>;
@@ -77,7 +78,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
async fn import_addressbook(
&self,
addressbook: Addressbook,
objects: Vec<AddressObject>,
objects: Vec<(String, AddressObject)>,
merge_existing: bool,
) -> Result<(), Error>;
}

View File

@@ -22,8 +22,8 @@ pub trait CalendarStore: Send + Sync + 'static {
async fn update_calendar(
&self,
principal: String,
id: String,
principal: &str,
id: &str,
calendar: Calendar,
) -> Result<(), Error>;
async fn insert_calendar(&self, calendar: Calendar) -> Result<(), Error>;
@@ -46,7 +46,7 @@ pub trait CalendarStore: Send + Sync + 'static {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error>;
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error>;
/// Since the <calendar-query> rules are rather complex this function
/// is only meant to do some prefiltering
@@ -55,7 +55,7 @@ pub trait CalendarStore: Send + Sync + 'static {
principal: &str,
cal_id: &str,
_query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
self.get_objects(principal, cal_id).await
}
@@ -69,7 +69,7 @@ pub trait CalendarStore: Send + Sync + 'static {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error>;
) -> Result<Vec<(String, CalendarObject)>, Error>;
async fn get_object(
&self,
principal: &str,
@@ -79,20 +79,26 @@ pub trait CalendarStore: Send + Sync + 'static {
) -> Result<CalendarObject, Error>;
async fn put_objects(
&self,
principal: String,
cal_id: String,
objects: Vec<CalendarObject>,
principal: &str,
cal_id: &str,
objects: Vec<(String, CalendarObject)>,
overwrite: bool,
) -> Result<(), Error>;
async fn put_object(
&self,
principal: String,
cal_id: String,
principal: &str,
cal_id: &str,
object_id: &str,
object: CalendarObject,
overwrite: bool,
) -> Result<(), Error> {
self.put_objects(principal, cal_id, vec![object], overwrite)
.await
self.put_objects(
principal,
cal_id,
vec![(object_id.to_owned(), object)],
overwrite,
)
.await
}
async fn delete_object(
&self,

View File

@@ -1,5 +1,6 @@
use crate::CalendarStore;
use crate::{Calendar, CalendarStore, calendar_store::CalendarQuery};
use async_trait::async_trait;
use rustical_ical::CalendarObject;
use std::{collections::HashMap, sync::Arc};
pub trait PrefixedCalendarStore: CalendarStore {
@@ -51,11 +52,11 @@ impl CalendarStore for CombinedCalendarStore {
async fn update_calendar(
&self,
principal: String,
id: String,
calendar: crate::Calendar,
principal: &str,
id: &str,
calendar: Calendar,
) -> Result<(), crate::Error> {
self.store_for_id(&id)
self.store_for_id(id)
.update_calendar(principal, id, calendar)
.await
}
@@ -88,7 +89,7 @@ impl CalendarStore for CombinedCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<rustical_ical::CalendarObject>, Vec<String>, i64), crate::Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), crate::Error> {
self.store_for_id(cal_id)
.sync_changes(principal, cal_id, synctoken)
.await
@@ -97,7 +98,7 @@ impl CalendarStore for CombinedCalendarStore {
async fn import_calendar(
&self,
calendar: crate::Calendar,
objects: Vec<rustical_ical::CalendarObject>,
objects: Vec<CalendarObject>,
merge_existing: bool,
) -> Result<(), crate::Error> {
self.store_for_id(&calendar.id)
@@ -109,8 +110,8 @@ impl CalendarStore for CombinedCalendarStore {
&self,
principal: &str,
cal_id: &str,
query: crate::calendar_store::CalendarQuery,
) -> Result<Vec<rustical_ical::CalendarObject>, crate::Error> {
query: CalendarQuery,
) -> Result<Vec<(String, CalendarObject)>, crate::Error> {
self.store_for_id(cal_id)
.calendar_query(principal, cal_id, query)
.await
@@ -141,7 +142,7 @@ impl CalendarStore for CombinedCalendarStore {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<rustical_ical::CalendarObject>, crate::Error> {
) -> Result<Vec<(String, CalendarObject)>, crate::Error> {
self.store_for_id(cal_id)
.get_objects(principal, cal_id)
.await
@@ -149,12 +150,12 @@ impl CalendarStore for CombinedCalendarStore {
async fn put_objects(
&self,
principal: String,
cal_id: String,
objects: Vec<rustical_ical::CalendarObject>,
principal: &str,
cal_id: &str,
objects: Vec<(String, CalendarObject)>,
overwrite: bool,
) -> Result<(), crate::Error> {
self.store_for_id(&cal_id)
self.store_for_id(cal_id)
.put_objects(principal, cal_id, objects, overwrite)
.await
}

View File

@@ -26,7 +26,7 @@ pub enum Error {
Other(#[from] anyhow::Error),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
IcalError(#[from] ical::parser::ParserError),
}
impl Error {
@@ -36,7 +36,8 @@ impl Error {
Self::NotFound => StatusCode::NOT_FOUND,
Self::AlreadyExists => StatusCode::CONFLICT,
Self::ReadOnly => StatusCode::FORBIDDEN,
Self::IcalError(err) => err.status_code(),
// TODO: Can also be Bad Request, depending on when this is raised
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}

View File

@@ -20,6 +20,7 @@ rstest.workspace = true
criterion.workspace = true
[dependencies]
ical.workspace = true
tokio.workspace = true
rustical_store.workspace = true
async-trait.workspace = true
@@ -36,3 +37,4 @@ pbkdf2.workspace = true
rustical_ical.workspace = true
rstest = { workspace = true, optional = true }
sha2.workspace = true
regex.workspace = true

View File

@@ -34,16 +34,19 @@ fn benchmark(c: &mut Criterion) {
cal_store
});
let object = CalendarObject::from_ics(include_str!("ical_event.ics").to_owned(), None).unwrap();
let row = (
"asd".to_owned(),
CalendarObject::from_ics(include_str!("ical_event.ics").to_owned()).unwrap(),
);
let batch_size = 1000;
let objects: Vec<_> = std::iter::repeat_n(object.clone(), batch_size).collect();
let objects: Vec<_> = std::iter::repeat_n(row.clone(), batch_size).collect();
c.bench_function("put_batch", |b| {
b.to_async(&runtime).iter(async || {
// yeet
cal_store
.put_objects("user".to_owned(), "okwow".to_owned(), objects.clone(), true)
.put_objects("user", "okwow", objects.clone(), true)
.await
.unwrap();
});
@@ -54,7 +57,7 @@ fn benchmark(c: &mut Criterion) {
// yeet
for _ in 0..1000 {
cal_store
.put_object("user".to_owned(), "okwow".to_owned(), object.clone(), true)
.put_object("user", "okwow", &row.0, row.1.clone(), true)
.await
.unwrap();
}

View File

@@ -1,14 +1,13 @@
use crate::addressbook_store::SqliteAddressbookStore;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use rustical_ical::{AddressObject, CalendarObject, CalendarObjectType};
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarMetadata, CalendarStore, CollectionMetadata,
Error, PrefixedCalendarStore,
};
use sha2::{Digest, Sha256};
use sqlx::{Executor, Sqlite};
use std::collections::HashMap;
use tracing::instrument;
pub const BIRTHDAYS_PREFIX: &str = "_birthdays_";
@@ -269,17 +268,18 @@ impl CalendarStore for SqliteAddressbookStore {
#[instrument]
async fn update_calendar(
&self,
principal: String,
id: String,
principal: &str,
id: &str,
mut calendar: Calendar,
) -> Result<(), Error> {
assert_eq!(principal, calendar.principal);
assert_eq!(id, calendar.id);
calendar.id = calendar
.id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?
.to_string();
Self::_update_birthday_calendar(&self.db, &principal, &calendar).await
Self::_update_birthday_calendar(&self.db, principal, &calendar).await
}
#[instrument]
@@ -324,19 +324,35 @@ impl CalendarStore for SqliteAddressbookStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let (objects, deleted_objects, new_synctoken) =
AddressbookStore::sync_changes(self, principal, cal_id, synctoken).await?;
let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
.iter()
.map(AddressObject::get_birthday_object)
.collect();
let objects = objects?.into_iter().flatten().collect();
Ok((objects, deleted_objects, new_synctoken))
let mut out_objects = vec![];
for (object_id, object) in objects {
if let Some(birthday) = object.get_birthday_object()? {
out_objects.push((format!("{object_id}-birthday"), birthday));
}
if let Some(anniversary) = object.get_anniversary_object()? {
out_objects.push((format!("{object_id}-anniversayr"), anniversary));
}
}
let deleted_objects = deleted_objects
.into_iter()
.flat_map(|object_id| {
[
format!("{object_id}-birthday"),
format!("{object_id}-anniversary"),
]
})
.collect();
Ok((out_objects, deleted_objects, new_synctoken))
}
#[instrument]
@@ -356,21 +372,19 @@ impl CalendarStore for SqliteAddressbookStore {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
let mut objects = vec![];
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
AddressbookStore::get_objects(self, principal, cal_id)
.await?
.iter()
.map(AddressObject::get_significant_dates)
.collect();
let objects = objects?
.into_iter()
.flat_map(HashMap::into_values)
.collect();
for (object_id, object) in AddressbookStore::get_objects(self, principal, cal_id).await? {
if let Some(birthday) = object.get_birthday_object()? {
objects.push((format!("{object_id}-birthday"), birthday));
}
if let Some(anniversary) = object.get_anniversary_object()? {
objects.push((format!("{object_id}-anniversayr"), anniversary));
}
}
Ok(objects)
}
@@ -386,19 +400,22 @@ impl CalendarStore for SqliteAddressbookStore {
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let (addressobject_id, date_type) = object_id.rsplit_once('-').ok_or(Error::NotFound)?;
AddressbookStore::get_object(self, principal, cal_id, addressobject_id, show_deleted)
.await?
.get_significant_dates()?
.remove(date_type)
.ok_or(Error::NotFound)
let obj =
AddressbookStore::get_object(self, principal, cal_id, addressobject_id, show_deleted)
.await?;
match date_type {
"birthday" => Ok(obj.get_birthday_object()?.ok_or(Error::NotFound)?),
"anniversary" => Ok(obj.get_anniversary_object()?.ok_or(Error::NotFound)?),
_ => Err(Error::NotFound),
}
}
#[instrument]
async fn put_objects(
&self,
_principal: String,
_cal_id: String,
_objects: Vec<CalendarObject>,
_principal: &str,
_cal_id: &str,
_objects: Vec<(String, CalendarObject)>,
_overwrite: bool,
) -> Result<(), Error> {
Err(Error::ReadOnly)

View File

@@ -19,11 +19,11 @@ struct AddressObjectRow {
vcf: String,
}
impl TryFrom<AddressObjectRow> for AddressObject {
impl TryFrom<AddressObjectRow> for (String, AddressObject) {
type Error = rustical_store::Error;
fn try_from(value: AddressObjectRow) -> Result<Self, Self::Error> {
Ok(Self::from_vcf(value.id, value.vcf)?)
Ok((value.id, AddressObject::from_vcf(value.vcf)?))
}
}
@@ -290,7 +290,7 @@ impl SqliteAddressbookStore {
principal: &str,
addressbook_id: &str,
synctoken: i64,
) -> Result<(Vec<AddressObject>, Vec<String>, i64), rustical_store::Error> {
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), rustical_store::Error> {
struct Row {
object_id: String,
synctoken: i64,
@@ -318,7 +318,7 @@ impl SqliteAddressbookStore {
for Row { object_id, .. } in changes {
match Self::_get_object(&mut *conn, principal, addressbook_id, &object_id, false).await
{
Ok(object) => objects.push(object),
Ok(object) => objects.push((object_id, object)),
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
Err(err) => return Err(err),
}
@@ -353,7 +353,7 @@ impl SqliteAddressbookStore {
executor: E,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<AddressObject>, rustical_store::Error> {
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
sqlx::query_as!(
AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
@@ -374,7 +374,7 @@ impl SqliteAddressbookStore {
object_id: &str,
show_deleted: bool,
) -> Result<AddressObject, rustical_store::Error> {
sqlx::query_as!(
let (id, object) = sqlx::query_as!(
AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
principal,
@@ -385,17 +385,20 @@ impl SqliteAddressbookStore {
.fetch_one(executor)
.await
.map_err(crate::Error::from)?
.try_into()
.try_into()?;
assert_eq!(id, object_id);
Ok(object)
}
async fn _put_object<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
addressbook_id: &str,
object_id: &str,
object: &AddressObject,
overwrite: bool,
) -> Result<(), rustical_store::Error> {
let (object_id, vcf) = (object.get_id(), object.get_vcf());
let vcf = object.get_vcf();
(if overwrite {
sqlx::query!(
@@ -500,11 +503,13 @@ impl AddressbookStore for SqliteAddressbookStore {
#[instrument]
async fn update_addressbook(
&self,
principal: String,
id: String,
principal: &str,
id: &str,
addressbook: Addressbook,
) -> Result<(), rustical_store::Error> {
Self::_update_addressbook(&self.db, &principal, &id, &addressbook).await
assert_eq!(principal, &addressbook.principal);
assert_eq!(id, &addressbook.id);
Self::_update_addressbook(&self.db, principal, id, &addressbook).await
}
#[instrument]
@@ -569,7 +574,7 @@ impl AddressbookStore for SqliteAddressbookStore {
principal: &str,
addressbook_id: &str,
synctoken: i64,
) -> Result<(Vec<AddressObject>, Vec<String>, i64), rustical_store::Error> {
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), rustical_store::Error> {
Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await
}
@@ -601,7 +606,7 @@ impl AddressbookStore for SqliteAddressbookStore {
&self,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<AddressObject>, rustical_store::Error> {
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
Self::_get_objects(&self.db, principal, addressbook_id).await
}
@@ -619,8 +624,9 @@ impl AddressbookStore for SqliteAddressbookStore {
#[instrument]
async fn put_object(
&self,
principal: String,
addressbook_id: String,
principal: &str,
addressbook_id: &str,
object_id: &str,
object: AddressObject,
overwrite: bool,
) -> Result<(), rustical_store::Error> {
@@ -630,15 +636,21 @@ impl AddressbookStore for SqliteAddressbookStore {
.await
.map_err(crate::Error::from)?;
let object_id = object.get_id().to_owned();
Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?;
Self::_put_object(
&mut *tx,
principal,
addressbook_id,
object_id,
&object,
overwrite,
)
.await?;
let sync_token = Self::log_object_operation(
&mut tx,
&principal,
&addressbook_id,
&object_id,
principal,
addressbook_id,
object_id,
ChangeOperation::Add,
)
.await
@@ -648,7 +660,7 @@ impl AddressbookStore for SqliteAddressbookStore {
self.send_push_notification(
CollectionOperationInfo::Content { sync_token },
self.get_addressbook(&principal, &addressbook_id, false)
self.get_addressbook(principal, addressbook_id, false)
.await?
.push_topic,
);
@@ -733,7 +745,7 @@ impl AddressbookStore for SqliteAddressbookStore {
async fn import_addressbook(
&self,
addressbook: Addressbook,
objects: Vec<AddressObject>,
objects: Vec<(String, AddressObject)>,
merge_existing: bool,
) -> Result<(), Error> {
let mut tx = self
@@ -758,11 +770,12 @@ impl AddressbookStore for SqliteAddressbookStore {
}
let mut sync_token = None;
for object in objects {
for (object_id, object) in objects {
Self::_put_object(
&mut *tx,
&addressbook.principal,
&addressbook.id,
&object_id,
&object,
false,
)
@@ -773,7 +786,7 @@ impl AddressbookStore for SqliteAddressbookStore {
&mut tx,
&addressbook.principal,
&addressbook.id,
object.get_id(),
&object_id,
ChangeOperation::Add,
)
.await?,

View File

@@ -3,7 +3,9 @@ use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait;
use chrono::TimeDelta;
use derive_more::derive::Constructor;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use ical::types::CalDateTime;
use regex::Regex;
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarMetadata, CalendarStore, CollectionMetadata, Error};
@@ -20,21 +22,20 @@ struct CalendarObjectRow {
uid: String,
}
impl TryFrom<CalendarObjectRow> for CalendarObject {
impl TryFrom<CalendarObjectRow> for (String, CalendarObject) {
type Error = rustical_store::Error;
fn try_from(value: CalendarObjectRow) -> Result<Self, Self::Error> {
let object = Self::from_ics(value.ics, Some(value.id))?;
if object.get_uid() != value.uid {
return Err(rustical_store::Error::IcalError(
rustical_ical::Error::InvalidData(format!(
"uid={} and UID={} don't match",
value.uid,
object.get_uid()
)),
));
fn try_from(row: CalendarObjectRow) -> Result<Self, Self::Error> {
let object = CalendarObject::from_ics(row.ics)?;
if object.get_uid() != row.uid {
warn!(
"Calendar object {}.ics: UID={} and row uid={} do not match",
row.id,
object.get_uid(),
row.uid
);
}
Ok(object)
Ok((row.id, object))
}
}
@@ -145,6 +146,83 @@ impl SqliteCalendarStore {
}
}
/// In the past exports generated objects with invalid VERSION:4.0
/// This repair sets them to VERSION:2.0
#[allow(clippy::missing_panics_doc)]
pub async fn repair_invalid_version_4_0(&self) -> Result<(), Error> {
struct Row {
principal: String,
cal_id: String,
id: String,
ics: String,
}
let mut tx = self
.db
.begin_with(BEGIN_IMMEDIATE)
.await
.map_err(crate::Error::from)?;
#[allow(clippy::missing_panics_doc)]
let version_pattern = Regex::new(r"(?mi)^VERSION:4.0").unwrap();
let repairs: Vec<Row> = sqlx::query_as!(
Row,
r#"SELECT principal, cal_id, id, ics FROM calendarobjects WHERE ics LIKE '%VERSION:4.0%';"#
)
.fetch_all(&mut *tx)
.await
.map_err(crate::Error::from)?
.into_iter()
.filter_map(|mut row| {
version_pattern.find(&row.ics)?;
let new_ics = version_pattern.replace(&row.ics, "VERSION:2.0");
// Safeguard that we really only changed the version
assert_eq!(row.ics.len(), new_ics.len());
row.ics = new_ics.to_string();
Some(row)
})
.collect();
if repairs.is_empty() {
return Ok(());
}
warn!(
"Found {} calendar objects with invalid VERSION:4.0. Repairing by setting to VERSION:2.0",
repairs.len()
);
for repair in &repairs {
// calendarobjectchangelog is used by sync-collection to fetch changes
// By deleting entries we will later regenerate new entries such that clients will notice
// the objects have changed
warn!(
"Repairing VERSION for {}/{}/{}.ics",
repair.principal, repair.cal_id, repair.id
);
sqlx::query!(
"DELETE FROM calendarobjectchangelog WHERE (principal, cal_id, object_id) = (?, ?, ?);",
repair.principal, repair.cal_id, repair.id
).execute(&mut *tx).await
.map_err(crate::Error::from)?;
sqlx::query!(
"UPDATE calendarobjects SET ics = ? WHERE (principal, cal_id, id) = (?, ?, ?);",
repair.ics,
repair.principal,
repair.cal_id,
repair.id
)
.execute(&mut *tx)
.await
.map_err(crate::Error::from)?;
}
tx.commit().await.map_err(crate::Error::from)?;
Ok(())
}
// Commit "orphaned" objects to the changelog table
pub async fn repair_orphans(&self) -> Result<(), Error> {
struct Row {
@@ -279,8 +357,8 @@ impl SqliteCalendarStore {
async fn _update_calendar<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: String,
id: String,
principal: &str,
id: &str,
calendar: Calendar,
) -> Result<(), Error> {
let comp_event = calendar.components.contains(&CalendarObjectType::Event);
@@ -378,7 +456,7 @@ impl SqliteCalendarStore {
executor: E,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
sqlx::query_as!(
CalendarObjectRow,
"SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
@@ -397,7 +475,7 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
// We extend our query interval by one day in each direction since we really don't want to
// miss any objects because of timezone differences
// I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these
@@ -433,7 +511,7 @@ impl SqliteCalendarStore {
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error> {
sqlx::query_as!(
let (row_id, object) = sqlx::query_as!(
CalendarObjectRow,
"SELECT id, uid, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
principal,
@@ -444,7 +522,9 @@ impl SqliteCalendarStore {
.fetch_one(executor)
.await
.map_err(crate::Error::from)?
.try_into()
.try_into()?;
assert_eq!(object_id, row_id);
Ok(object)
}
#[instrument]
@@ -452,23 +532,24 @@ impl SqliteCalendarStore {
executor: E,
principal: &str,
cal_id: &str,
object_id: &str,
object: &CalendarObject,
overwrite: bool,
) -> Result<(), Error> {
let (object_id, uid, ics) = (object.get_id(), object.get_uid(), object.get_ics());
let (uid, ics) = (object.get_uid(), object.get_ics());
let first_occurence = object
.get_inner()
.get_inner()
.get_first_occurence()
.ok()
.flatten()
.as_ref()
.map(CalDateTime::date);
.map(CalDateTime::date_floor);
let last_occurence = object
.get_inner()
.get_inner()
.get_last_occurence()
.ok()
.flatten()
.as_ref()
.map(CalDateTime::date);
.map(CalDateTime::date_ceil);
let etag = object.get_etag();
let object_type = object.get_object_type() as u8;
@@ -560,7 +641,7 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
struct Row {
object_id: String,
synctoken: i64,
@@ -587,7 +668,7 @@ impl SqliteCalendarStore {
for Row { object_id, .. } in changes {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
Ok(object) => objects.push(object),
Ok(object) => objects.push((object_id, object)),
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
Err(err) => return Err(err),
}
@@ -627,8 +708,8 @@ impl CalendarStore for SqliteCalendarStore {
#[instrument]
async fn update_calendar(
&self,
principal: String,
id: String,
principal: &str,
id: &str,
calendar: Calendar,
) -> Result<(), Error> {
Self::_update_calendar(&self.db, principal, id, calendar).await
@@ -696,14 +777,23 @@ impl CalendarStore for SqliteCalendarStore {
let mut sync_token = None;
for object in objects {
Self::_put_object(&mut *tx, &calendar.principal, &calendar.id, &object, false).await?;
let object_id = object.get_uid();
Self::_put_object(
&mut *tx,
&calendar.principal,
&calendar.id,
object_id,
&object,
false,
)
.await?;
sync_token = Some(
Self::log_object_operation(
&mut tx,
&calendar.principal,
&calendar.id,
object.get_id(),
object_id,
ChangeOperation::Add,
)
.await?,
@@ -729,7 +819,7 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str,
cal_id: &str,
query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_calendar_query(&self.db, principal, cal_id, query).await
}
@@ -760,7 +850,7 @@ impl CalendarStore for SqliteCalendarStore {
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_get_objects(&self.db, principal, cal_id).await
}
@@ -778,9 +868,9 @@ impl CalendarStore for SqliteCalendarStore {
#[instrument]
async fn put_objects(
&self,
principal: String,
cal_id: String,
objects: Vec<CalendarObject>,
principal: &str,
cal_id: &str,
objects: Vec<(String, CalendarObject)>,
overwrite: bool,
) -> Result<(), Error> {
let mut tx = self
@@ -789,25 +879,25 @@ impl CalendarStore for SqliteCalendarStore {
.await
.map_err(crate::Error::from)?;
let calendar = Self::_get_calendar(&mut *tx, &principal, &cal_id, true).await?;
let calendar = Self::_get_calendar(&mut *tx, principal, cal_id, true).await?;
if calendar.subscription_url.is_some() {
// We cannot commit an object to a subscription calendar
return Err(Error::ReadOnly);
}
let mut sync_token = None;
for object in objects {
for (object_id, object) in objects {
sync_token = Some(
Self::log_object_operation(
&mut tx,
&principal,
&cal_id,
object.get_id(),
principal,
cal_id,
&object_id,
ChangeOperation::Add,
)
.await?,
);
Self::_put_object(&mut *tx, &principal, &cal_id, &object, overwrite).await?;
Self::_put_object(&mut *tx, principal, cal_id, &object_id, &object, overwrite).await?;
}
tx.commit().await.map_err(crate::Error::from)?;
@@ -815,9 +905,7 @@ impl CalendarStore for SqliteCalendarStore {
if let Some(sync_token) = sync_token {
self.send_push_notification(
CollectionOperationInfo::Content { sync_token },
self.get_calendar(&principal, &cal_id, true)
.await?
.push_topic,
self.get_calendar(principal, cal_id, true).await?.push_topic,
);
}
Ok(())
@@ -885,7 +973,7 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
Self::_sync_changes(&self.db, principal, cal_id, synctoken).await
}

View File

@@ -136,7 +136,7 @@ impl NamedStruct {
#(#builder_field_inits),*
};
let (ns, name) = reader.resolve_element(start.name());
let (ns, name) = reader.resolver().resolve_element(start.name());
#(#tagname_field_branches);*
#(#namespace_field_branches);*
@@ -161,7 +161,7 @@ impl NamedStruct {
// start of a child element
Event::Start(start) | Event::Empty(start) => {
let empty = matches!(event, Event::Empty(_));
let (ns, name) = reader.resolve_element(start.name());
let (ns, name) = reader.resolver().resolve_element(start.name());
match (ns, name.as_ref()) {
#(#named_field_branches),*
#(#untagged_field_branches),*

View File

@@ -42,7 +42,7 @@ impl<T: XmlRootTag + XmlDeserialize> XmlDocument for T {
match event {
Event::Decl(_) | Event::Comment(_) => { /* ignore this */ }
Event::Start(start) | Event::Empty(start) => {
let (ns, name) = reader.resolve_element(start.name());
let (ns, name) = reader.resolver().resolve_element(start.name());
let matches = match (Self::root_ns(), &ns, name) {
// Wrong tag
(_, _, name) if name.as_ref() != Self::root_tag().as_bytes() => false,

View File

@@ -5,14 +5,14 @@ use quick_xml::events::BytesStart;
use crate::{XmlDeserialize, XmlError};
// TODO: actually implement
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unparsed(BytesStart<'static>);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Unparsed(String);
impl Unparsed {
#[must_use]
pub fn tag_name(&self) -> String {
// TODO: respect namespace?
String::from_utf8_lossy(self.0.local_name().as_ref()).to_string()
self.0.clone()
}
}
@@ -27,6 +27,7 @@ impl XmlDeserialize for Unparsed {
let mut buf = vec![];
reader.read_to_end_into(start.name(), &mut buf)?;
}
Ok(Self(start.to_owned()))
let tag_name = String::from_utf8_lossy(start.local_name().as_ref()).to_string();
Ok(Self(tag_name))
}
}

View File

@@ -0,0 +1,253 @@
# RFC 4791 (CalDAV)
## ☑ 1. Introduction
### ☑ 1.1 Notational Conventions
### ☑ 1.2 XML Namespaces and Processing
### ☐ 1.3 Method Preconditions and Postconditions
## ☐ 2. Requirements Overview
- [x] MUST support iCalendar [RFC2445] as a media type for the calendar
object resource format;
- [ ] MUST support WebDAV Class 1 [RFC2518] (note that [rfc2518bis]
describes clarifications to [RFC2518] that aid interoperability);
- [x] MUST support WebDAV ACL [RFC3744] with the additional privilege
defined in Section 6.1 of this document;
- [x] MUST support transport over TLS [RFC2246] as defined in [RFC2818]
(note that [RFC2246] has been obsoleted by [RFC4346]);
- [x] MUST support ETags [RFC2616] with additional requirements
specified in Section 5.3.4 of this document;
- [ ] MUST support all calendaring reports defined in Section 7 of this
document; and
- [x] MUST advertise support on all calendar collections and calendar
object resources for the calendaring reports in the DAV:supported-
report-set property, as defined in Versioning Extensions to WebDAV
[RFC3253].
In addition, a server:
- [x] SHOULD support the MKCALENDAR method defined in Section 5.3.1 of
this document.
## ☑ 3. Calendaring Data Model
### ☑ 3.1 Calendar Server
### ☑ 3.2 Recurrence and the Data Model
## ☑ 4. Calendar Resources
### ☑ 4.1 Calendar Object Resources
### ☑ 4.2 Calendar Collection
## ☐ 5. Calendar Access Feature
### ☑ 5.1 Calendar Access Support
#### ☑ 5.1.1 Example: Using OPTIONS for the Discovery of Calendar Access Support
### ☑ 5.2 Calendar Collection Properties
#### ☑ 5.2.1 CALDAV:calendar-description Property
#### ☑ 5.2.2 CALDAV:calendar-timezone Property
#### ☑ 5.2.3 CALDAV:supported-calendar-component-set Property
#### ☑ 5.2.4 CALDAV:supported-calendar-data Property
#### ☑ 5.2.5 CALDAV:max-resource-size Property
#### ☑ 5.2.6 CALDAV:min-date-time Property
#### ☑ 5.2.7 CALDAV:max-date-time Property
#### ☐ 5.2.8 CALDAV:max-instances Property (Maybe set this :))
#### ☑ 5.2.9 CALDAV:max-attendees-per-instance Property (does not apply)
#### ☑ 5.2.10 Additional Precondition for PROPPATCH
### ☑ 5.3 Creating Resources
#### ☑ 5.3.1 MKCALENDAR Method
##### ☑ 5.3.1.1 Status Codes
##### ☑ 5.3.1.2 Example: Successful MKCALENDAR Request
- Example fails because of the tzid is not in the Olson database, but that's okay
#### ☑ 5.3.2 Creating Calendar Object Resources
##### ☐ 5.3.2.1 Additional Preconditions for PUT, COPY, and MOVE
### ☑ 5.3.3 Non-Standard Components, Properties, and Parameters
### ☑ 5.3.4 Calendar Object Resource Entity Tag
## ☐ 6. Calendaring Access Control
### ☐ 6.1 Calendaring Privilege
#### ☐ 6.1.1 CALDAV:read-free-busy Privilege
### ☑ 6.2 Additional Principal Property
#### ☑ 6.2.1 CALDAV:calendar-home-set Property
## ☐ 7. Calendaring Reports
- [ ] `DAV:expand-property`
### ☑ 7.1 REPORT Method
### ☑ 7.2 Ordinary Collections
### ☑ 7.3 Date and Floating Time
### ☑ 7.4 Time Range Filtering
### ☑ 7.5 Searching Text: Collations
#### ☑ 7.5.1 CALDAV:supported-collation-set Property
### ☐ 7.6 Partial Retrieval
### ☑ 7.7 Non-Standard Components, Properties, and Parameters
### ☑ 7.8 CALDAV:calendar-query REPORT
#### ☐ 7.8.1 Example: Partial Retrieval of Events by Time Range
#### ☐ 7.8.2 Example: Partial Retrieval of Recurring Events
#### ☐ 7.8.3 Example: Expanded Retrieval of Recurring Events
#### ☐ 7.8.4 Example: Partial Retrieval of Stored Free Busy Components
#### ☐ 7.8.5 Example: Retrieval of To-Dos by Alarm Time Range
#### ☐ 7.8.6 Example: Retrieval of Event by UID
#### ☐ 7.8.7 Example: Retrieval of Events by PARTSTAT
#### ☐ 7.8.8 Example: Retrieval of Events Only
#### ☐ 7.8.9 Example: Retrieval of All Pending To-Dos
#### ☐ 7.8.10 Example: Attempt to Query Unsupported Property
### ☐ 7.9 CALDAV:calendar-multiget REPORT
#### ☐ 7.9.1 Example: Successful CALDAV:calendar-multiget REPORT
### ☐ 7.10 CALDAV:free-busy-query REPORT
#### ☐ 7.10.1 Example: Successful CALDAV:free-busy-query REPORT
## ☐ 8. Guidelines
### ☐ 8.1 Client-to-Client Interoperability
### ☐ 8.2 Synchronization Operations
#### ☐ 8.2.1 Use of Reports
##### ☐ 8.2.1.1 Restrict the Time Range
##### ☐ 8.2.1.2 Synchronize by Time Range
##### ☐ 8.2.1.3 Synchronization Process
#### ☐ 8.2.2 Restrict the Properties Returned
### ☐ 8.3 Use of Locking
### ☐ 8.4 Finding Calendars
### ☐ 8.5 Storing and Using Attachments
#### ☐ 8.5.1 Inline Attachments
#### ☐ 8.5.2 External Attachments
### ☐ 8.6 Storing and Using Alarms
## ☐ 9. XML Element Definitions
### ☐ 9.1 CALDAV:calendar XML Element
### ☐ 9.2 CALDAV:mkcalendar XML Element
### ☐ 9.3 CALDAV:mkcalendar-response XML Element
### ☐ 9.4 CALDAV:supported-collation XML Element
### ☐ 9.5 CALDAV:calendar-query XML Element
### ☐ 9.6 CALDAV:calendar-data XML Element
#### ☐ 9.6.1 CALDAV:comp XML Element
#### ☐ 9.6.2 CALDAV:allcomp XML Element
#### ☐ 9.6.3 CALDAV:allprop XML Element
#### ☐ 9.6.4 CALDAV:prop XML Element
#### ☐ 9.6.5 CALDAV:expand XML Element
#### ☐ 9.6.6 CALDAV:limit-recurrence-set XML Element
#### ☐ 9.6.7 CALDAV:limit-freebusy-set XML Element
### ☐ 9.7 CALDAV:filter XML Element
#### ☐ 9.7.1 CALDAV:comp-filter XML Element
#### ☐ 9.7.2 CALDAV:prop-filter XML Element
#### ☐ 9.7.3 CALDAV:param-filter XML Element
#### ☐ 9.7.4 CALDAV:is-not-defined XML Element
#### ☐ 9.7.5 CALDAV:text-match XML Element
### ☐ 9.8 CALDAV:timezone XML Element
### ☐ 9.9 CALDAV:time-range XML Element
### ☐ 9.10 CALDAV:calendar-multiget XML Element
### ☐ 9.11 CALDAV:free-busy-query XML Element
## ☐ 10. Internationalization Considerations
## ☐ 11. Security Considerations
## ☐ 12. IANA Considerations
### ☐ 12.1 Namespace Registration
## ☐ 13. Acknowledgements
## ☐ 14. References
### ☐ 14.1 Normative References
### ☐ 14.2 Informative References
## ☐ A. CalDAV Method Privilege Table (Normative)
## ☐ B. Calendar Collections Used in the Examples

View File

@@ -0,0 +1,175 @@
# RFC 6352 (CardDAV)
## ☑ 1. Introduction and Overview
## ☑ 2. Conventions
## ☐ 3. Requirements Overview
## ☑ 4. Address Book Data Model
### ☑ 4.1 Address Book Server
## ☐ 5. Address Book Resources
### ☐ 5.1 Address Object Resources
- vCard objects MUST have a unique UID
- Right now the uniqueness is not enforced in store_sqlite :(
#### ☐ 5.1.1 Data Type Conversion
Again, the client can use content negotiation to
request that data be returned in a specific media type by specifying
appropriate attributes on the CARDDAV:address-data XML element used
in the request body (see Section 10.4).
- [ ] Accept address-data attributes
#### ☐ 5.1.1.1 Additional Precondition for GET
- Make sure that Accept header matches content type
### ☐ 5.2 Address Book Collections
## ☑ 6. Address Book Feature
### ☑ 6.1 Address Book Support
#### ☑ 6.1.1 Example: Using OPTIONS for the Discovery of Support for CardDAV
### ☐ 6.2 Address Book Properties
#### ☑ 6.2.1 CARDDAV:addressbook-description Property
#### ☑ 6.2.2 CARDDAV:supported-address-data Property
#### ☑ 6.2.3 CARDDAV:max-resource-size Property
### ☐ 6.3 Creating Resources
#### ☑ 6.3.1 Extended MKCOL Method
##### ☑ 6.3.1.1 Example - Successful MKCOL Request
#### ☐ 6.3.2 Creating Address Object Resources
- [x] If-None-Match support
##### ☐ 6.3.2.1 Additional Preconditions for PUT, COPY, and MOVE
- [ ] Make sure UID is unique
##### ☑ 6.3.2.2 Non-Standard vCard Properties and Parameters
##### ☑ 6.3.2.3 Address Object Resource Entity Tag
## ☑ 7. Address Book Access Control
### ☑ 7.1 Additional Principal Properties
#### ☑ 7.1.1 CARDDAV:addressbook-home-set Property
#### ☑ 7.1.2 CARDDAV:principal-address Property
## ☐ 8. Address Book Reports
### ☐ 8.1 REPORT Method
- [ ] DAV:expand-property REPORT
### ☑ 8.2 Ordinary Collections
### ☑ 8.3 Searching Text: Collations
#### ☑ 8.3.1 CARDDAV:supported-collation-set Property
### ☐ 8.4 Partial Retrieval (Optional)
### ☑ 8.5 Non-Standard Properties and Parameters
### ☑ 8.6 CARDDAV:addressbook-query Report
#### ☐ 8.6.1 Limiting Results
#### ☑ 8.6.2 Truncation of Results (does not apply)
#### ☐ 8.6.3 Example: Partial Retrieval of vCards Matching NICKNAME
#### ☐ 8.6.4 Example: Partial Retrieval of vCards Matching a Full Name or Email Address
#### ☐ 8.6.5 Example: Truncated Results
### ☐ 8.7 CARDDAV:addressbook-multiget Report
#### ☑ 8.7.1 Example: CARDDAV:addressbook-multiget Report
#### ☐ 8.7.2 Example: CARDDAV:addressbook-multiget Report
- [ ] Check for content-type of requested data
```
<C:address-data content-type='text/vcard' version='4.0'/>
```
## ☑ 9. Client Guidelines
### ☑ 9.1 Restrict the Properties Returned
### ☑ 9.2 Avoiding Lost Updates
### ☑ 9.3 Client Configuration
### ☐ 9.4 Finding Other Users' Address Books
- [ ] Implement DAV:principal-property-search REPORT [RFC3744]
## ☑ 10. XML Element Definitions
### ☑ 10.1 CARDDAV:addressbook XML Element
### ☑ 10.2 CARDDAV:supported-collation XML Element
### ☑ 10.3 CARDDAV:addressbook-query XML Element
### ☑ 10.4 CARDDAV:address-data XML Element
- [ ] Support content-type and version
#### ☐ 10.4.1 CARDDAV:allprop XML Element (does not apply, is for vCard props)
#### ☐ 10.4.2 CARDDAV:prop XML Element (does not apply, is for vCard props)
### ☑ 10.5 CARDDAV:filter XML Element
#### ☑ 10.5.1 CARDDAV:prop-filter XML Element
#### ☑ 10.5.2 CARDDAV:param-filter XML Element
#### ☑ 10.5.3 CARDDAV:is-not-defined XML Element
#### ☑ 10.5.4 CARDDAV:text-match XML Element
### ☑ 10.6 CARDDAV:limit XML Element
#### ☑ 10.6.1 CARDDAV:nresults XML Element
### ☑ 10.7 CARDDAV:addressbook-multiget XML Element
## ☑ 11. Service Discovery via SRV Records
## ☑ 12. Internationalization Considerations
## ☑ 13. Security Considerations
## ☑ 14. IANA Consideration
### ☑ 14.1 Namespace Registration
## ☑ 15. Acknowledgments
## ☑ 16. References
### ☑ 16.1 Normative References
### ☑ 16.2 Informative References

View File

@@ -75,7 +75,9 @@ nav:
- OpenID Connect: setup/oidc.md
- Developers:
- developers/index.md
- Relevant RFCs: developers/rfcs.md
- Relevant RFCs:
- developers/rfcs.md
- RFC 6352: developers/rfcs/rfc6352.md
- Frontend: developers/frontend.md
- Debugging: developers/debugging.md
- Cargo docs: /rustical/_crate/rustical/

View File

@@ -17,6 +17,7 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> {
http: HttpConfig::default(),
data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url: "/var/lib/rustical/db.sqlite3".to_owned(),
run_repairs: true,
}),
tracing: TracingConfig::default(),
frontend: FrontendConfig {

View File

@@ -26,6 +26,8 @@ impl Default for HttpConfig {
#[serde(deny_unknown_fields)]
pub struct SqliteDataStoreConfig {
pub db_url: String,
#[serde(default = "default_true")]
pub run_repairs: bool,
}
#[derive(Debug, Deserialize, Serialize)]

View File

@@ -8,7 +8,7 @@ use rustical_store::{CalendarMetadata, CalendarStore};
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt;
fn mkcalendar_template(
pub fn mkcalendar_template(
CalendarMetadata {
displayname,
order: _order,
@@ -29,12 +29,34 @@ fn mkcalendar_template(
<displayname>{displayname}</displayname>
<CAL:calendar-description>{description}</CAL:calendar-description>
<n0:calendar-color xmlns:n0="http://apple.com/ns/ical/">{color}</n0:calendar-color>
<CAL:calendar-timezone-id>Europe/Berlin</CAL:calendar-timezone-id>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
<CAL:comp name="VTODO"/>
<CAL:comp name="VJOURNAL"/>
</CAL:supported-calendar-component-set>
<CAL:calendar-timezone><![CDATA[BEGIN:VCALENDAR
PRODID:-//Example Corp.//CalDAV Client//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:US/Eastern
LAST-MODIFIED:19870101T000000Z
BEGIN:STANDARD
DTSTART:19671029T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:Eastern Standard Time (US & Canada)
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19870405T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:Eastern Daylight Time (US & Canada)
END:DAYLIGHT
END:VTIMEZONE
END:VCALENDAR
]]></CAL:calendar-timezone>
</prop>
</set>
</CAL:mkcalendar>
@@ -209,3 +231,106 @@ async fn test_caldav_calendar(
Err(rustical_store::Error::NotFound)
));
}
#[rstest]
#[tokio::test]
async fn test_rfc4791_5_3_2(
#[from(test_store_context)]
#[future]
context: TestStoreContext,
) {
let context = context.await;
let app = get_app(context.clone());
let calendar_meta = CalendarMetadata {
displayname: Some("Calendar".to_string()),
description: Some("Description".to_string()),
color: Some("#00FF00".to_string()),
order: 0,
};
let (principal, cal_id) = ("user", "calendar");
let url = format!("/caldav/principal/{principal}/{cal_id}");
let request_template = || {
Request::builder()
.method("MKCALENDAR")
.uri(&url)
.body(Body::from(mkcalendar_template(&calendar_meta)))
.unwrap()
};
// Try with correct credentials
let mut request = request_template();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let ical = r"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:20010712T182145Z-123401@example.com
DTSTAMP:20060712T182145Z
DTSTART:20060714T170000Z
DTEND:20060715T040000Z
SUMMARY:Bastille Day Party
END:VEVENT
END:VCALENDAR";
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/qwue23489.ics"))
.header("If-None-Match", "*")
.header("Content-Type", "text/calendar")
.body(Body::from(ical))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/qwue23489.ics"))
.header("If-None-Match", "*")
.header("Content-Type", "text/calendar")
.body(Body::from(ical))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let mut request = Request::builder()
.method("REPORT")
.uri(&url)
.header("Depth", "infinity")
.header("Content-Type", "text/xml; charset=\"utf-8\"")
.body(Body::from(format!(
r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-multiget xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
</D:prop>
<D:href>{url}/qwue23489.ics</D:href>
<D:href>/home/bernard/addressbook/vcf1.vcf</D:href>
</C:calendar-multiget>
"#
)))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let body = response.extract_string().await;
insta::assert_snapshot!("multiget_body", body);
}

View File

@@ -0,0 +1,98 @@
use crate::integration_tests::{ResponseExtractString, get_app};
use axum::body::Body;
use axum::extract::Request;
use headers::{Authorization, HeaderMapExt};
use http::StatusCode;
use rstest::rstest;
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt;
const ICAL: &str = r"
BEGIN:VCALENDAR
PRODID:-//Example Corp.//CalDAV Client//EN
VERSION:2.0
BEGIN:VEVENT
UID:1@example.com
SUMMARY:One-off Meeting
DTSTAMP:20041210T183904Z
DTSTART:20041207T120000Z
DTEND:20041207T130000Z
END:VEVENT
BEGIN:VEVENT
UID:2@example.com
SUMMARY:Weekly Meeting
DTSTAMP:20041210T183838Z
DTSTART:20041206T120000Z
DTEND:20041206T130000Z
RRULE:FREQ=WEEKLY
END:VEVENT
BEGIN:VEVENT
UID:2@example.com
SUMMARY:Weekly Meeting
RECURRENCE-ID:20041213T120000Z
DTSTAMP:20041210T183838Z
DTSTART:20041213T130000Z
DTEND:20041213T140000Z
END:VEVENT
END:VCALENDAR
";
#[rstest]
#[case(0, ICAL)]
#[case(1, include_str!("resources/rfc4791_appb.ics"))]
#[tokio::test]
async fn test_import(
#[from(test_store_context)]
#[future]
context: TestStoreContext,
#[case] case: usize,
#[case] ical: &'static str,
) {
let context = context.await;
let app = get_app(context.clone());
let (principal, addr_id) = ("user", "calendar");
let url = format!("/caldav/principal/{principal}/{addr_id}");
let request_template = || {
Request::builder()
.method("IMPORT")
.uri(&url)
.body(Body::from(ical))
.unwrap()
};
// Try without authentication
let request = request_template();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
// Try with correct credentials
let mut request = request_template();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.extract_string().await;
insta::assert_snapshot!(format!("{case}_import_body"), body);
let mut request = Request::builder()
.method("GET")
.uri(&url)
.body(Body::empty())
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.extract_string().await;
insta::with_settings!({
filters => vec![
(r"UID:.+", "UID:[UID]")
]
}, {
insta::assert_snapshot!(format!("{case}_get_body"), body);
});
}

View File

@@ -0,0 +1,77 @@
use axum::body::Body;
use headers::{Authorization, HeaderMapExt};
use http::{Request, StatusCode};
use rstest::rstest;
use rustical_store::CalendarMetadata;
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt;
use crate::integration_tests::{
ResponseExtractString, caldav::calendar::mkcalendar_template, get_app,
};
#[rstest]
#[tokio::test]
async fn test_put_invalid(
#[from(test_store_context)]
#[future]
context: TestStoreContext,
) {
let context = context.await;
let app = get_app(context.clone());
let calendar_meta = CalendarMetadata {
displayname: Some("Calendar".to_string()),
description: Some("Description".to_string()),
color: Some("#00FF00".to_string()),
order: 0,
};
let (principal, cal_id) = ("user", "calendar");
let url = format!("/caldav/principal/{principal}/{cal_id}");
let mut request = Request::builder()
.method("MKCALENDAR")
.uri(&url)
.body(Body::from(mkcalendar_template(&calendar_meta)))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
// Invalid calendar data
let ical = r"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:20010712T182145Z-123401@example.com
DTSTAMP:20060712T182145Z
DTSTART:20060714T170000Z
RRULE:UNTIL=123
DTEND:20060715T040000Z
SUMMARY:Bastille Day Party
END:VEVENT
END:VCALENDAR";
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/qwue23489.ics"))
.header("If-None-Match", "*")
.header("Content-Type", "text/calendar")
.body(Body::from(ical))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
let body = response.extract_string().await;
insta::assert_snapshot!(body, @r#"
<?xml version="1.0" encoding="utf-8"?>
<error xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<CAL:valid-calendar-data/>
</error>
"#);
}

View File

@@ -0,0 +1,209 @@
use crate::integration_tests::{ResponseExtractString, get_app};
use axum::body::Body;
use axum::extract::Request;
use headers::{Authorization, HeaderMapExt};
use http::StatusCode;
use rstest::rstest;
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt;
const ICS_1: &str = include_str!("resources/rfc4791_appb.ics");
const REPORT_7_8_1: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data>
<C:comp name="VCALENDAR">
<C:prop name="VERSION"/>
<C:comp name="VEVENT">
<C:prop name="SUMMARY"/>
<C:prop name="UID"/>
<C:prop name="DTSTART"/>
<C:prop name="DTEND"/>
<C:prop name="DURATION"/>
<C:prop name="RRULE"/>
<C:prop name="RDATE"/>
<C:prop name="EXRULE"/>
<C:prop name="EXDATE"/>
<C:prop name="RECURRENCE-ID"/>
</C:comp>
<C:comp name="VTIMEZONE"/>
</C:comp>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060104T000000Z"
end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
const REPORT_7_8_2: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:limit-recurrence-set start="20060103T000000Z"
end="20060105T000000Z"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z"
end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
const REPORT_7_8_3: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="20060103T000000Z"
end="20060105T000000Z"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z"
end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
// Adapted from Example 7.8.3 of RFC 4791
// In the RFC the output is wrong since it returns DTSTART in UTC as local time, e.g.
// DTSTART:20060103T170000
// instead of
// DTSTART:20060103T170000Z
// In https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5
// it is clearly stated that times with timezone information MUST be returned in UTC.
// Also, the RECURRENCE-ID needs to include the TIMEZONE, which is fixed here by converting it to
// UTC
const OUTPUT_7_8_3: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<response>
<href>/caldav/principal/user/calendar/abcd2.ics</href>
<propstat>
<prop>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART:20060103T170000Z
DURATION:PT1H
SUMMARY:Event #2
UID:abcd2
RECURRENCE-ID:20060103T170000Z
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART:20060104T190000Z
DURATION:PT1H
RECURRENCE-ID:20060104T170000Z
SUMMARY:Event #2 bis
UID:abcd2
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/caldav/principal/user/calendar/abcd3.ics</href>
<propstat>
<prop>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART:20060104T150000Z
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:abcd3
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>"#;
#[rstest]
#[case(0, ICS_1, REPORT_7_8_1, None)]
#[case(1, ICS_1, REPORT_7_8_2, None)]
#[case(2, ICS_1, REPORT_7_8_3, Some(OUTPUT_7_8_3))]
#[tokio::test]
async fn test_report(
#[from(test_store_context)]
#[future]
context: TestStoreContext,
#[case] case: usize,
#[case] ics: &'static str,
#[case] report: &'static str,
#[case] output: Option<&'static str>,
) {
let context = context.await;
let app = get_app(context.clone());
let (principal, addr_id) = ("user", "calendar");
let url = format!("/caldav/principal/{principal}/{addr_id}");
let request_template = || {
Request::builder()
.method("IMPORT")
.uri(&url)
.body(Body::from(ics))
.unwrap()
};
// Try with correct credentials
let mut request = request_template();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let mut request = Request::builder()
.method("REPORT")
.uri(&url)
.body(Body::from(report))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let body = response.extract_string().await;
insta::assert_snapshot!(format!("{case}_report_body"), body);
if let Some(output) = output {
similar_asserts::assert_eq!(output, body.replace('\r', ""));
}
}

View File

@@ -8,6 +8,9 @@ use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt;
mod calendar;
mod calendar_import;
mod calendar_put;
mod calendar_report;
#[rstest]
#[tokio::test]

View File

@@ -0,0 +1,103 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001102Z
DTSTART;TZID=US/Eastern:20060102T100000
DURATION:PT1H
SUMMARY:Event #1
Description:Go Steelers!
UID:abcd1
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:abcd2
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:abcd2
END:VEVENT
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:abcd3
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
END:VEVENT
BEGIN:VTODO
DTSTAMP:20060205T235335Z
DUE;VALUE=DATE:20060104
STATUS:NEEDS-ACTION
SUMMARY:Task #1
UID:abcd4
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235300Z
DUE;VALUE=DATE:20060106
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:NEEDS-ACTION
SUMMARY:Task #2
UID:abcd5
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
COMPLETED:20051223T122322Z
DTSTAMP:20060205T235400Z
DUE;VALUE=DATE:20051225
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:COMPLETED
SUMMARY:Task #3
UID:abcd6
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235600Z
DUE;VALUE=DATE:20060101
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:CANCELLED
SUMMARY:Task #4
UID:abcd7
END:VTODO
END:VCALENDAR

View File

@@ -4,9 +4,10 @@ expression: body
---
BEGIN:VCALENDAR
VERSION:2.0
PRODID:RustiCal Export
CALSCALE:GREGORIAN
PRODID:RustiCal
X-WR-CALNAME:Calendar
X-WR-CALDESC:Description
X-WR-TIMEZONE:Europe/Berlin
X-WR-CALCOLOR:#00FF00
X-WR-TIMEZONE:US/Eastern
END:VCALENDAR

View File

@@ -0,0 +1,20 @@
---
source: src/integration_tests/caldav/calendar.rs
expression: body
---
<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<response>
<href>/caldav/principal/user/calendar/qwue23489.ics</href>
<propstat>
<prop>
<getetag>&quot;aea50382a7775bb9742bfec277382e3a260b6066f503b5f5ae34548d7215ee46&quot;</getetag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/home/bernard/addressbook/vcf1.vcf</href>
<status>HTTP/1.1 404 Not Found</status>
</response>
</multistatus>

View File

@@ -14,109 +14,117 @@ expression: body
PRODID:-//github.com/lennart-k/vzic-rs//RustiCal Calendar server//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
TZID:America/New_York
LAST-MODIFIED:20250723T190331Z
X-LIC-LOCATION:Europe/Berlin
X-LIC-LOCATION:America/New_York
X-PROLEPTIC-TZNAME:LMT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+005328
TZOFFSETTO:+0100
DTSTART:18930401T000000
TZNAME:EST
TZOFFSETFROM:-045602
TZOFFSETTO:-0500
DTSTART:18831118T120358
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19160430T230000
RDATE:19400401T020000
RDATE:19430329T020000
RDATE:19460414T020000
RDATE:19470406T030000
RDATE:19480418T020000
RDATE:19490410T020000
RDATE:19800406T020000
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19180331T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19200328T070000Z
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19161001T010000
RDATE:19421102T030000
RDATE:19431004T030000
RDATE:19441002T030000
RDATE:19451118T030000
RDATE:19461007T030000
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:19181027T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19201031T060000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19170416T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3MO;UNTIL=19180415T010000Z
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19210424T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19410427T070000Z
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19170917T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=3MO;UNTIL=19180916T010000Z
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:19210925T020000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19410928T060000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19440403T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO;UNTIL=19450402T010000Z
TZNAME:EWT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19420209T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:CEMT
TZOFFSETFROM:+0200
TZOFFSETTO:+0300
DTSTART:19450524T020000
RDATE:19470511T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0300
TZOFFSETTO:+0200
DTSTART:19450924T030000
RDATE:19470629T030000
TZNAME:EPT
TZOFFSETFROM:-0400
TZOFFSETTO:-0400
DTSTART:19450814T190000
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0100
TZOFFSETTO:+0100
DTSTART:19460101T000000
RDATE:19800101T000000
END:STANDARD
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19471005T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19491002T010000Z
END:STANDARD
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19800928T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T010000Z
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:19450930T020000
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19460428T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19961027T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:19460929T020000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19540926T060000Z
END:STANDARD
BEGIN:STANDARD
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:19551030T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19740106T020000
RDATE:19750223T020000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19760425T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:19870405T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
@@ -124,7 +132,7 @@ END:VCALENDAR
<CAL:timezone-service-set>
<href>https://www.iana.org/time-zones</href>
</CAL:timezone-service-set>
<CAL:calendar-timezone-id>Europe/Berlin</CAL:calendar-timezone-id>
<CAL:calendar-timezone-id>US/Eastern</CAL:calendar-timezone-id>
<calendar-order xmlns="http://apple.com/ns/ical/">0</calendar-order>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
@@ -139,7 +147,7 @@ END:VCALENDAR
<CAL:supported-collation>i;unicode-casemap</CAL:supported-collation>
<CAL:supported-collation>i;octet</CAL:supported-collation>
</CAL:supported-collation-set>
<max-resource-size>10000000</max-resource-size>
<CAL:max-resource-size>10000000</CAL:max-resource-size>
<supported-report-set>
<supported-report>
<report>

View File

@@ -0,0 +1,32 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---
BEGIN:VCALENDAR
VERSION:2.0
PRODID:RustiCal Export
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:[UID]
SUMMARY:One-off Meeting
DTSTAMP:20041210T183904Z
DTSTART:20041207T120000Z
DTEND:20041207T130000Z
END:VEVENT
BEGIN:VEVENT
UID:[UID]
SUMMARY:Weekly Meeting
DTSTAMP:20041210T183838Z
DTSTART:20041206T120000Z
DTEND:20041206T130000Z
RRULE:FREQ=WEEKLY
END:VEVENT
BEGIN:VEVENT
UID:[UID]
SUMMARY:Weekly Meeting
RECURRENCE-ID:20041213T120000Z
DTSTAMP:20041210T183838Z
DTSTART:20041213T130000Z
DTEND:20041213T140000Z
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,5 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -0,0 +1,108 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---
BEGIN:VCALENDAR
VERSION:2.0
PRODID:RustiCal Export
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001102Z
DTSTART;TZID=US/Eastern:20060102T100000
DURATION:PT1H
SUMMARY:Event #1
DESCRIPTION:Go Steelers!
UID:[UID]
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:[UID]
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:[UID]
END:VEVENT
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:[UID]
X-ABC-GUID:[UID]
END:VEVENT
BEGIN:VTODO
DTSTAMP:20060205T235335Z
DUE;VALUE=DATE:20060104
STATUS:NEEDS-ACTION
SUMMARY:Task #1
UID:[UID]
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235300Z
DUE;VALUE=DATE:20060106
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:NEEDS-ACTION
SUMMARY:Task #2
UID:[UID]
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
COMPLETED:20051223T122322Z
DTSTAMP:20060205T235400Z
DUE;VALUE=DATE:20051225
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:COMPLETED
SUMMARY:Task #3
UID:[UID]
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235600Z
DUE;VALUE=DATE:20060101
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:CANCELLED
SUMMARY:Task #4
UID:[UID]
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,5 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -0,0 +1,107 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:RustiCal
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:[UID]
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001102Z
DTSTART;TZID=US/Eastern:20060102T100000
DURATION:PT1H
SUMMARY:Event #1
Description:Go Steelers!
UID:[UID]
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:[UID]
END:VEVENT
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:[UID]
END:VEVENT
BEGIN:VTODO
DTSTAMP:20060205T235335Z
DUE;VALUE=DATE:20060104
STATUS:NEEDS-ACTION
SUMMARY:Task #1
UID:[UID]
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235300Z
DUE;VALUE=DATE:20060106
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:NEEDS-ACTION
SUMMARY:Task #2
UID:[UID]
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
COMPLETED:20051223T122322Z
DTSTAMP:20060205T235400Z
DUE;VALUE=DATE:20051225
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:COMPLETED
SUMMARY:Task #3
UID:[UID]
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235600Z
DUE;VALUE=DATE:20060101
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:CANCELLED
SUMMARY:Task #4
UID:[UID]
END:VTODO
END:VCALENDAR

View File

@@ -0,0 +1,5 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -0,0 +1,101 @@
---
source: src/integration_tests/caldav/calendar_report.rs
expression: body
---
<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<response>
<href>/caldav/principal/user/calendar/abcd2.ics</href>
<propstat>
<prop>
<getetag>&quot;7d80077c5655339885a36b6dbe97336767fb85e6b12c94668bcac100ed971fac&quot;</getetag>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:abcd2
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:abcd2
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/caldav/principal/user/calendar/abcd3.ics</href>
<propstat>
<prop>
<getetag>&quot;a84fd022dfc742bf8f17ac04fca3aad687e9ae724180185e8e0df11e432dae30&quot;</getetag>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:abcd3
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>

View File

@@ -0,0 +1,99 @@
---
source: src/integration_tests/caldav/calendar_report.rs
expression: body
---
<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<response>
<href>/caldav/principal/user/calendar/abcd2.ics</href>
<propstat>
<prop>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:abcd2
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:abcd2
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/caldav/principal/user/calendar/abcd3.ics</href>
<propstat>
<prop>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:abcd3
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>

View File

@@ -0,0 +1,63 @@
---
source: src/integration_tests/caldav/calendar_report.rs
expression: body
---
<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<response>
<href>/caldav/principal/user/calendar/abcd2.ics</href>
<propstat>
<prop>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART:20060103T170000Z
DURATION:PT1H
SUMMARY:Event #2
UID:abcd2
RECURRENCE-ID:20060103T170000Z
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART:20060104T190000Z
DURATION:PT1H
RECURRENCE-ID:20060104T170000Z
SUMMARY:Event #2 bis
UID:abcd2
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/caldav/principal/user/calendar/abcd3.ics</href>
<propstat>
<prop>
<CAL:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART:20060104T150000Z
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:abcd3
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
END:VEVENT
END:VCALENDAR
</CAL:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>

View File

@@ -192,3 +192,256 @@ async fn test_carddav_addressbook(
Err(rustical_store::Error::NotFound)
));
}
#[rstest]
#[tokio::test]
async fn test_mkcol_rfc6352_6_3_1_1(
#[from(test_store_context)]
#[future]
context: TestStoreContext,
) {
let context = context.await;
let app = get_app(context.clone());
let addr_store = context.addr_store;
let (displayname, description) = (
"Lisa's Contacts".to_owned(),
"My primary address book.".to_owned(),
);
let (principal, addr_id) = ("user", "contacts");
let url = format!("/carddav/principal/{principal}/{addr_id}");
let mut request = Request::builder()
.method("MKCOL")
.uri(&url)
.body(Body::from(format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<D:mkcol xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:set>
<D:prop>
<D:resourcetype>
<D:collection/>
<C:addressbook/>
</D:resourcetype>
<D:displayname>{displayname}</D:displayname>
<C:addressbook-description xml:lang="en"
>{description}</C:addressbook-description>
</D:prop>
</D:set>
</D:mkcol>"#
)))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.extract_string().await;
insta::assert_snapshot!("mkcol_body", body);
let saved_addressbook = addr_store
.get_addressbook(principal, addr_id, false)
.await
.unwrap();
assert_eq!(
(
saved_addressbook.displayname.unwrap(),
saved_addressbook.description.unwrap()
),
(displayname, description)
);
let vcard = r"BEGIN:VCARD
VERSION:3.0
FN:Cyrus Daboo
N:Daboo;Cyrus
ADR;TYPE=POSTAL:;2822 Email HQ;Suite 2821;RFCVille;PA;15213;USA
EMAIL;TYPE=INTERNET,PREF:cyrus@example.com
NICKNAME:me
NOTE:Example VCard.
ORG:Self Employed
TEL;TYPE=WORK,VOICE:412 605 0499
TEL;TYPE=FAX:412 605 0705
URL:http://www.example.com
UID:1234-5678-9000-1
END:VCARD
";
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/newcard.vcf"))
.header("If-None-Match", "*")
.header("Content-Type", "text/vcard")
.body(Body::from(vcard))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let etag = response.headers().get("ETag").unwrap();
// This should overwrite
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/newcard.vcf"))
.header("If-None-Match", "\"somearbitraryetag\"")
.header("Content-Type", "text/vcard")
.body(Body::from(vcard))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/newcard.vcf"))
.header("If-None-Match", etag)
.header("Content-Type", "text/vcard")
.body(Body::from(vcard))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/newcard.vcf"))
.header("If-None-Match", "*")
.header("Content-Type", "text/vcard")
.body(Body::from(vcard))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[rstest]
#[tokio::test]
async fn test_rfc6352_8_7_1(
#[from(test_store_context)]
#[future]
context: TestStoreContext,
) {
let context = context.await;
let app = get_app(context.clone());
let addr_store = context.addr_store;
let (displayname, description) = (
"Lisa's Contacts".to_owned(),
"My primary address book.".to_owned(),
);
let (principal, addr_id) = ("user", "contacts");
let url = format!("/carddav/principal/{principal}/{addr_id}");
let mut request = Request::builder()
.method("MKCOL")
.uri(&url)
.body(Body::from(format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<D:mkcol xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:set>
<D:prop>
<D:resourcetype>
<D:collection/>
<C:addressbook/>
</D:resourcetype>
<D:displayname>{displayname}</D:displayname>
<C:addressbook-description xml:lang="en"
>{description}</C:addressbook-description>
</D:prop>
</D:set>
</D:mkcol>"#
)))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.extract_string().await;
insta::assert_snapshot!("mkcol_body", body);
let saved_addressbook = addr_store
.get_addressbook(principal, addr_id, false)
.await
.unwrap();
assert_eq!(
(
saved_addressbook.displayname.unwrap(),
saved_addressbook.description.unwrap()
),
(displayname, description)
);
let vcard = r"BEGIN:VCARD
VERSION:3.0
FN:Cyrus Daboo
N:Daboo;Cyrus
ADR;TYPE=POSTAL:;2822 Email HQ;Suite 2821;RFCVille;PA;15213;USA
EMAIL;TYPE=INTERNET,PREF:cyrus@example.com
NICKNAME:me
NOTE:Example VCard.
ORG:Self Employed
TEL;TYPE=WORK,VOICE:412 605 0499
TEL;TYPE=FAX:412 605 0705
URL:http://www.example.com
UID:1234-5678-9000-1
END:VCARD
";
let mut request = Request::builder()
.method("PUT")
.uri(format!("{url}/newcard.vcf"))
.header("If-None-Match", "*")
.header("Content-Type", "text/vcard")
.body(Body::from(vcard))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let mut request = Request::builder()
.method("REPORT")
.uri(&url)
.header("Depth", "infinity")
.header("Content-Type", "text/xml; charset=\"utf-8\"")
.body(Body::from(format!(
r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:addressbook-multiget xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag/>
<C:address-data>
<C:prop name="VERSION"/>
<C:prop name="UID"/>
<C:prop name="NICKNAME"/>
<C:prop name="EMAIL"/>
<C:prop name="FN"/>
</C:address-data>
</D:prop>
<D:href>{url}/newcard.vcf</D:href>
<D:href>/home/bernard/addressbook/vcf1.vcf</D:href>
</C:addressbook-multiget>
"#
)))
.unwrap();
request
.headers_mut()
.typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let body = response.extract_string().await;
insta::assert_snapshot!("multiget_body", body);
}

View File

@@ -0,0 +1,35 @@
---
source: src/integration_tests/carddav/addressbook.rs
expression: body
---
<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<response>
<href>/carddav/principal/user/contacts/newcard.vcf</href>
<propstat>
<prop>
<getetag>&quot;ea0bf4a2ce7ef84606a4cf9235776dbc11b3e7ce351ddf35f27cbc0088acca7e&quot;</getetag>
<CARD:address-data>BEGIN:VCARD
VERSION:3.0
FN:Cyrus Daboo
N:Daboo;Cyrus
ADR;TYPE=POSTAL:;2822 Email HQ;Suite 2821;RFCVille;PA;15213;USA
EMAIL;TYPE=INTERNET,PREF:cyrus@example.com
NICKNAME:me
NOTE:Example VCard.
ORG:Self Employed
TEL;TYPE=WORK,VOICE:412 605 0499
TEL;TYPE=FAX:412 605 0705
URL:http://www.example.com
UID:1234-5678-9000-1
END:VCARD
</CARD:address-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/home/bernard/addressbook/vcf1.vcf</href>
<status>HTTP/1.1 404 Not Found</status>
</response>
</multistatus>

View File

@@ -25,7 +25,7 @@ use std::sync::Arc;
use tokio::sync::mpsc::Receiver;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
use tracing::info;
use tracing::{info, warn};
mod app;
mod commands;
@@ -34,6 +34,9 @@ mod config;
pub mod integration_tests;
mod setup_tracing;
mod migration_0_12;
use migration_0_12::{validate_address_objects_0_12, validate_calendar_objects_0_12};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
@@ -67,15 +70,22 @@ async fn get_data_stores(
Receiver<CollectionOperation>,
)> {
Ok(match &config {
DataStoreConfig::Sqlite(SqliteDataStoreConfig { db_url }) => {
DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url,
run_repairs,
}) => {
let db = create_db_pool(db_url, migrate).await?;
// Channel to watch for changes (for DAV Push)
let (send, recv) = tokio::sync::mpsc::channel(1000);
let addressbook_store = Arc::new(SqliteAddressbookStore::new(db.clone(), send.clone()));
addressbook_store.repair_orphans().await?;
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send));
cal_store.repair_orphans().await?;
if *run_repairs {
info!("Running repair tasks");
addressbook_store.repair_orphans().await?;
cal_store.repair_invalid_version_4_0().await?;
cal_store.repair_orphans().await?;
}
let subscription_store = Arc::new(SqliteStore::new(db.clone()));
let principal_store = Arc::new(SqlitePrincipalStore::new(db));
(
@@ -115,6 +125,14 @@ async fn main() -> Result<()> {
let (addr_store, cal_store, subscription_store, principal_store, update_recv) =
get_data_stores(!args.no_migrations, &config.data_store).await?;
warn!(
"Validating calendar data against the next-version ical parser.
In the next major release these will be rejected and cause errors.
If any errors occur, please open an issue so they can be fixed before the next major release."
);
validate_calendar_objects_0_12(principal_store.as_ref(), cal_store.as_ref()).await?;
validate_address_objects_0_12(principal_store.as_ref(), addr_store.as_ref()).await?;
let mut tasks = vec![];
if config.dav_push.enabled {

76
src/migration_0_12.rs Normal file
View File

@@ -0,0 +1,76 @@
use ical::parser::{ical::IcalObjectParser, vcard::VcardParser};
use rustical_store::{AddressbookStore, CalendarStore, auth::AuthenticationProvider};
use tracing::{error, info};
pub async fn validate_calendar_objects_0_12(
principal_store: &impl AuthenticationProvider,
cal_store: &impl CalendarStore,
) -> Result<(), rustical_store::Error> {
let mut success = true;
for principal in principal_store.get_principals().await? {
for calendar in cal_store.get_calendars(&principal.id).await? {
for (object_id, object) in cal_store
.get_objects(&calendar.principal, &calendar.id)
.await?
{
if let Err(err) =
IcalObjectParser::from_slice(object.get_ics().as_bytes()).expect_one()
{
success = false;
error!(
"An error occured parsing a calendar object: principal={principal}, calendar={calendar}, object_id={object_id}: {err}",
principal = principal.id,
calendar = calendar.id,
);
println!("{}", object.get_ics());
}
}
}
}
if success {
info!("Your calendar data seems to be valid in the next major version.");
} else {
error!(
"Not all calendar objects will be successfully parsed in the next major version (v0.12).
This will not cause issues in this version, but please comment under the tracking issue on GitHub:
https://github.com/lennart-k/rustical/issues/165"
);
}
Ok(())
}
pub async fn validate_address_objects_0_12(
principal_store: &impl AuthenticationProvider,
addr_store: &impl AddressbookStore,
) -> Result<(), rustical_store::Error> {
let mut success = true;
for principal in principal_store.get_principals().await? {
for addressbook in addr_store.get_addressbooks(&principal.id).await? {
for (object_id, object) in addr_store
.get_objects(&addressbook.principal, &addressbook.id)
.await?
{
if let Err(err) = VcardParser::from_slice(object.get_vcf().as_bytes()).expect_one()
{
success = false;
error!(
"An error occured parsing an address object: principal={principal}, addressbook={addressbook}, object_id={object_id}: {err}",
principal = principal.id,
addressbook = addressbook.id,
);
println!("{}", object.get_vcf());
}
}
}
}
if success {
info!("Your addressbook data seems to be valid in the next major version.");
} else {
error!(
"Not all address objects will be successfully parsed in the next major version (v0.12).
This will not cause issues in this version, but please comment under the tracking issue on GitHub:
https://github.com/lennart-k/rustical/issues/165"
);
}
Ok(())
}