Compare commits

..

11 Commits

Author SHA1 Message Date
Lennart
aa02d11f58 Increase version number to 0.3.0 2025-06-14 20:33:25 +02:00
Lennart
1c31323512 Remove optional dependencies to remove openssl dependency 2025-06-14 20:32:10 +02:00
Lennart
03ae492483 Implement DAV Push 2025-06-14 20:24:50 +02:00
Lennart
0c48507f0c dav: Fix Destination header percent decoding 2025-06-14 16:49:34 +02:00
Lennart
829d4a4385 dav: MOVE/COPY remove origin from Destination header 2025-06-14 15:46:39 +02:00
Lennart
4fe28c5b0f dav: Make MethodFunction public 2025-06-14 15:24:23 +02:00
Lennart
529f36ad99 dav: Convert is_collection const to function which will make filesystem access easier 2025-06-14 15:21:10 +02:00
Lennart
ca5891314c Forgot to commit Cargo.lock 2025-06-14 14:58:33 +02:00
Lennart
e653c68cae Set log level for 404 2025-06-14 14:57:42 +02:00
Lennart
26941c621b Update version to v0.2.2 2025-06-14 14:44:47 +02:00
Lennart
86ab6ef75e dav: Add interface for copy and move 2025-06-14 14:44:10 +02:00
37 changed files with 2595 additions and 722 deletions

54
Cargo.lock generated
View File

@@ -759,6 +759,19 @@ dependencies = [
"spki", "spki",
] ]
[[package]]
name = "ece"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
dependencies = [
"base64 0.21.7",
"byteorder",
"hex",
"once_cell",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@@ -1622,6 +1635,18 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matchit-serde"
version = "0.1.0"
source = "git+https://github.com/lennart-k/matchit-serde?rev=f0591d13#f0591d139ea1c88fa4ee397f3fcb4225fad4c6dc"
dependencies = [
"derive_more",
"matchit",
"percent-encoding",
"serde",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -2644,7 +2669,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -2687,7 +2712,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2722,7 +2747,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2754,7 +2779,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2765,6 +2790,8 @@ dependencies = [
"http", "http",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"matchit",
"matchit-serde",
"quick-xml", "quick-xml",
"rustical_xml", "rustical_xml",
"serde", "serde",
@@ -2777,15 +2804,20 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum",
"base64 0.22.1",
"derive_more", "derive_more",
"ece",
"futures-util", "futures-util",
"http", "http",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"p256",
"quick-xml", "quick-xml",
"rand 0.9.1",
"reqwest", "reqwest",
"rustical_dav", "rustical_dav",
"rustical_store", "rustical_store",
@@ -2798,7 +2830,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -2831,7 +2863,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -2849,7 +2881,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2864,7 +2896,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -2898,7 +2930,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -2918,7 +2950,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.1.0" version = "0.2.2"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.12", "thiserror 2.0.12",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical" repository = "https://github.com/lennart-k/rustical"
@@ -34,6 +34,7 @@ opentelemetry = [
debug = 0 debug = 0
[workspace.dependencies] [workspace.dependencies]
matchit = "0.8"
uuid = { version = "1.11", features = ["v4", "fast-rng"] } uuid = { version = "1.11", features = ["v4", "fast-rng"] }
async-trait = "0.1" async-trait = "0.1"
axum = "0.8" axum = "0.8"
@@ -133,6 +134,9 @@ reqwest = { version = "0.12", features = [
], default-features = false } ], default-features = false }
openidconnect = "4.0" openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "f0591d13" }
ece = { version = "2.3", default-features = false }
p256 = { version = "0.13", features = ["ecdh"] }
[dependencies] [dependencies]
rustical_store = { workspace = true } rustical_store = { workspace = true }

View File

@@ -11,7 +11,8 @@ a CalDAV/CardDAV server
## Features ## Features
- easy to backup, everything saved in one SQLite database - easy to backup, everything saved in one SQLite database
- ~~[WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5~~ (currently broken) - also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary) - lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks) - adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable - deleted calendars are recoverable

View File

@@ -7,5 +7,6 @@ accepted = [
"CDLA-Permissive-2.0", "CDLA-Permissive-2.0",
"Zlib", "Zlib",
"AGPL-3.0", "AGPL-3.0",
"MPL-2.0",
] ]
workarounds = ["ring", "chrono", "rustls"] workarounds = ["ring", "chrono", "rustls"]

View File

@@ -1,4 +1,4 @@
pub mod mkcalendar;
// pub mod post;
pub mod get; pub mod get;
pub mod mkcalendar;
pub mod post;
pub mod report; pub mod report;

View File

@@ -1,8 +1,9 @@
use crate::Error; use crate::Error;
use crate::calendar::resource::{CalendarResource, CalendarResourceService}; use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use http::{HeaderMap, StatusCode, header}; use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister; use rustical_dav_push::register::PushRegister;
@@ -73,20 +74,17 @@ pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
.upsert_subscription(subscription) .upsert_subscription(subscription)
.await?; .await?;
// let location = req // TODO: make nicer
// .resource_map() let location = format!("/push_subscription/{sub_id}");
// .url_for(&req, "subscription", &[sub_id])
// .unwrap();
//
let location = "asd";
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
HeaderMap::from_iter([(header::LOCATION, location)]), HeaderMap::from_iter([
(header::LOCATION, HeaderValue::from_str(&location).unwrap()),
(
header::EXPIRES,
HeaderValue::from_str(&expires.to_rfc2822()).unwrap(),
),
]),
) )
.into_response()); .into_response())
Ok(HttpResponse::Created()
.append_header((header::LOCATION, location.to_string()))
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
} }

