Compare commits

...

14 Commits

Author SHA1 Message Date
Lennart K
11a61cf8b1 version 0.12.1 2026-01-20 13:20:04 +01:00
Lennart Kämmle
227d4bc61a Merge pull request #171 from wrvsrx/fix-anniversayr-typo
Fix a typo about anniversary
2026-01-20 13:17:44 +01:00
wrvsrx
d9afc85222 Fix a typo about anniversary 2026-01-20 19:45:50 +08:00
Lennart
c9fe5706a9 clippy appeasement 2026-01-19 17:03:14 +01:00
Lennart
1b6214d426 MKCALENDAR: Handling of invalid timezones 2026-01-19 16:36:25 +01:00
Lennart
be34cc3091 xml: Implement namespace for Unparsed 2026-01-19 16:22:21 +01:00
Lennart
99287f85f4 version 0.12.0 2026-01-19 15:48:56 +01:00
Lennart
df3143cd4c Fix status code for failed preconditions 2026-01-19 15:37:41 +01:00
Lennart Kämmle
92a3418f8e Merge pull request #164 from lennart-k/feat/ical-rewrite
ical-rs overhaul
2026-01-19 15:14:14 +01:00
Lennart
ea2f841269 ical-rs: Pin version to Git commit 2026-01-19 15:04:54 +01:00
Lennart
15e1509fe3 sqlite_store: Add option to skip broken objects and add validation on start-up 2026-01-19 14:48:21 +01:00
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
27 changed files with 355 additions and 187 deletions

26
Cargo.lock generated
View File

@@ -1771,7 +1771,7 @@ dependencies = [
[[package]] [[package]]
name = "ical" name = "ical"
version = "0.12.0-dev" version = "0.12.0-dev"
source = "git+https://github.com/lennart-k/ical-rs?branch=dev#8697656303f182ce173efdaf6aa7e842ffdb3f33" source = "git+https://github.com/lennart-k/ical-rs?rev=f1ad6456fd6cbd1e6da095297febddd2cfe61422#f1ad6456fd6cbd1e6da095297febddd2cfe61422"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz", "chrono-tz",
@@ -3317,7 +3317,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -3364,7 +3364,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait", "async-trait",
@@ -3406,7 +3406,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3440,7 +3440,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3466,7 +3466,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3491,7 +3491,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -3527,7 +3527,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -3546,7 +3546,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3562,7 +3562,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3595,7 +3595,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3620,7 +3620,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -5442,7 +5442,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "xml_derive" name = "xml_derive"
version = "0.11.17" version = "0.12.1"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"heck", "heck",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.11.17" version = "0.12.1"
rust-version = "1.92" rust-version = "1.92"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
@@ -107,7 +107,7 @@ strum = "0.27"
strum_macros = "0.27" strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] } serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { git = "https://github.com/lennart-k/ical-rs", branch = "dev", features = [ ical = { git = "https://github.com/lennart-k/ical-rs", rev = "f1ad6456fd6cbd1e6da095297febddd2cfe61422", features = [
"chrono-tz", "chrono-tz",
] } ] }
toml = "0.9" toml = "0.9"

View File

