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]]
name = "ical"
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 = [
"chrono",
"chrono-tz",
@@ -3317,7 +3317,7 @@ dependencies = [
[[package]]
name = "rustical"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"anyhow",
"argon2",
@@ -3364,7 +3364,7 @@ dependencies = [
[[package]]
name = "rustical_caldav"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"async-std",
"async-trait",
@@ -3406,7 +3406,7 @@ dependencies = [
[[package]]
name = "rustical_carddav"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"async-trait",
"axum",
@@ -3440,7 +3440,7 @@ dependencies = [
[[package]]
name = "rustical_dav"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"async-trait",
"axum",
@@ -3466,7 +3466,7 @@ dependencies = [
[[package]]
name = "rustical_dav_push"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"async-trait",
"axum",
@@ -3491,7 +3491,7 @@ dependencies = [
[[package]]
name = "rustical_frontend"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"askama",
"askama_web",
@@ -3527,7 +3527,7 @@ dependencies = [
[[package]]
name = "rustical_ical"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"axum",
"chrono",
@@ -3546,7 +3546,7 @@ dependencies = [
[[package]]
name = "rustical_oidc"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"async-trait",
"axum",
@@ -3562,7 +3562,7 @@ dependencies = [
[[package]]
name = "rustical_store"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"anyhow",
"async-trait",
@@ -3595,7 +3595,7 @@ dependencies = [
[[package]]
name = "rustical_store_sqlite"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"async-trait",
"chrono",
@@ -3620,7 +3620,7 @@ dependencies = [
[[package]]
name = "rustical_xml"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"quick-xml",
"thiserror 2.0.18",
@@ -5442,7 +5442,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "xml_derive"
version = "0.11.17"
version = "0.12.1"
dependencies = [
"darling 0.23.0",
"heck",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.11.17"
version = "0.12.1"
rust-version = "1.92"
edition = "2024"
description = "A CalDAV server"
@@ -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", branch = "dev", features = [
ical = { git = "https://github.com/lennart-k/ical-rs", rev = "f1ad6456fd6cbd1e6da095297febddd2cfe61422", features = [
"chrono-tz",
] }
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 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
let displayname = cal
@@ -67,7 +70,10 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
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 {
principal,
id: cal_id,

View File

@@ -1,6 +1,9 @@
use std::str::FromStr;
use crate::Error;
use crate::calendar::CalendarResourceService;
use crate::calendar::prop::SupportedCalendarComponentSet;
use crate::error::Precondition;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
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 {
if chrono_tz::Tz::from_str(&tzid).is_err() {
return Err(Error::PreconditionFailed(Precondition::CalendarTimezone(
"Invalid timezone ID in calendar-timezone-id",
)));
}
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
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("Error parsing timezone".to_owned()))?;
.ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
"No timezone data provided",
)))?
.map_err(|_| {
Error::PreconditionFailed(Precondition::CalendarTimezone("Error parsing timezone"))
})?;
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
let timezone = calendar
.vtimezones
.values()
.next()
.ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
"No timezone data provided",
)))?;
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())
})?;
let timezone = timezone.ok_or(Error::PreconditionFailed(
Precondition::CalendarTimezone("No timezone data provided"),
))?;
Some(timezone.name().to_owned())
} else {

View File

@@ -11,7 +11,7 @@ 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>(
@@ -94,9 +94,13 @@ pub async fn put_event<C: CalendarStore>(
true
};
let Ok(object) = CalendarObject::from_ics(body.clone()) 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

View File

@@ -12,6 +12,9 @@ pub enum Precondition {
#[error("valid-calendar-data")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
ValidCalendarData,
#[error("calendar-timezone")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarTimezone(&'static str),
}
impl IntoResponse for Precondition {
@@ -23,7 +26,7 @@ impl IntoResponse for Precondition {
if let Err(err) = error.serialize_root(&mut writer) {
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.body(Body::from(output)).unwrap()
}
@@ -52,9 +55,6 @@ pub enum Error {
#[error(transparent)]
XmlDecodeError(#[from] rustical_xml::XmlError),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
#[error(transparent)]
PreconditionFailed(Precondition),
}
@@ -75,19 +75,20 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND,
// TODO: Can also be Bad Request, if it's used input
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
Self::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED,
// 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(_err) => StatusCode::FORBIDDEN,
}
}
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
if let Self::PreconditionFailed(precondition) = self {
return precondition.into_response();
}
if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
error!("{self}");
}
(self.status_code(), self.to_string()).into_response()

View File

@@ -103,7 +103,10 @@ pub async fn put_object<AS: AddressbookStore>(
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();
addr_store
.put_object(&principal, &addressbook_id, &object_id, object, overwrite)

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,8 +40,6 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
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,
},
Self::PropReadOnly => StatusCode::CONFLICT,
Self::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
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 {
fn into_response(self) -> axum::response::Response {
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
error!("{self}");
}

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

@@ -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

@@ -71,6 +71,7 @@ pub async fn axum_route_proppatch<R: ResourceService>(
route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await
}
#[allow(clippy::too_many_lines)]
pub async fn route_proppatch<R: ResourceService>(
path_components: &R::PathComponents,
path: &str,
@@ -116,12 +117,14 @@ pub async fn route_proppatch<R: ResourceService>(
}
}
SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name();
let Unparsed(propns, propname) = invalid;
if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter()
.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()))
} else {
None
@@ -133,7 +136,7 @@ pub async fn route_proppatch<R: ResourceService>(
// - internal properties
props_conflict.push(full_propname);
} 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::AlreadyExists => StatusCode::CONFLICT,
Self::ReadOnly => StatusCode::FORBIDDEN,
// 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,
@@ -53,9 +52,7 @@ impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR
| StatusCode::PRECONDITION_FAILED
| StatusCode::CONFLICT
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::CONFLICT
) {
error!("{self}");
}

View File

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

View File

@@ -2,6 +2,7 @@ use super::ChangeOperation;
use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait;
use derive_more::derive::Constructor;
use ical::parser::ParserError;
use rustical_ical::AddressObject;
use rustical_store::{
Addressbook, AddressbookStore, CollectionMetadata, CollectionOperation,
@@ -9,7 +10,7 @@ use rustical_store::{
};
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender;
use tracing::{error_span, instrument, warn};
use tracing::{error, error_span, instrument, warn};
pub mod birthday_calendar;
@@ -18,6 +19,12 @@ struct AddressObjectRow {
id: 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) {
type Error = rustical_store::Error;
@@ -31,6 +38,7 @@ impl TryFrom<AddressObjectRow> for (String, AddressObject) {
pub struct SqliteAddressbookStore {
db: SqlitePool,
sender: Sender<CollectionOperation>,
skip_broken: bool,
}
impl SqliteAddressbookStore {
@@ -88,6 +96,36 @@ impl SqliteAddressbookStore {
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
async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>,
@@ -134,7 +172,7 @@ impl SqliteAddressbookStore {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!(
"Error trying to send addressbook update notification:",
err = format!("{err:?}"),
err = format!("{err}"),
);
}
}
@@ -353,8 +391,8 @@ impl SqliteAddressbookStore {
executor: E,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
sqlx::query_as!(
) -> Result<impl Iterator<Item = (String, Result<AddressObject, ParserError>)>, Error> {
Ok(sqlx::query_as!(
AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
principal,
@@ -363,8 +401,8 @@ impl SqliteAddressbookStore {
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(std::convert::TryInto::try_into)
.collect()
.map(Into::into)
)
}
async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -607,7 +645,16 @@ impl AddressbookStore for SqliteAddressbookStore {
principal: &str,
addressbook_id: &str,
) -> 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]

View File

@@ -3,6 +3,7 @@ use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait;
use chrono::TimeDelta;
use derive_more::derive::Constructor;
use ical::parser::ParserError;
use ical::types::CalDateTime;
use regex::Regex;
use rustical_ical::{CalendarObject, CalendarObjectType};
@@ -13,7 +14,7 @@ use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender;
use tracing::{error_span, instrument, warn};
use tracing::{error, error_span, instrument, warn};
#[derive(Debug, Clone)]
struct CalendarObjectRow {
@@ -22,6 +23,23 @@ struct CalendarObjectRow {
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) {
type Error = rustical_store::Error;
@@ -92,6 +110,7 @@ impl From<CalendarRow> for Calendar {
pub struct SqliteCalendarStore {
db: SqlitePool,
sender: Sender<CollectionOperation>,
skip_broken: bool,
}
impl SqliteCalendarStore {
@@ -141,11 +160,40 @@ impl SqliteCalendarStore {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!(
"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
/// This repair sets them to VERSION:2.0
#[allow(clippy::missing_panics_doc)]
@@ -456,8 +504,8 @@ impl SqliteCalendarStore {
executor: E,
principal: &str,
cal_id: &str,
) -> Result<Vec<(String, CalendarObject)>, Error> {
sqlx::query_as!(
) -> Result<impl Iterator<Item = (String, Result<CalendarObject, ParserError>)>, Error> {
Ok(sqlx::query_as!(
CalendarObjectRow,
"SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
principal,
@@ -466,8 +514,8 @@ impl SqliteCalendarStore {
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(std::convert::TryInto::try_into)
.collect()
.map(Into::into)
)
}
async fn _calendar_query<'e, E: Executor<'e, Database = Sqlite>>(
@@ -475,14 +523,14 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
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
// miss any objects because of timezone differences
// 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 end = query.time_end.map(|end| end + TimeDelta::days(1));
sqlx::query_as!(
Ok(sqlx::query_as!(
CalendarObjectRow,
r"SELECT id, uid, ics FROM calendarobjects
WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL
@@ -500,8 +548,7 @@ impl SqliteCalendarStore {
.await
.map_err(crate::Error::from)?
.into_iter()
.map(std::convert::TryInto::try_into)
.collect()
.map(Into::into))
}
async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -641,6 +688,7 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
synctoken: i64,
skip_broken: bool,
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
struct Row {
object_id: String,
@@ -670,6 +718,8 @@ impl SqliteCalendarStore {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
Ok(object) => objects.push((object_id, object)),
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),
}
}
@@ -820,7 +870,16 @@ impl CalendarStore for SqliteCalendarStore {
cal_id: &str,
query: CalendarQuery,
) -> 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(
@@ -851,7 +910,16 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str,
cal_id: &str,
) -> 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]
@@ -974,7 +1042,7 @@ impl CalendarStore for SqliteCalendarStore {
cal_id: &str,
synctoken: i64,
) -> 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 {

View File

@@ -18,7 +18,7 @@ impl From<sqlx::Error> for Error {
sqlx::Error::RowNotFound => Self::StoreError(rustical_store::Error::NotFound),
sqlx::Error::Database(err) => {
if err.is_unique_violation() {
warn!("{err:?}");
warn!("{err}");
Self::StoreError(rustical_store::Error::AlreadyExists)
} else {
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;
TestStoreContext {
db: db.clone(),
addr_store: SqliteAddressbookStore::new(db.clone(), send_addr),
cal_store: SqliteCalendarStore::new(db.clone(), send_cal),
addr_store: SqliteAddressbookStore::new(db.clone(), send_addr, false),
cal_store: SqliteCalendarStore::new(db.clone(), send_cal, false),
principal_store: SqlitePrincipalStore::new(db.clone()),
sub_store: SqliteStore::new(db),
}

View File

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

View File

@@ -1,18 +1,21 @@
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)]
pub struct Unparsed(String);
pub struct Unparsed(pub Option<NamespaceOwned>, pub String);
impl Unparsed {
#[must_use]
pub fn tag_name(&self) -> String {
// TODO: respect namespace?
self.0.clone()
pub const fn ns(&self) -> Option<&NamespaceOwned> {
self.0.as_ref()
}
#[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![];
reader.read_to_end_into(start.name(), &mut buf)?;
}
let tag_name = String::from_utf8_lossy(start.local_name().as_ref()).to_string();
Ok(Self(tag_name))
let (ns, tag_name) = reader.resolver().resolve_element(start.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 {
db_url: "/var/lib/rustical/db.sqlite3".to_owned(),
run_repairs: true,
skip_broken: true,
}),
tracing: TracingConfig::default(),
frontend: FrontendConfig {

View File

@@ -28,6 +28,8 @@ pub struct SqliteDataStoreConfig {
pub db_url: String,
#[serde(default = "default_true")]
pub run_repairs: bool,
#[serde(default = "default_true")]
pub skip_broken: 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,

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_import;
mod calendar_put;
mod calendar_report;
#[rstest]

View File

@@ -34,9 +34,6 @@ 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 {
@@ -73,13 +70,18 @@ async fn get_data_stores(
DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url,
run_repairs,
skip_broken,
}) => {
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()));
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send));
let addressbook_store = Arc::new(SqliteAddressbookStore::new(
db.clone(),
send.clone(),
*skip_broken,
));
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send, *skip_broken));
if *run_repairs {
info!("Running repair tasks");
addressbook_store.repair_orphans().await?;
@@ -88,6 +90,13 @@ async fn get_data_stores(
}
let subscription_store = Arc::new(SqliteStore::new(db.clone()));
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,
cal_store,
@@ -125,14 +134,6 @@ 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 {

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