View File

@@ -9,7 +9,7 @@ use rustical_dav::extensions::{
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName}; use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension; use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime; use rustical_ical::CalDateTime;
use rustical_store::Calendar; use rustical_store::Calendar;
use rustical_store::auth::User; use rustical_store::auth::User;
@@ -58,7 +58,7 @@ pub enum CalendarProp {
pub enum CalendarPropWrapper { pub enum CalendarPropWrapper {
Calendar(CalendarProp), Calendar(CalendarProp),
SyncToken(SyncTokenExtensionProp), SyncToken(SyncTokenExtensionProp),
// DavPush(DavPushExtensionProp), DavPush(DavPushExtensionProp),
Common(CommonPropertiesProp), Common(CommonPropertiesProp),
} }
@@ -97,7 +97,9 @@ impl Resource for CalendarResource {
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
const IS_COLLECTION: bool = true; fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
if self.cal.subscription_url.is_none() { if self.cal.subscription_url.is_none() {
@@ -166,9 +168,9 @@ impl Resource for CalendarResource {
CalendarPropWrapperName::SyncToken(prop) => { CalendarPropWrapperName::SyncToken(prop) => {
CalendarPropWrapper::SyncToken(SyncTokenExtension::get_prop(self, prop)?) CalendarPropWrapper::SyncToken(SyncTokenExtension::get_prop(self, prop)?)
} }
// CalendarPropWrapperName::DavPush(prop) => { CalendarPropWrapperName::DavPush(prop) => {
// CalendarPropWrapper::DavPush(DavPushExtension::get_prop(self, prop)?) CalendarPropWrapper::DavPush(DavPushExtension::get_prop(self, prop)?)
// } }
CalendarPropWrapperName::Common(prop) => CalendarPropWrapper::Common( CalendarPropWrapperName::Common(prop) => CalendarPropWrapper::Common(
CommonPropertiesExtension::get_prop(self, puri, user, prop)?, CommonPropertiesExtension::get_prop(self, puri, user, prop)?,
), ),
@@ -226,7 +228,7 @@ impl Resource for CalendarResource {
CalendarProp::MaxDateTime(_) => Err(rustical_dav::Error::PropReadOnly), CalendarProp::MaxDateTime(_) => Err(rustical_dav::Error::PropReadOnly),
}, },
CalendarPropWrapper::SyncToken(prop) => SyncTokenExtension::set_prop(self, prop), CalendarPropWrapper::SyncToken(prop) => SyncTokenExtension::set_prop(self, prop),
// CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop), CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop),
CalendarPropWrapper::Common(prop) => CommonPropertiesExtension::set_prop(self, prop), CalendarPropWrapper::Common(prop) => CommonPropertiesExtension::set_prop(self, prop),
} }
} }
@@ -270,7 +272,7 @@ impl Resource for CalendarResource {
CalendarPropName::MaxDateTime => Err(rustical_dav::Error::PropReadOnly), CalendarPropName::MaxDateTime => Err(rustical_dav::Error::PropReadOnly),
}, },
CalendarPropWrapperName::SyncToken(prop) => SyncTokenExtension::remove_prop(self, prop), CalendarPropWrapperName::SyncToken(prop) => SyncTokenExtension::remove_prop(self, prop),
// CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop), CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop),
CalendarPropWrapperName::Common(prop) => { CalendarPropWrapperName::Common(prop) => {
CommonPropertiesExtension::remove_prop(self, prop) CommonPropertiesExtension::remove_prop(self, prop)
} }

View File