@@ -26,7 +26,10 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
} }
let parser = ical::IcalParser::from_slice(body.as_bytes()); let parser = ical::IcalParser::from_slice(body.as_bytes());
let mut cal = parser.expect_one()?.mutable(); 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 // Extract calendar metadata
let displayname = cal let displayname = cal
@@ -67,7 +70,10 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
cal_components.push(CalendarObjectType::Todo); cal_components.push(CalendarObjectType::Todo);
} }
let objects = cal.into_objects()?.into_iter().map(Into::into).collect(); 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 { let new_cal = Calendar {
principal, principal,
id: cal_id, id: cal_id,

View File

@@ -1,6 +1,9 @@
use std::str::FromStr;
use crate::Error; use crate::Error;
use crate::calendar::CalendarResourceService; use crate::calendar::CalendarResourceService;
use crate::calendar::prop::SupportedCalendarComponentSet; use crate::calendar::prop::SupportedCalendarComponentSet;
use crate::error::Precondition;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode}; use http::{Method, StatusCode};
@@ -84,21 +87,33 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
} }
let timezone_id = if let Some(tzid) = request.calendar_timezone_id { let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
if chrono_tz::Tz::from_str(&tzid).is_err() {
return Err(Error::PreconditionFailed(Precondition::CalendarTimezone(
"Invalid timezone ID in calendar-timezone-id",
)));
}
Some(tzid) Some(tzid)
} else if let Some(tz) = request.calendar_timezone { } else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::from_slice(tz.as_bytes()) let calendar = IcalParser::from_slice(tz.as_bytes())
.next() .next()
.ok_or_else(|| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))? .ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
.map_err(|_| rustical_dav::Error::BadRequest("Error parsing timezone".to_owned()))?; "No timezone data provided",
)))?
.map_err(|_| {
Error::PreconditionFailed(Precondition::CalendarTimezone("Error parsing timezone"))
})?;
let timezone = calendar.vtimezones.values().next().ok_or_else(|| { let timezone = calendar
rustical_dav::Error::BadRequest("No timezone data provided".to_owned()) .vtimezones
})?; .values()
.next()
.ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
"No timezone data provided",
)))?;
let timezone: Option<chrono_tz::Tz> = timezone.into(); let timezone: Option<chrono_tz::Tz> = timezone.into();
let timezone = timezone.ok_or_else(|| { let timezone = timezone.ok_or(Error::PreconditionFailed(
rustical_dav::Error::BadRequest("Cannot translate VTIMEZONE into IANA TZID".to_owned()) Precondition::CalendarTimezone("No timezone data provided"),
})?; ))?;
Some(timezone.name().to_owned()) Some(timezone.name().to_owned())
} else { } else {

View File

@@ -11,7 +11,7 @@ use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use std::str::FromStr; use std::str::FromStr;
use tracing::{debug, instrument}; use tracing::{instrument, warn};
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]
pub async fn get_event<C: CalendarStore>( pub async fn get_event<C: CalendarStore>(
@@ -94,9 +94,13 @@ pub async fn put_event<C: CalendarStore>(
true true
}; };
let Ok(object) = CalendarObject::from_ics(body.clone()) else { let object = match CalendarObject::from_ics(body.clone()) {
debug!("invalid calendar data:\n{body}"); Ok(object) => object,
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData)); Err(err) => {
warn!("invalid calendar data:\n{body}");
warn!("{err}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
}
}; };
let etag = object.get_etag(); let etag = object.get_etag();
cal_store cal_store

View File

@@ -12,6 +12,9 @@ pub enum Precondition {
#[error("valid-calendar-data")] #[error("valid-calendar-data")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
ValidCalendarData, ValidCalendarData,
#[error("calendar-timezone")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarTimezone(&'static str),
} }
impl IntoResponse for Precondition { impl IntoResponse for Precondition {
@@ -23,7 +26,7 @@ impl IntoResponse for Precondition {
if let Err(err) = error.serialize_root(&mut writer) { if let Err(err) = error.serialize_root(&mut writer) {
return rustical_dav::Error::from(err).into_response(); return rustical_dav::Error::from(err).into_response();
} }
let mut res = Response::builder().status(StatusCode::PRECONDITION_FAILED); let mut res = Response::builder().status(StatusCode::FORBIDDEN);
res.headers_mut().unwrap().typed_insert(ContentType::xml()); res.headers_mut().unwrap().typed_insert(ContentType::xml());
res.body(Body::from(output)).unwrap() res.body(Body::from(output)).unwrap()
} }
@@ -52,9 +55,6 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
XmlDecodeError(#[from] rustical_xml::XmlError), XmlDecodeError(#[from] rustical_xml::XmlError),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
#[error(transparent)] #[error(transparent)]
PreconditionFailed(Precondition), PreconditionFailed(Precondition),
} }
@@ -75,19 +75,20 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST, Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
// TODO: Can also be Bad Request, if it's used input // The correct status code for a failed precondition is not PreconditionFailed but
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR, // Forbidden (or Conflict):
Self::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED, // https://datatracker.ietf.org/doc/html/rfc4791#section-1.3
Self::PreconditionFailed(_err) => StatusCode::FORBIDDEN,
} }
} }
} }
impl IntoResponse for Error { impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
if matches!( if let Self::PreconditionFailed(precondition) = self {
self.status_code(), return precondition.into_response();
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED }
) { if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
error!("{self}"); error!("{self}");
} }
(self.status_code(), self.to_string()).into_response() (self.status_code(), self.to_string()).into_response()