@@ -1,5 +1,6 @@
use crate::calendar::methods::get::route_get; use crate::calendar::methods::get::route_get;
use crate::calendar::methods::mkcalendar::route_mkcalendar; use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::post::route_post;
use crate::calendar::methods::report::route_report_calendar; use crate::calendar::methods::report::route_report_calendar;
use crate::calendar::resource::CalendarResource; use crate::calendar::resource::CalendarResource;
use crate::calendar_object::CalendarObjectResourceService; use crate::calendar_object::CalendarObjectResourceService;
@@ -50,7 +51,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type Principal = User; type Principal = User;
type PrincipalUri = CalDavPrincipalUri; type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy"; const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy, webdav-push";
async fn get_resource( async fn get_resource(
&self, &self,
@@ -126,6 +127,13 @@ impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceSer
}) })
} }
fn post() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_post::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{ {
Some(|state, req| { Some(|state, req| {

View File

@@ -27,7 +27,9 @@ impl Resource for CalendarObjectResource {
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
const IS_COLLECTION: bool = false; fn is_collection(&self) -> bool {
false
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[]) Resourcetype(&[])

View File

@@ -14,7 +14,6 @@ pub mod calendar;
pub mod calendar_object; pub mod calendar_object;
pub mod error; pub mod error;
pub mod principal; pub mod principal;
// mod subscription;
pub use error::Error; pub use error::Error;

View File

@@ -29,7 +29,9 @@ impl Resource for PrincipalResource {
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
const IS_COLLECTION: bool = true; fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ Resourcetype(&[

View File

@@ -1,33 +0,0 @@
use std::sync::Arc;
use actix_web::{
HttpResponse,
web::{self, Data, Path},
};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::SubscriptionStore;
use rustical_xml::{XmlRootTag, XmlSerialize};
use crate::calendar::resource::CalendarProp;
async fn handle_delete<S: SubscriptionStore>(
store: Data<S>,
path: Path<String>,
) -> Result<HttpResponse, rustical_store::Error> {
let id = path.into_inner();
store.delete_subscription(&id).await?;
Ok(HttpResponse::NoContent().body("Unregistered"))
}
pub fn subscription_resource<S: SubscriptionStore>(sub_store: Arc<S>) -> actix_web::Resource {
web::resource("/subscription/{id}")
.app_data(Data::from(sub_store))
.name("subscription")
.delete(handle_delete::<S>)
}
#[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
pub struct PushMessage {
propstat: PropstatElement<CalendarProp>,
}

View File

@@ -32,7 +32,9 @@ impl Resource for AddressObjectResource {
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
const IS_COLLECTION: bool = false; fn is_collection(&self) -> bool {
false
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[]) Resourcetype(&[])

View File

@@ -1,5 +1,5 @@
pub mod mkcol;
// pub mod post;
pub mod get; pub mod get;
pub mod mkcol;
pub mod post;
pub mod put; pub mod put;
pub mod report; pub mod report;

View File

@@ -1,33 +1,40 @@
use crate::Error; use crate::Error;
use crate::addressbook::resource::AddressbookResourceService; use crate::addressbook::AddressbookResourceService;
use actix_web::http::header; use crate::addressbook::resource::AddressbookResource;
use actix_web::web::{Data, Path}; use axum::extract::{Path, State};
use actix_web::{HttpRequest, HttpResponse}; use axum::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister; use rustical_dav_push::register::PushRegister;
use rustical_store::auth::User; use rustical_store::auth::User;
use rustical_store::{AddressbookStore, Subscription, SubscriptionStore}; use rustical_store::{AddressbookStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument; use rustical_xml::XmlDocument;
use tracing::instrument; use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(parent = root_span.id(), skip(resource_service, root_span, req))] #[instrument(skip(resource_service))]
pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>( pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
path: Path<(String, String)>, Path((principal, addr_id)): Path<(String, String)>,
body: String,
user: User, user: User,
resource_service: Data<AddressbookResourceService<A, S>>, State(resource_service): State<AddressbookResourceService<AS, S>>,
root_span: RootSpan, body: String,
req: HttpRequest, ) -> Result<Response, Error> {
) -> Result<HttpResponse, Error> {
let (principal, addressbook_id) = path.into_inner();
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
let addressbook = resource_service let addressbook = resource_service
.addr_store .addr_store
.get_addressbook(&principal, &addressbook_id, false) .get_addressbook(&principal, &addr_id, false)
.await?; .await?;
let addressbook_resource = AddressbookResource(addressbook);
if !addressbook_resource
.get_user_privileges(&user)?
.has(&UserPrivilege::Read)
{
return Err(Error::Unauthorized);
}
let request = PushRegister::parse_str(&body)?; let request = PushRegister::parse_str(&body)?;
let sub_id = uuid::Uuid::new_v4().to_string(); let sub_id = uuid::Uuid::new_v4().to_string();
@@ -44,7 +51,7 @@ pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
.web_push_subscription .web_push_subscription
.push_resource .push_resource
.to_owned(), .to_owned(),
topic: addressbook.push_topic, topic: addressbook_resource.0.push_topic,
expiration: expires.naive_local(), expiration: expires.naive_local(),
public_key: request public_key: request
.subscription .subscription
@@ -63,13 +70,17 @@ pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
.upsert_subscription(subscription) .upsert_subscription(subscription)
.await?; .await?;
let location = req // TODO: make nicer
.resource_map() let location = format!("/push_subscription/{sub_id}");
.url_for(&req, "subscription", &[sub_id]) Ok((
.unwrap(); StatusCode::CREATED,
HeaderMap::from_iter([
Ok(HttpResponse::Created() (header::LOCATION, HeaderValue::from_str(&location).unwrap()),
.append_header((header::LOCATION, location.to_string())) (
.append_header((header::EXPIRES, expires.to_rfc2822())) header::EXPIRES,
.finish()) HeaderValue::from_str(&expires.to_rfc2822()).unwrap(),
),
]),
)
.into_response())
} }

View File

@@ -38,7 +38,9 @@ impl Resource for AddressbookResource {
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
const IS_COLLECTION: bool = true; fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ Resourcetype(&[

View File

@@ -3,6 +3,7 @@ use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService; use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource; use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get; use crate::addressbook::methods::get::route_get;
use crate::addressbook::methods::post::route_post;
use crate::addressbook::methods::put::route_put; use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource; use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error}; use crate::{CardDavPrincipalUri, Error};
@@ -53,7 +54,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
type Principal = User; type Principal = User;
type PrincipalUri = CardDavPrincipalUri; type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook"; const DAV_HEADER: &str = "1, 3, access-control, addressbook, webdav-push";
async fn get_resource( async fn get_resource(
&self, &self,
@@ -130,6 +131,13 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
}) })
} }
fn post() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_post::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> { fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| { Some(|state, req| {
let mut service = Handler::with_state(route_put::<AS, S>, state); let mut service = Handler::with_state(route_put::<AS, S>, state);

View File

@@ -29,7 +29,9 @@ impl Resource for PrincipalResource {
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
const IS_COLLECTION: bool = true; fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ Resourcetype(&[

View File

@@ -26,3 +26,5 @@ tokio.workspace = true
http.workspace = true http.workspace = true
headers.workspace = true headers.workspace = true
strum.workspace = true strum.workspace = true
matchit.workspace = true
matchit-serde.workspace = true

View File

@@ -28,6 +28,9 @@ pub enum Error {
#[error("Precondition Failed")] #[error("Precondition Failed")]
PreconditionFailed, PreconditionFailed,
#[error("Forbidden")]
Forbidden,
} }
impl Error { impl Error {
@@ -49,6 +52,7 @@ impl Error {
Error::PropReadOnly => StatusCode::CONFLICT, Error::PropReadOnly => StatusCode::CONFLICT,
Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED, Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Forbidden => StatusCode::FORBIDDEN,
} }
} }
} }

View File

@@ -1,25 +1,54 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use tracing::instrument;
use crate::{ use crate::{
header::{Depth, Overwrite}, header::{Depth, Overwrite},
resource::ResourceService, resource::ResourceService,
}; };
use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(_path, _resource_service,))] #[instrument(skip(path, resource_service,))]
pub(crate) async fn axum_route_copy<R: ResourceService>( pub(crate) 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>,
depth: Option<Depth>, depth: Option<Depth>,
principal: R::Principal, principal: R::Principal,
overwrite: Overwrite, overwrite: Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
) -> Result<Response, R::Error> { ) -> Result<Response, R::Error> {
// TODO: Actually implement, but to be WebDAV-compliant we must at least support this route but let destination = header_map
// can return a 403 error .get("Destination")
let _depth = depth.unwrap_or(Depth::Infinity); .ok_or(crate::Error::Forbidden)?
Ok(StatusCode::FORBIDDEN.into_response()) .to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
let destination = destination_uri.path();
let mut router = matchit::Router::new();
router.insert(matched_path.as_str(), ()).unwrap();
if let Ok(matchit::Match { params, .. }) = router.at(destination) {
let params =
matchit_serde::Params::try_from(&params).map_err(|_| crate::Error::Forbidden)?;
let dest_path = R::PathComponents::deserialize(&ParamsDeserializer::new(params))
.map_err(|_| crate::Error::Forbidden)?;
if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite.is_true())
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
} }

View File

@@ -1,25 +1,54 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use tracing::instrument;
use crate::{ use crate::{
header::{Depth, Overwrite}, header::{Depth, Overwrite},
resource::ResourceService, resource::ResourceService,
}; };
use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(_path, _resource_service,))] #[instrument(skip(path, resource_service,))]
pub(crate) async fn axum_route_move<R: ResourceService>( pub(crate) 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>,
depth: Option<Depth>, depth: Option<Depth>,
principal: R::Principal, principal: R::Principal,
overwrite: Overwrite, overwrite: Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
) -> Result<Response, R::Error> { ) -> Result<Response, R::Error> {
// TODO: Actually implement, but to be WebDAV-compliant we must at least support this route but let destination = header_map
// can return a 403 error .get("Destination")
let _depth = depth.unwrap_or(Depth::Infinity); .ok_or(crate::Error::Forbidden)?
Ok(StatusCode::FORBIDDEN.into_response()) .to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
let destination = destination_uri.path();
let mut router = matchit::Router::new();
router.insert(matched_path.as_str(), ()).unwrap();
if let Ok(matchit::Match { params, .. }) = router.at(destination) {
let params =
matchit_serde::Params::try_from(&params).map_err(|_| crate::Error::Forbidden)?;
let dest_path = R::PathComponents::deserialize(&ParamsDeserializer::new(params))
.map_err(|_| crate::Error::Forbidden)?;
if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite.is_true())
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
} }

View File

@@ -18,7 +18,7 @@ mod methods;
mod principal_uri; mod principal_uri;
mod resource_service; mod resource_service;
pub use axum_methods::AxumMethods; pub use axum_methods::{AxumMethods, MethodFunction};
pub use axum_service::AxumService; pub use axum_service::AxumService;
pub use principal_uri::PrincipalUri; pub use principal_uri::PrincipalUri;
@@ -37,7 +37,7 @@ pub trait Resource: Clone + Send + 'static {
type Error: From<crate::Error>; type Error: From<crate::Error>;
type Principal: Principal; type Principal: Principal;
const IS_COLLECTION: bool; fn is_collection(&self) -> bool;
fn get_resourcetype(&self) -> Resourcetype; fn get_resourcetype(&self) -> Resourcetype;
@@ -111,7 +111,7 @@ pub trait Resource: Clone + Send + 'static {
) -> Result<ResponseElement<Self::Prop>, Self::Error> { ) -> Result<ResponseElement<Self::Prop>, Self::Error> {
// Collections have a trailing slash // Collections have a trailing slash
let mut path = path.to_string(); let mut path = path.to_string();
if Self::IS_COLLECTION && !path.ends_with('/') { if self.is_collection() && !path.ends_with('/') {
path.push('/'); path.push('/');
} }

View File

@@ -9,7 +9,13 @@ use serde::Deserialize;
#[async_trait] #[async_trait]
pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static { pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
type PathComponents: for<'de> Deserialize<'de> + Sized + Send + Sync + Clone + 'static; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String) type PathComponents: std::fmt::Debug
+ for<'de> Deserialize<'de>
+ Sized
+ Send
+ Sync
+ Clone
+ 'static; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String)
type MemberType: Resource<Error = Self::Error, Principal = Self::Principal> type MemberType: Resource<Error = Self::Error, Principal = Self::Principal>
+ super::ResourceName; + super::ResourceName;
type Resource: Resource<Error = Self::Error, Principal = Self::Principal>; type Resource: Resource<Error = Self::Error, Principal = Self::Principal>;
@@ -47,6 +53,28 @@ pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
Err(crate::Error::Unauthorized.into()) Err(crate::Error::Unauthorized.into())
} }
// Returns whether an existing resource was overwritten
async fn copy_resource(
&self,
_path: &Self::PathComponents,
_destination: &Self::PathComponents,
_user: &Self::Principal,
_overwrite: bool,
) -> Result<bool, Self::Error> {
Err(crate::Error::Forbidden.into())
}
// Returns whether an existing resource was overwritten
async fn move_resource(
&self,
_path: &Self::PathComponents,
_destination: &Self::PathComponents,
_user: &Self::Principal,
_overwrite: bool,
) -> Result<bool, Self::Error> {
Err(crate::Error::Forbidden.into())
}
fn axum_service(self) -> AxumService<Self> fn axum_service(self) -> AxumService<Self>
where where
Self: AxumMethods, Self: AxumMethods,

View File

@@ -24,7 +24,9 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
type Error = PR::Error; type Error = PR::Error;
type Principal = P; type Principal = P;
const IS_COLLECTION: bool = true; fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype { fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ResourcetypeInner( Resourcetype(&[ResourcetypeInner(

View File

@@ -23,3 +23,8 @@ tokio.workspace = true
rustical_dav.workspace = true rustical_dav.workspace = true
rustical_store.workspace = true rustical_store.workspace = true
http.workspace = true http.workspace = true
base64.workspace = true
p256.workspace = true
rand.workspace = true
ece.workspace = true
axum.workspace = true

View File

@@ -0,0 +1,23 @@
use axum::{
Router,
extract::{Path, State},
response::{IntoResponse, Response},
routing::delete,
};
use http::StatusCode;
use rustical_store::SubscriptionStore;
use std::sync::Arc;
async fn handle_delete<S: SubscriptionStore>(
State(store): State<Arc<S>>,
Path(id): Path<String>,
) -> Result<Response, rustical_store::Error> {
store.delete_subscription(&id).await?;
Ok((StatusCode::NO_CONTENT, "Unregistered").into_response())
}
pub fn subscription_service<S: SubscriptionStore>(sub_store: Arc<S>) -> Router {
Router::new()
.route("/push_subscription/{id}", delete(handle_delete::<S>))
.with_state(sub_store)
}

View File

@@ -1,14 +1,41 @@
mod extension; mod extension;
pub mod notifier;
mod prop; mod prop;
pub mod register; pub mod register;
use base64::Engine;
use derive_more::Constructor; use derive_more::Constructor;
pub use extension::*; pub use extension::*;
use http::{HeaderValue, Method, header};
pub use prop::*; pub use prop::*;
use rustical_store::{CollectionOperation, SubscriptionStore}; use reqwest::{Body, Url};
use std::sync::Arc; use rustical_store::{
CollectionOperation, CollectionOperationInfo, Subscription, SubscriptionStore,
};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tracing::error; use tracing::{error, warn};
mod endpoints;
pub use endpoints::subscription_service;
#[derive(XmlSerialize, Debug)]
pub struct ContentUpdate {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
sync_token: Option<String>,
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
topic: String,
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
content_update: Option<ContentUpdate>,
}
#[derive(Debug, Constructor)] #[derive(Debug, Constructor)]
pub struct DavPushController<S: SubscriptionStore> { pub struct DavPushController<S: SubscriptionStore> {
@@ -18,14 +45,176 @@ pub struct DavPushController<S: SubscriptionStore> {
impl<S: SubscriptionStore> DavPushController<S> { impl<S: SubscriptionStore> DavPushController<S> {
pub async fn notifier(&self, mut recv: Receiver<CollectionOperation>) { pub async fn notifier(&self, mut recv: Receiver<CollectionOperation>) {
while let Some(message) = recv.recv().await { loop {
let subscribers = match self.sub_store.get_subscriptions(&message.topic).await { // Make sure we don't flood the subscribers
Ok(subs) => subs, tokio::time::sleep(Duration::from_secs(10)).await;
Err(err) => { let mut messages = vec![];
error!("{err}"); recv.recv_many(&mut messages, 100).await;
continue;
// Right now we just have to show the latest content update by topic
// This might become more complicated in the future depending on what kind of updates
// we add
let mut latest_messages = HashMap::new();
for message in messages {
if matches!(message.data, CollectionOperationInfo::Content { .. }) {
latest_messages.insert(message.topic.to_string(), message);
} }
}; }
let messages = latest_messages.into_values();
for message in messages {
self.send_message(message).await;
}
}
}
async fn send_message(&self, message: CollectionOperation) {
let subscriptions = match self.sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
return;
}
};
if subscriptions.is_empty() {
return;
}
if matches!(message.data, CollectionOperationInfo::Delete) {
// Collection has been deleted, but we cannot handle that
return;
}
let content_update = if let CollectionOperationInfo::Content { sync_token } = message.data {
Some(ContentUpdate {
sync_token: Some(sync_token),
})
} else {
None
};
let push_message = PushMessage {
topic: message.topic,
content_update,
};
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = push_message.serialize_root(&mut writer) {
error!("Could not serialize push message: {}", err);
return;
}
let payload = String::from_utf8(output).unwrap();
for subsciption in subscriptions {
if let Some(allowed_push_servers) = &self.allowed_push_servers {
if let Ok(url) = Url::parse(&subsciption.push_resource) {
let origin = url.origin().unicode_serialization();
if !allowed_push_servers.contains(&origin) {
warn!(
"Deleting subscription {} on topic {} because the endpoint is not in the list of allowed push servers",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
}
} else {
warn!(
"Deleting subscription {} on topic {} because of invalid URL",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
};
}
if let Err(err) = self.send_payload(&payload, &subsciption).await {
error!("An error occured sending out a push notification: {err}");
if err.is_permament_error() {
warn!(
"Deleting subscription {} on topic {}",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
}
}
}
}
async fn try_delete_subscription(&self, sub_id: &str) {
if let Err(err) = self.sub_store.delete_subscription(sub_id).await {
error!("Error deleting subsciption: {err}");
}
}
async fn send_payload(
&self,
payload: &str,
subsciption: &Subscription,
) -> Result<(), NotifierError> {
if subsciption.public_key_type != "p256dh" {
return Err(NotifierError::InvalidPublicKeyType(
subsciption.public_key_type.to_string(),
));
}
let endpoint = subsciption.push_resource.parse().map_err(|_| {
NotifierError::InvalidEndpointUrl(subsciption.push_resource.to_string())
})?;
let ua_public = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&subsciption.public_key)
.map_err(|_| NotifierError::InvalidKeyEncoding)?;
let auth_secret = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&subsciption.auth_secret)
.map_err(|_| NotifierError::InvalidKeyEncoding)?;
let client = reqwest::ClientBuilder::new()
.build()
.map_err(NotifierError::from)?;
let payload = ece::encrypt(&ua_public, &auth_secret, payload.as_bytes())?;
let mut request = reqwest::Request::new(Method::POST, endpoint);
*request.body_mut() = Some(Body::from(payload));
let hdrs = request.headers_mut();
hdrs.insert(
header::CONTENT_ENCODING,
HeaderValue::from_static("aes128gcm"),
);
hdrs.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
client.execute(request).await?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
enum NotifierError {
#[error("Invalid public key type: {0}")]
InvalidPublicKeyType(String),
#[error("Invalid endpoint URL: {0}")]
InvalidEndpointUrl(String),
#[error("Invalid key encoding")]
InvalidKeyEncoding,
#[error(transparent)]
EceError(#[from] ece::Error),
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
}
impl NotifierError {
// Decide whether the error should cause the subscription to be removed
pub fn is_permament_error(&self) -> bool {
match self {
Self::InvalidPublicKeyType(_)
| Self::InvalidEndpointUrl(_)
| Self::InvalidKeyEncoding => true,
Self::EceError(err) => matches!(
err,
ece::Error::InvalidAuthSecret | ece::Error::InvalidKeyLength
),
Self::ReqwestError(_) => false,
} }
} }
} }

View File

@@ -1,147 +0,0 @@
use http::StatusCode;
use reqwest::{
Method, Request,
header::{self, HeaderName, HeaderValue},
};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::{CollectionOperation, CollectionOperationType, SubscriptionStore};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::{str::FromStr, sync::Arc};
use tokio::sync::mpsc::Receiver;
use tracing::{error, info, warn};
// use web_push::{SubscriptionInfo, WebPushMessage, WebPushMessageBuilder};
#[derive(XmlSerialize, Debug)]
struct PushMessageProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
topic: String,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
sync_token: Option<String>,
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
propstat: PropstatElement<PushMessageProp>,
}
// pub fn build_request(message: WebPushMessage) -> Request {
// // A little janky :)
// let url = reqwest::Url::from_str(&message.endpoint.to_string()).unwrap();
// let mut builder = Request::new(Method::POST, url);
//
// if let Some(topic) = message.topic {
// builder
// .headers_mut()
// .insert("Topic", HeaderValue::from_str(topic.as_str()).unwrap());
// }
//
// if let Some(payload) = message.payload {
// builder.headers_mut().insert(
// header::CONTENT_ENCODING,
// HeaderValue::from_static(payload.content_encoding.to_str()),
// );
// builder.headers_mut().insert(
// header::CONTENT_TYPE,
// HeaderValue::from_static("application/octet-stream"),
// );
//
// for (k, v) in payload.crypto_headers.into_iter() {
// let v: &str = v.as_ref();
// builder.headers_mut().insert(
// HeaderName::from_static(k),
// HeaderValue::from_str(&v).unwrap(),
// );
// }
//
// *builder.body_mut() = Some(reqwest::Body::from(payload.content));
// }
// builder
// }
pub async fn push_notifier(
allowed_push_servers: Option<Vec<String>>,
mut recv: Receiver<CollectionOperation>,
sub_store: Arc<impl SubscriptionStore>,
) {
let client = reqwest::Client::new();
while let Some(message) = recv.recv().await {
let subscribers = match sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
continue;
}
};
let status = match message.r#type {
CollectionOperationType::Object => StatusCode::OK,
CollectionOperationType::Delete => StatusCode::NOT_FOUND,
};
let push_message = PushMessage {
propstat: PropstatElement {
prop: PushMessageProp {
topic: message.topic,
sync_token: message.sync_token,
},
status,
},
};
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = push_message.serialize_root(&mut writer) {
error!("Could not serialize push message: {}", err);
continue;
}
let payload = String::from_utf8(output).unwrap();
// for subscriber in subscribers {
// let push_resource = subscriber.push_resource;
//
// let sub_info = SubscriptionInfo {
// endpoint: push_resource.to_owned(),
// keys: web_push::SubscriptionKeys {
// p256dh: subscriber.public_key,
// auth: subscriber.auth_secret,
// },
// };
// let mut builder = WebPushMessageBuilder::new(&sub_info);
// builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload.as_bytes());
// let push_message = builder.build().unwrap();
// let request = build_request(push_message);
//
// let allowed = if let Some(allowed_push_servers) = &allowed_push_servers {
// if let Ok(resource_url) = reqwest::Url::parse(&push_resource) {
// let origin = resource_url.origin().ascii_serialization();
// allowed_push_servers
// .iter()
// .any(|allowed_push_server| allowed_push_server == &origin)
// } else {
// warn!("Invalid push url: {push_resource}");
// false
// }
// } else {
// true
// };
//
// if allowed {
// info!("Sending a push message to {}: {}", push_resource, payload);
// if let Err(err) = client.execute(request).await {
// error!("{err}");
// }
// } else {
// warn!(
// "Not sending a push notification to {} since it's not allowed in dav_push::allowed_push_servers",
// push_resource
// );
// }
// }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,22 +22,15 @@ pub use addressbook::Addressbook;
pub use calendar::Calendar; pub use calendar::Calendar;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum CollectionOperationType { pub enum CollectionOperationInfo {
// Sync-Token increased // Sync-Token increased
Object, Content { sync_token: String },
// Collection deleted
Delete, Delete,
} }
#[derive(Debug, Clone)]
pub enum CollectionOperationDomain {
Calendar,
Addressbook,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CollectionOperation { pub struct CollectionOperation {
pub r#type: CollectionOperationType,
pub domain: CollectionOperationDomain,
pub topic: String, pub topic: String,
pub sync_token: Option<String>, pub data: CollectionOperationInfo,
} }

View File

@@ -3,8 +3,8 @@ use async_trait::async_trait;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{ use rustical_store::{
Addressbook, AddressbookStore, CollectionOperation, CollectionOperationDomain, Addressbook, AddressbookStore, CollectionOperation, CollectionOperationInfo, Error,
CollectionOperationType, Error, synctoken::format_synctoken, synctoken::format_synctoken,
}; };
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
@@ -413,10 +413,8 @@ impl AddressbookStore for SqliteAddressbookStore {
if let Some(addressbook) = addressbook { if let Some(addressbook) = addressbook {
if let Err(err) = self.sender.try_send(CollectionOperation { if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Delete, data: CollectionOperationInfo::Delete,
domain: CollectionOperationDomain::Addressbook,
topic: addressbook.push_topic, topic: addressbook.push_topic,
sync_token: None,
}) { }) {
error!("Push notification about deleted addressbook failed: {err}"); error!("Push notification about deleted addressbook failed: {err}");
}; };
@@ -485,7 +483,7 @@ impl AddressbookStore for SqliteAddressbookStore {
) )
.await?; .await?;
let synctoken = log_object_operation( let sync_token = log_object_operation(
&mut tx, &mut tx,
&principal, &principal,
&addressbook_id, &addressbook_id,
@@ -498,13 +496,11 @@ impl AddressbookStore for SqliteAddressbookStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object, data: CollectionOperationInfo::Content { sync_token },
domain: CollectionOperationDomain::Addressbook,
topic: self topic: self
.get_addressbook(&principal, &addressbook_id, false) .get_addressbook(&principal, &addressbook_id, false)
.await? .await?
.push_topic, .push_topic,
sync_token: Some(synctoken),
}) { }) {
error!("Push notification about deleted addressbook failed: {err}"); error!("Push notification about deleted addressbook failed: {err}");
}; };
@@ -524,7 +520,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_delete_object(&mut *tx, principal, addressbook_id, object_id, use_trashbin).await?; Self::_delete_object(&mut *tx, principal, addressbook_id, object_id, use_trashbin).await?;
let synctoken = log_object_operation( let sync_token = log_object_operation(
&mut tx, &mut tx,
principal, principal,
addressbook_id, addressbook_id,
@@ -536,16 +532,15 @@ impl AddressbookStore for SqliteAddressbookStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
// TODO: Watch for errors here? if let Err(err) = self.sender.try_send(CollectionOperation {
let _ = self.sender.try_send(CollectionOperation { data: CollectionOperationInfo::Content { sync_token },
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
topic: self topic: self
.get_addressbook(principal, addressbook_id, false) .get_addressbook(principal, addressbook_id, false)
.await? .await?
.push_topic, .push_topic,
sync_token: Some(synctoken), }) {
}); error!("Push notification about deleted addressbook failed: {err}");
};
Ok(()) Ok(())
} }
@@ -560,7 +555,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_restore_object(&mut *tx, principal, addressbook_id, object_id).await?; Self::_restore_object(&mut *tx, principal, addressbook_id, object_id).await?;
let synctoken = log_object_operation( let sync_token = log_object_operation(
&mut tx, &mut tx,
principal, principal,
addressbook_id, addressbook_id,
@@ -571,16 +566,15 @@ impl AddressbookStore for SqliteAddressbookStore {
.map_err(crate::Error::from)?; .map_err(crate::Error::from)?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
// TODO: Watch for errors here? if let Err(err) = self.sender.try_send(CollectionOperation {
let _ = self.sender.try_send(CollectionOperation { data: CollectionOperationInfo::Content { sync_token },
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
topic: self topic: self
.get_addressbook(principal, addressbook_id, false) .get_addressbook(principal, addressbook_id, false)
.await? .await?
.push_topic, .push_topic,
sync_token: Some(synctoken), }) {
}); error!("Push notification about deleted addressbook failed: {err}");
};
Ok(()) Ok(())
} }

View File

@@ -6,7 +6,7 @@ use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery; use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken; use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarStore, Error}; use rustical_store::{Calendar, CalendarStore, Error};
use rustical_store::{CollectionOperation, CollectionOperationType}; 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;
@@ -518,10 +518,8 @@ impl CalendarStore for SqliteCalendarStore {
if let Some(cal) = cal { if let Some(cal) = cal {
if let Err(err) = self.sender.try_send(CollectionOperation { if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Delete, data: CollectionOperationInfo::Delete,
domain: rustical_store::CollectionOperationDomain::Calendar,
topic: cal.push_topic, topic: cal.push_topic,
sync_token: None,
}) { }) {
error!("Push notification about deleted calendar failed: {err}"); error!("Push notification about deleted calendar failed: {err}");
}; };
@@ -585,7 +583,7 @@ impl CalendarStore for SqliteCalendarStore {
) )
.await?; .await?;
let synctoken = log_object_operation( let sync_token = log_object_operation(
&mut tx, &mut tx,
&principal, &principal,
&cal_id, &cal_id,
@@ -597,10 +595,8 @@ impl CalendarStore for SqliteCalendarStore {
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object, data: CollectionOperationInfo::Content { sync_token },
domain: rustical_store::CollectionOperationDomain::Calendar,
topic: self.get_calendar(&principal, &cal_id).await?.push_topic, topic: self.get_calendar(&principal, &cal_id).await?.push_topic,
sync_token: Some(synctoken),
}) { }) {
error!("Push notification about deleted calendar failed: {err}"); error!("Push notification about deleted calendar failed: {err}");
}; };
@@ -619,15 +615,13 @@ impl CalendarStore for SqliteCalendarStore {
Self::_delete_object(&mut *tx, principal, cal_id, id, use_trashbin).await?; Self::_delete_object(&mut *tx, principal, cal_id, id, use_trashbin).await?;
let synctoken = let sync_token =
log_object_operation(&mut tx, principal, cal_id, id, ChangeOperation::Delete).await?; log_object_operation(&mut tx, principal, cal_id, id, ChangeOperation::Delete).await?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object, data: CollectionOperationInfo::Content { sync_token },
domain: rustical_store::CollectionOperationDomain::Calendar,
topic: self.get_calendar(principal, cal_id).await?.push_topic, topic: self.get_calendar(principal, cal_id).await?.push_topic,
sync_token: Some(synctoken),
}) { }) {
error!("Push notification about deleted calendar failed: {err}"); error!("Push notification about deleted calendar failed: {err}");
}; };
@@ -645,16 +639,14 @@ impl CalendarStore for SqliteCalendarStore {
Self::_restore_object(&mut *tx, principal, cal_id, object_id).await?; Self::_restore_object(&mut *tx, principal, cal_id, object_id).await?;
let synctoken = let sync_token =
log_object_operation(&mut tx, principal, cal_id, object_id, ChangeOperation::Add) log_object_operation(&mut tx, principal, cal_id, object_id, ChangeOperation::Add)
.await?; .await?;
tx.commit().await.map_err(crate::Error::from)?; tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation { if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object, data: CollectionOperationInfo::Content { sync_token },
domain: rustical_store::CollectionOperationDomain::Calendar,
topic: self.get_calendar(principal, cal_id).await?.push_topic, topic: self.get_calendar(principal, cal_id).await?.push_topic,
sync_token: Some(synctoken),
}) { }) {
error!("Push notification about deleted calendar failed: {err}"); error!("Push notification about deleted calendar failed: {err}");
}; };

View File

@@ -11,6 +11,7 @@ If you still want to play around with it in its current state, absolutely feel f
- easy to backup, everything saved in one SQLite database - easy to backup, everything saved in one SQLite database
- also export feature in the frontend - also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary) - lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks) - adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable - deleted calendars are recoverable