View File

@@ -103,7 +103,10 @@ pub async fn put_object<AS: AddressbookStore>(
true true
}; };
let object = AddressObject::from_vcf(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(); let etag = object.get_etag();
addr_store addr_store
.put_object(&principal, &addressbook_id, &object_id, object, overwrite) .put_object(&principal, &addressbook_id, &object_id, object, overwrite)

View File

@@ -23,9 +23,6 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
XmlDecodeError(#[from] rustical_xml::XmlError), XmlDecodeError(#[from] rustical_xml::XmlError),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
} }
impl Error { impl Error {
@@ -43,8 +40,6 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST, Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
// TODO: Can also be Bad Request, if it's used input
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
} }

View File

@@ -51,19 +51,18 @@ impl Error {
_ => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_REQUEST,
}, },
Self::PropReadOnly => StatusCode::CONFLICT, Self::PropReadOnly => StatusCode::CONFLICT,
Self::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::InternalError | Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::InternalError | Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Forbidden => StatusCode::FORBIDDEN, // The correct status code for a failed precondition is not PreconditionFailed but
// Forbidden (or Conflict):
// https://datatracker.ietf.org/doc/html/rfc4791#section-1.3
Self::PreconditionFailed | Self::Forbidden => StatusCode::FORBIDDEN,
} }
} }
} }
impl axum::response::IntoResponse for Error { impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
if matches!( if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
error!("{self}"); error!("{self}");
} }

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ pub async fn axum_route_proppatch<R: ResourceService>(
route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await
} }
#[allow(clippy::too_many_lines)]
pub async fn route_proppatch<R: ResourceService>( pub async fn route_proppatch<R: ResourceService>(
path_components: &R::PathComponents, path_components: &R::PathComponents,
path: &str, path: &str,
@@ -116,12 +117,14 @@ pub async fn route_proppatch<R: ResourceService>(
} }
} }
SetPropertyPropWrapper::Invalid(invalid) => { SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name(); let Unparsed(propns, propname) = invalid;
if let Some(full_propname) = <R::Resource as Resource>::list_props() if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter() .into_iter()
.find_map(|(ns, tag)| { .find_map(|(ns, tag)| {
if tag == propname.as_str() { if (ns, tag)
== (propns.as_ref().map(NamespaceOwned::as_ref), &propname)
{
Some((ns.map(NamespaceOwned::from), tag.to_owned())) Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else { } else {
None None
@@ -133,7 +136,7 @@ pub async fn route_proppatch<R: ResourceService>(
// - internal properties // - internal properties
props_conflict.push(full_propname); props_conflict.push(full_propname);
} else { } else {
props_not_found.push((None, propname)); props_not_found.push((propns, propname));
} }
} }
} }

View File

@@ -36,7 +36,6 @@ impl Error {
Self::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
Self::AlreadyExists => StatusCode::CONFLICT, Self::AlreadyExists => StatusCode::CONFLICT,
Self::ReadOnly => StatusCode::FORBIDDEN, Self::ReadOnly => StatusCode::FORBIDDEN,
// TODO: Can also be Bad Request, depending on when this is raised
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR, Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST, Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
@@ -53,9 +52,7 @@ impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
if matches!( if matches!(
self.status_code(), self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR | StatusCode::CONFLICT
| StatusCode::PRECONDITION_FAILED
| StatusCode::CONFLICT
) { ) {
error!("{self}"); error!("{self}");
} }

View File

@@ -338,7 +338,7 @@ impl CalendarStore for SqliteAddressbookStore {
out_objects.push((format!("{object_id}-birthday"), birthday)); out_objects.push((format!("{object_id}-birthday"), birthday));
} }
if let Some(anniversary) = object.get_anniversary_object()? { if let Some(anniversary) = object.get_anniversary_object()? {
out_objects.push((format!("{object_id}-anniversayr"), anniversary)); out_objects.push((format!("{object_id}-anniversary"), anniversary));
} }
} }
@@ -382,7 +382,7 @@ impl CalendarStore for SqliteAddressbookStore {
objects.push((format!("{object_id}-birthday"), birthday)); objects.push((format!("{object_id}-birthday"), birthday));
} }
if let Some(anniversary) = object.get_anniversary_object()? { if let Some(anniversary) = object.get_anniversary_object()? {
objects.push((format!("{object_id}-anniversayr"), anniversary)); objects.push((format!("{object_id}-anniversary"), anniversary));
} }
} }
Ok(objects) Ok(objects)