View File

@@ -34,6 +34,7 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
frontend_config: FrontendConfig, frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>, oidc_config: Option<OidcConfig>,
nextcloud_login_config: NextcloudLoginConfig, nextcloud_login_config: NextcloudLoginConfig,
dav_push_enabled: bool,
) -> Router<()> { ) -> Router<()> {
let combined_cal_store = Arc::new(CombinedCalendarStore::new( let combined_cal_store = Arc::new(CombinedCalendarStore::new(
cal_store.clone(), cal_store.clone(),
@@ -90,6 +91,13 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
nextcloud_login_router(auth_provider.clone()), nextcloud_login_router(auth_provider.clone()),
); );
} }
if dav_push_enabled {
router = router.merge(rustical_dav_push::subscription_service(
subscription_store.clone(),
));
}
router router
.layer( .layer(
SessionManagerLayer::new(session_store) SessionManagerLayer::new(session_store)
@@ -126,12 +134,18 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
if response.status().is_server_error() { if response.status().is_server_error() {
tracing::error!("server error"); tracing::error!("server error");
} else if response.status().is_client_error() { } else if response.status().is_client_error() {
if response.status() == StatusCode::UNAUTHORIZED { match response.status() {
// The iOS client always tries an unauthenticated request first so StatusCode::UNAUTHORIZED => {
// logging 401's as errors would clog up our logs // The iOS client always tries an unauthenticated request first so
tracing::debug!("unauthorized"); // logging 401's as errors would clog up our logs
} else { tracing::debug!("unauthorized");
tracing::error!("client error"); }
StatusCode::NOT_FOUND => {
tracing::warn!("client error");
}
_ => {
tracing::error!("client error");
}
} }
}; };
}) })

View File

@@ -37,9 +37,14 @@ pub struct TracingConfig {
pub opentelemetry: bool, pub opentelemetry: bool,
} }
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
pub struct DavPushConfig { pub struct DavPushConfig {
#[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
#[serde(default)] #[serde(default)]
// Allowed Push servers, accepts any by default // Allowed Push servers, accepts any by default

View File

@@ -117,6 +117,7 @@ async fn main() -> Result<()> {
config.frontend.clone(), config.frontend.clone(),
config.oidc.clone(), config.oidc.clone(),
config.nextcloud_login.clone(), config.nextcloud_login.clone(),
config.dav_push.enabled,
); );
let app = ServiceExt::<Request>::into_make_service( let app = ServiceExt::<Request>::into_make_service(
NormalizePathLayer::trim_trailing_slash().layer(app), NormalizePathLayer::trim_trailing_slash().layer(app),