View File

@@ -2,6 +2,7 @@ use super::ChangeOperation;
use crate::BEGIN_IMMEDIATE; use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait; use async_trait::async_trait;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use ical::parser::ParserError;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{ use rustical_store::{
Addressbook, AddressbookStore, CollectionMetadata, CollectionOperation, Addressbook, AddressbookStore, CollectionMetadata, CollectionOperation,
@@ -9,7 +10,7 @@ use rustical_store::{
}; };
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tracing::{error_span, instrument, warn}; use tracing::{error, error_span, instrument, warn};
pub mod birthday_calendar; pub mod birthday_calendar;
@@ -18,6 +19,12 @@ struct AddressObjectRow {
id: String, id: String,
vcf: String, vcf: String,
} }
impl From<AddressObjectRow> for (String, Result<AddressObject, ParserError>) {
fn from(row: AddressObjectRow) -> Self {
let result = AddressObject::from_vcf(row.vcf);
(row.id, result)
}
}
impl TryFrom<AddressObjectRow> for (String, AddressObject) { impl TryFrom<AddressObjectRow> for (String, AddressObject) {
type Error = rustical_store::Error; type Error = rustical_store::Error;
@@ -31,6 +38,7 @@ impl TryFrom<AddressObjectRow> for (String, AddressObject) {
pub struct SqliteAddressbookStore { pub struct SqliteAddressbookStore {
db: SqlitePool, db: SqlitePool,
sender: Sender<CollectionOperation>, sender: Sender<CollectionOperation>,
skip_broken: bool,
} }
impl SqliteAddressbookStore { impl SqliteAddressbookStore {
@@ -88,6 +96,36 @@ impl SqliteAddressbookStore {
Ok(()) Ok(())
} }
#[allow(clippy::missing_panics_doc)]
pub async fn validate_objects(&self, principal: &str) -> Result<(), Error> {
let mut success = true;
for addressbook in self.get_addressbooks(principal).await? {
for (object_id, res) in Self::_get_objects(&self.db, principal, &addressbook.id).await?
{
if let Err(err) = res {
warn!(
"Invalid address object found at {principal}/{addr_id}/{object_id}.vcf. Error: {err}",
addr_id = addressbook.id
);
success = false;
}
}
}
if !success {
if self.skip_broken {
error!(
"Not all address objects are valid. Since data_store.sqlite.skip_broken=true they will be hidden. You are still advised to manually remove or repair the object. If you need help feel free to open up an issue on GitHub."
);
} else {
error!(
"Not all address objects are valid. Since data_store.sqlite.skip_broken=false this causes a panic. Remove or repair the broken objects manually or set data_store.sqlite.skip_broken=false as a temporary solution to ignore the error. If you need help feel free to open up an issue on GitHub."
);
panic!();
}
}
Ok(())
}
// Logs an operation to an address object // Logs an operation to an address object
async fn log_object_operation( async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>, tx: &mut Transaction<'_, Sqlite>,
@@ -134,7 +172,7 @@ impl SqliteAddressbookStore {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) { if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!( error_span!(
"Error trying to send addressbook update notification:", "Error trying to send addressbook update notification:",
err = format!("{err:?}"), err = format!("{err}"),
); );
} }
} }
@@ -353,8 +391,8 @@ impl SqliteAddressbookStore {
executor: E, executor: E,
principal: &str, principal: &str,
addressbook_id: &str, addressbook_id: &str,
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> { ) -> Result<impl Iterator<Item = (String, Result<AddressObject, ParserError>)>, Error> {
sqlx::query_as!( Ok(sqlx::query_as!(
AddressObjectRow, AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL", "SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
principal, principal,
@@ -363,8 +401,8 @@ impl SqliteAddressbookStore {
.fetch_all(executor) .fetch_all(executor)
.await.map_err(crate::Error::from)? .await.map_err(crate::Error::from)?
.into_iter() .into_iter()
.map(std::convert::TryInto::try_into) .map(Into::into)
.collect() )
} }
async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -607,7 +645,16 @@ impl AddressbookStore for SqliteAddressbookStore {
principal: &str, principal: &str,
addressbook_id: &str, addressbook_id: &str,
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> { ) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
Self::_get_objects(&self.db, principal, addressbook_id).await let objects = Self::_get_objects(&self.db, principal, addressbook_id).await?;
if self.skip_broken {
Ok(objects
.filter_map(|(id, res)| Some((id, res.ok()?)))
.collect())
} else {
Ok(objects
.map(|(id, res)| res.map(|obj| (id, obj)))
.collect::<Result<Vec<_>, _>>()?)
}
} }
#[instrument] #[instrument]

View File

@@ -3,6 +3,7 @@ use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::TimeDelta; use chrono::TimeDelta;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use ical::parser::ParserError;
use ical::types::CalDateTime; use ical::types::CalDateTime;
use regex::Regex; use regex::Regex;
use rustical_ical::{CalendarObject, CalendarObjectType}; use rustical_ical::{CalendarObject, CalendarObjectType};
@@ -13,7 +14,7 @@ use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tracing::{error_span, instrument, warn}; use tracing::{error, error_span, instrument, warn};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct CalendarObjectRow { struct CalendarObjectRow {
@@ -22,6 +23,23 @@ struct CalendarObjectRow {
uid: String, uid: String,
} }
impl From<CalendarObjectRow> for (String, Result<CalendarObject, ParserError>) {
fn from(row: CalendarObjectRow) -> Self {
let result = CalendarObject::from_ics(row.ics).inspect(|object| {
if object.get_uid() != row.uid {
warn!(
"Calendar object {}.ics: UID={} and row uid={} do not match",
row.id,
object.get_uid(),
row.uid
);
}
});
(row.id, result)
}
}
impl TryFrom<CalendarObjectRow> for (String, CalendarObject) { impl TryFrom<CalendarObjectRow> for (String, CalendarObject) {
type Error = rustical_store::Error; type Error = rustical_store::Error;
@@ -92,6 +110,7 @@ impl From<CalendarRow> for Calendar {
pub struct SqliteCalendarStore { pub struct SqliteCalendarStore {
db: SqlitePool, db: SqlitePool,
sender: Sender<CollectionOperation>, sender: Sender<CollectionOperation>,
skip_broken: bool,
} }
impl SqliteCalendarStore { impl SqliteCalendarStore {
@@ -141,11 +160,40 @@ impl SqliteCalendarStore {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) { if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!( error_span!(
"Error trying to send calendar update notification:", "Error trying to send calendar update notification:",
err = format!("{err:?}"), err = format!("{err}"),
); );
} }
} }
#[allow(clippy::missing_panics_doc)]
pub async fn validate_objects(&self, principal: &str) -> Result<(), Error> {
let mut success = true;
for calendar in self.get_calendars(principal).await? {
for (object_id, res) in Self::_get_objects(&self.db, principal, &calendar.id).await? {
if let Err(err) = res {
warn!(
"Invalid calendar object found at {principal}/{cal_id}/{object_id}.ics. Error: {err}",
cal_id = calendar.id
);
success = false;
}
}
}
if !success {
if self.skip_broken {
error!(
"Not all calendar objects are valid. Since data_store.sqlite.skip_broken=true they will be hidden. You are still advised to manually remove or repair the object. If you need help feel free to open up an issue on GitHub."
);
} else {
error!(
"Not all calendar objects are valid. Since data_store.sqlite.skip_broken=false this causes a panic. Remove or repair the broken objects manually or set data_store.sqlite.skip_broken=false as a temporary solution to ignore the error. If you need help feel free to open up an issue on GitHub."
);
panic!();
}
}
Ok(())
}
/// In the past exports generated objects with invalid VERSION:4.0 /// In the past exports generated objects with invalid VERSION:4.0
/// This repair sets them to VERSION:2.0 /// This repair sets them to VERSION:2.0
#[allow(clippy::missing_panics_doc)] #[allow(clippy::missing_panics_doc)]
@@ -456,8 +504,8 @@ impl SqliteCalendarStore {
executor: E, executor: E,
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<impl Iterator<Item = (String, Result<CalendarObject, ParserError>)>, Error> {
sqlx::query_as!( Ok(sqlx::query_as!(
CalendarObjectRow, CalendarObjectRow,
"SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL", "SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
principal, principal,
@@ -466,8 +514,8 @@ impl SqliteCalendarStore {
.fetch_all(executor) .fetch_all(executor)
.await.map_err(crate::Error::from)? .await.map_err(crate::Error::from)?
.into_iter() .into_iter()
.map(std::convert::TryInto::try_into) .map(Into::into)
.collect() )
} }
async fn _calendar_query<'e, E: Executor<'e, Database = Sqlite>>( async fn _calendar_query<'e, E: Executor<'e, Database = Sqlite>>(
@@ -475,14 +523,14 @@ impl SqliteCalendarStore {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
query: CalendarQuery, query: CalendarQuery,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<impl Iterator<Item = (String, Result<CalendarObject, ParserError>)>, Error> {
// We extend our query interval by one day in each direction since we really don't want to // 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 // miss any objects because of timezone differences
// I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these // I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these
let start = query.time_start.map(|start| start - TimeDelta::days(1)); let start = query.time_start.map(|start| start - TimeDelta::days(1));
let end = query.time_end.map(|end| end + TimeDelta::days(1)); let end = query.time_end.map(|end| end + TimeDelta::days(1));
sqlx::query_as!( Ok(sqlx::query_as!(
CalendarObjectRow, CalendarObjectRow,
r"SELECT id, uid, ics FROM calendarobjects r"SELECT id, uid, ics FROM calendarobjects
WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL
@@ -500,8 +548,7 @@ impl SqliteCalendarStore {
.await .await
.map_err(crate::Error::from)? .map_err(crate::Error::from)?
.into_iter() .into_iter()
.map(std::convert::TryInto::try_into) .map(Into::into))
.collect()
} }
async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -641,6 +688,7 @@ impl SqliteCalendarStore {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
synctoken: i64, synctoken: i64,
skip_broken: bool,
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> { ) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
struct Row { struct Row {
object_id: String, object_id: String,
@@ -670,6 +718,8 @@ impl SqliteCalendarStore {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await { match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
Ok(object) => objects.push((object_id, object)), Ok(object) => objects.push((object_id, object)),
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id), Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
// Skip broken object
Err(rustical_store::Error::IcalError(_)) if skip_broken => (),
Err(err) => return Err(err), Err(err) => return Err(err),
} }
} }
@@ -820,7 +870,16 @@ impl CalendarStore for SqliteCalendarStore {
cal_id: &str, cal_id: &str,
query: CalendarQuery, query: CalendarQuery,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_calendar_query(&self.db, principal, cal_id, query).await let objects = Self::_calendar_query(&self.db, principal, cal_id, query).await?;
if self.skip_broken {
Ok(objects
.filter_map(|(id, res)| Some((id, res.ok()?)))
.collect())
} else {
Ok(objects
.map(|(id, res)| res.map(|obj| (id, obj)))
.collect::<Result<Vec<_>, _>>()?)
}
} }
async fn calendar_metadata( async fn calendar_metadata(
@@ -851,7 +910,16 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_get_objects(&self.db, principal, cal_id).await let objects = Self::_get_objects(&self.db, principal, cal_id).await?;
if self.skip_broken {
Ok(objects
.filter_map(|(id, res)| Some((id, res.ok()?)))
.collect())
} else {
Ok(objects
.map(|(id, res)| res.map(|obj| (id, obj)))
.collect::<Result<Vec<_>, _>>()?)
}
} }
#[instrument] #[instrument]
@@ -974,7 +1042,7 @@ impl CalendarStore for SqliteCalendarStore {
cal_id: &str, cal_id: &str,
synctoken: i64, synctoken: i64,
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> { ) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
Self::_sync_changes(&self.db, principal, cal_id, synctoken).await Self::_sync_changes(&self.db, principal, cal_id, synctoken, self.skip_broken).await
} }
fn is_read_only(&self, _cal_id: &str) -> bool { fn is_read_only(&self, _cal_id: &str) -> bool {

View File

@@ -18,7 +18,7 @@ impl From<sqlx::Error> for Error {
sqlx::Error::RowNotFound => Self::StoreError(rustical_store::Error::NotFound), sqlx::Error::RowNotFound => Self::StoreError(rustical_store::Error::NotFound),
sqlx::Error::Database(err) => { sqlx::Error::Database(err) => {
if err.is_unique_violation() { if err.is_unique_violation() {
warn!("{err:?}"); warn!("{err}");
Self::StoreError(rustical_store::Error::AlreadyExists) Self::StoreError(rustical_store::Error::AlreadyExists)
} else { } else {
Self::SqlxError(sqlx::Error::Database(err)) Self::SqlxError(sqlx::Error::Database(err))

View File

@@ -52,8 +52,8 @@ pub async fn test_store_context() -> TestStoreContext {
let db = get_test_db().await; let db = get_test_db().await;
TestStoreContext { TestStoreContext {
db: db.clone(), db: db.clone(),
addr_store: SqliteAddressbookStore::new(db.clone(), send_addr), addr_store: SqliteAddressbookStore::new(db.clone(), send_addr, false),
cal_store: SqliteCalendarStore::new(db.clone(), send_cal), cal_store: SqliteCalendarStore::new(db.clone(), send_cal, false),
principal_store: SqlitePrincipalStore::new(db.clone()), principal_store: SqlitePrincipalStore::new(db.clone()),
sub_store: SqliteStore::new(db), sub_store: SqliteStore::new(db),
} }

View File

@@ -1,6 +1,6 @@
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct NamespaceOwned(pub Vec<u8>); pub struct NamespaceOwned(pub Vec<u8>);
impl<'a> From<Namespace<'a>> for NamespaceOwned { impl<'a> From<Namespace<'a>> for NamespaceOwned {

View File

@@ -1,18 +1,21 @@
use std::io::BufRead; use std::io::BufRead;
use quick_xml::events::BytesStart; use quick_xml::{events::BytesStart, name::ResolveResult};
use crate::{XmlDeserialize, XmlError}; use crate::{NamespaceOwned, XmlDeserialize, XmlError};
// TODO: actually implement
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Unparsed(String); pub struct Unparsed(pub Option<NamespaceOwned>, pub String);
impl Unparsed { impl Unparsed {
#[must_use] #[must_use]
pub fn tag_name(&self) -> String { pub const fn ns(&self) -> Option<&NamespaceOwned> {
// TODO: respect namespace? self.0.as_ref()
self.0.clone() }
#[must_use]
pub const fn tag_name(&self) -> &str {
self.1.as_str()
} }
} }
@@ -27,7 +30,12 @@ impl XmlDeserialize for Unparsed {
let mut buf = vec![]; let mut buf = vec![];
reader.read_to_end_into(start.name(), &mut buf)?; reader.read_to_end_into(start.name(), &mut buf)?;
} }
let tag_name = String::from_utf8_lossy(start.local_name().as_ref()).to_string(); let (ns, tag_name) = reader.resolver().resolve_element(start.name());
Ok(Self(tag_name)) let ns: Option<NamespaceOwned> = match ns {
ResolveResult::Bound(ns) => Some(ns.into()),
ResolveResult::Unbound | ResolveResult::Unknown(_) => None,
};
let tag_name = String::from_utf8_lossy(tag_name.as_ref()).to_string();
Ok(Self(ns, tag_name))
} }
} }

View File

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

View File

@@ -28,6 +28,8 @@ pub struct SqliteDataStoreConfig {
pub db_url: String, pub db_url: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub run_repairs: bool, pub run_repairs: bool,
#[serde(default = "default_true")]
pub skip_broken: bool,
} }
#[derive(Debug, Deserialize, Serialize)] #[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 rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt; use tower::ServiceExt;
fn mkcalendar_template( pub fn mkcalendar_template(
CalendarMetadata { CalendarMetadata {
displayname, displayname,
order: _order, order: _order,

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::FORBIDDEN);
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

@@ -9,6 +9,7 @@ use tower::ServiceExt;
mod calendar; mod calendar;
mod calendar_import; mod calendar_import;
mod calendar_put;
mod calendar_report; mod calendar_report;
#[rstest] #[rstest]

View File

@@ -34,9 +34,6 @@ mod config;
pub mod integration_tests; pub mod integration_tests;
mod setup_tracing; mod setup_tracing;
mod migration_0_12;
use migration_0_12::{validate_address_objects_0_12, validate_calendar_objects_0_12};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Args { struct Args {
@@ -73,13 +70,18 @@ async fn get_data_stores(
DataStoreConfig::Sqlite(SqliteDataStoreConfig { DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url, db_url,
run_repairs, run_repairs,
skip_broken,
}) => { }) => {
let db = create_db_pool(db_url, migrate).await?; let db = create_db_pool(db_url, migrate).await?;
// Channel to watch for changes (for DAV Push) // Channel to watch for changes (for DAV Push)
let (send, recv) = tokio::sync::mpsc::channel(1000); let (send, recv) = tokio::sync::mpsc::channel(1000);
let addressbook_store = Arc::new(SqliteAddressbookStore::new(db.clone(), send.clone())); let addressbook_store = Arc::new(SqliteAddressbookStore::new(
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send)); db.clone(),
send.clone(),
*skip_broken,
));
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send, *skip_broken));
if *run_repairs { if *run_repairs {
info!("Running repair tasks"); info!("Running repair tasks");
addressbook_store.repair_orphans().await?; addressbook_store.repair_orphans().await?;
@@ -88,6 +90,13 @@ async fn get_data_stores(
} }
let subscription_store = Arc::new(SqliteStore::new(db.clone())); let subscription_store = Arc::new(SqliteStore::new(db.clone()));
let principal_store = Arc::new(SqlitePrincipalStore::new(db)); let principal_store = Arc::new(SqlitePrincipalStore::new(db));
// Validate all calendar objects
for principal in principal_store.get_principals().await? {
cal_store.validate_objects(&principal.id).await?;
addressbook_store.validate_objects(&principal.id).await?;
}
( (
addressbook_store, addressbook_store,
cal_store, cal_store,
@@ -125,14 +134,6 @@ async fn main() -> Result<()> {
let (addr_store, cal_store, subscription_store, principal_store, update_recv) = let (addr_store, cal_store, subscription_store, principal_store, update_recv) =
get_data_stores(!args.no_migrations, &config.data_store).await?; 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![]; let mut tasks = vec![];
if config.dav_push.enabled { if config.dav_push.enabled {

View File

@@ -1,76 +0,0 @@
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(())
}