carddav: Implement DAV Push

This commit is contained in:
Lennart
2025-01-15 17:14:33 +01:00
parent 618ed3b327
commit 751c2d1ce7
15 changed files with 276 additions and 113 deletions

1
Cargo.lock generated
View File

@@ -2884,6 +2884,7 @@ name = "rustical_store_sqlite"
version = "0.1.0"
dependencies = [
"async-trait",
"derive_more 1.0.0",
"rustical_store",
"serde",
"sqlx",

View File

@@ -2,35 +2,13 @@ use crate::Error;
use actix_web::http::header;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse};
use rustical_dav::push::PushRegister;
use rustical_store::auth::User;
use rustical_store::{CalendarStore, Subscription, SubscriptionStore};
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
use rustical_xml::XmlDocument;
use tracing::instrument;
use tracing_actix_web::RootSpan;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
struct WebPushSubscription {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
push_resource: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
struct SubscriptionElement {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
pub web_push_subscription: WebPushSubscription,
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = b"push-register")]
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
struct PushRegister {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
subscription: SubscriptionElement,
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
expires: Option<String>,
}
#[instrument(parent = root_span.id(), skip(store, subscription_store, root_span, req))]
pub async fn route_post<C: CalendarStore + ?Sized, S: SubscriptionStore + ?Sized>(
path: Path<(String, String)>,
@@ -79,37 +57,3 @@ pub async fn route_post<C: CalendarStore + ?Sized, S: SubscriptionStore + ?Sized
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xml_push_register() {
let push_register = PushRegister::parse_str(
r#"
<?xml version="1.0" encoding="utf-8" ?>
<push-register xmlns="https://bitfire.at/webdav-push">
<subscription>
<web-push-subscription>
<push-resource>https://up.example.net/yohd4yai5Phiz1wi</push-resource>
</web-push-subscription>
</subscription>
<expires>Wed, 20 Dec 2023 10:03:31 GMT</expires>
</push-register>
"#,
)
.unwrap();
assert_eq!(
push_register,
PushRegister {
subscription: SubscriptionElement {
web_push_subscription: WebPushSubscription {
push_resource: "https://up.example.net/yohd4yai5Phiz1wi".to_owned()
}
},
expires: Some("Wed, 20 Dec 2023 10:03:31 GMT".to_owned())
}
)
}
}

View File

@@ -80,24 +80,3 @@ impl Default for SupportedReportSet {
}
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub enum Transport {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
WebPush,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct Transports {
#[xml(flatten, ty = "untagged")]
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
transports: Vec<Transport>,
}
impl Default for Transports {
fn default() -> Self {
Self {
transports: vec![Transport::WebPush],
}
}
}

View File

@@ -1,9 +1,7 @@
use super::methods::mkcalendar::route_mkcalendar;
use super::methods::post::route_post;
use super::methods::report::route_report_calendar;
use super::prop::{
SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet, Transports,
};
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet};
use crate::calendar_object::resource::CalendarObjectResource;
use crate::principal::PrincipalResource;
use crate::Error;
@@ -13,6 +11,7 @@ use actix_web::web;
use async_trait::async_trait;
use derive_more::derive::{From, Into};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::push::Transports;
use rustical_dav::resource::{Resource, ResourceService};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::User;

View File

@@ -1,2 +1,3 @@
pub mod mkcol;
pub mod post;
pub mod report;

View File

@@ -0,0 +1,59 @@
use crate::Error;
use actix_web::http::header;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse};
use rustical_dav::push::PushRegister;
use rustical_store::auth::User;
use rustical_store::{AddressbookStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument;
use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(parent = root_span.id(), skip(store, subscription_store, root_span, req))]
pub async fn route_post<A: AddressbookStore + ?Sized, S: SubscriptionStore + ?Sized>(
path: Path<(String, String)>,
body: String,
user: User,
store: Data<A>,
subscription_store: Data<S>,
root_span: RootSpan,
req: HttpRequest,
) -> Result<HttpResponse, Error> {
let (principal, addressbook_id) = path.into_inner();
if principal != user.id {
return Err(Error::Unauthorized);
}
let addressbook = store.get_addressbook(&principal, &addressbook_id).await?;
let request = PushRegister::parse_str(&body)?;
let sub_id = uuid::Uuid::new_v4().to_string();
let expires = if let Some(expires) = request.expires {
chrono::DateTime::parse_from_rfc2822(&expires)
.map_err(|err| crate::Error::Other(err.into()))?
} else {
chrono::Utc::now().fixed_offset() + chrono::Duration::weeks(1)
};
let subscription = Subscription {
id: sub_id.to_owned(),
push_resource: request
.subscription
.web_push_subscription
.push_resource
.to_owned(),
topic: addressbook.push_topic,
expiration: expires.naive_local(),
};
subscription_store.upsert_subscription(subscription).await?;
let location = req
.resource_map()
.url_for(&req, "subscription", &[sub_id])
.unwrap();
Ok(HttpResponse::Created()
.append_header((header::LOCATION, location.to_string()))
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
}

View File

@@ -1,4 +1,5 @@
use super::methods::mkcol::route_mkcol;
use super::methods::post::route_post;
use super::methods::report::route_report_addressbook;
use super::prop::{SupportedAddressData, SupportedReportSet};
use crate::address_object::resource::AddressObjectResource;
@@ -10,22 +11,29 @@ use actix_web::web;
use async_trait::async_trait;
use derive_more::derive::{From, Into};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::push::Transports;
use rustical_dav::resource::{Resource, ResourceService};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_store::auth::User;
use rustical_store::{Addressbook, AddressbookStore};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore};
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::marker::PhantomData;
use std::str::FromStr;
use std::sync::Arc;
use strum::{EnumDiscriminants, EnumString, IntoStaticStr, VariantNames};
pub struct AddressbookResourceService<AS: AddressbookStore + ?Sized> {
pub struct AddressbookResourceService<AS: AddressbookStore + ?Sized, S: SubscriptionStore + ?Sized>
{
addr_store: Arc<AS>,
__phantom_sub: PhantomData<S>,
}
impl<A: AddressbookStore + ?Sized> AddressbookResourceService<A> {
impl<A: AddressbookStore + ?Sized, S: SubscriptionStore + ?Sized> AddressbookResourceService<A, S> {
pub fn new(addr_store: Arc<A>) -> Self {
Self { addr_store }
Self {
addr_store,
__phantom_sub: PhantomData,
}
}
}
@@ -42,6 +50,13 @@ pub enum AddressbookProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
Getcontenttype(&'static str),
// WebDav Push
#[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
Transports(Transports),
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
Topic(String),
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookDescription(Option<String>),
@@ -90,6 +105,8 @@ impl Resource for AddressbookResource {
AddressbookPropName::Getcontenttype => {
AddressbookProp::Getcontenttype("text/vcard;charset=utf-8")
}
AddressbookPropName::Transports => AddressbookProp::Transports(Default::default()),
AddressbookPropName::Topic => AddressbookProp::Topic(self.0.push_topic.to_owned()),
AddressbookPropName::MaxResourceSize => AddressbookProp::MaxResourceSize(10000000),
AddressbookPropName::SupportedReportSet => {
AddressbookProp::SupportedReportSet(SupportedReportSet::default())
@@ -116,6 +133,8 @@ impl Resource for AddressbookResource {
Ok(())
}
AddressbookProp::Getcontenttype(_) => Err(rustical_dav::Error::PropReadOnly),
AddressbookProp::Transports(_) => Err(rustical_dav::Error::PropReadOnly),
AddressbookProp::Topic(_) => Err(rustical_dav::Error::PropReadOnly),
AddressbookProp::MaxResourceSize(_) => Err(rustical_dav::Error::PropReadOnly),
AddressbookProp::SupportedReportSet(_) => Err(rustical_dav::Error::PropReadOnly),
AddressbookProp::SupportedAddressData(_) => Err(rustical_dav::Error::PropReadOnly),
@@ -135,6 +154,8 @@ impl Resource for AddressbookResource {
Ok(())
}
AddressbookPropName::Getcontenttype => Err(rustical_dav::Error::PropReadOnly),
AddressbookPropName::Transports => Err(rustical_dav::Error::PropReadOnly),
AddressbookPropName::Topic => Err(rustical_dav::Error::PropReadOnly),
AddressbookPropName::MaxResourceSize => Err(rustical_dav::Error::PropReadOnly),
AddressbookPropName::SupportedReportSet => Err(rustical_dav::Error::PropReadOnly),
AddressbookPropName::SupportedAddressData => Err(rustical_dav::Error::PropReadOnly),
@@ -153,7 +174,9 @@ impl Resource for AddressbookResource {
}
#[async_trait(?Send)]
impl<AS: AddressbookStore + ?Sized> ResourceService for AddressbookResourceService<AS> {
impl<AS: AddressbookStore + ?Sized, S: SubscriptionStore + ?Sized> ResourceService
for AddressbookResourceService<AS, S>
{
type MemberType = AddressObjectResource;
type PathComponents = (String, String); // principal, addressbook_id
type Resource = AddressbookResource;
@@ -220,5 +243,6 @@ impl<AS: AddressbookStore + ?Sized> ResourceService for AddressbookResourceServi
let report_method = web::method(Method::from_str("REPORT").unwrap());
res.route(mkcol_method.to(route_mkcol::<AS>))
.route(report_method.to(route_report_addressbook::<AS>))
.post(route_post::<AS, S>)
}
}

View File

@@ -16,7 +16,7 @@ use rustical_dav::resource::{NamedRoute, ResourceService};
use rustical_dav::resources::RootResourceService;
use rustical_store::{
auth::{AuthenticationMiddleware, AuthenticationProvider},
AddressbookStore,
AddressbookStore, SubscriptionStore,
};
use std::sync::Arc;
@@ -29,10 +29,15 @@ pub fn configure_well_known(cfg: &mut web::ServiceConfig, carddav_root: String)
cfg.service(web::redirect("/carddav", carddav_root).permanent());
}
pub fn configure_dav<AP: AuthenticationProvider, A: AddressbookStore + ?Sized>(
pub fn configure_dav<
AP: AuthenticationProvider,
A: AddressbookStore + ?Sized,
S: SubscriptionStore + ?Sized,
>(
cfg: &mut web::ServiceConfig,
auth_provider: Arc<AP>,
store: Arc<A>,
subscription_store: Arc<S>,
) {
cfg.service(
web::scope("")
@@ -58,6 +63,7 @@ pub fn configure_dav<AP: AuthenticationProvider, A: AddressbookStore + ?Sized>(
}),
)
.app_data(Data::from(store.clone()))
.app_data(Data::from(subscription_store))
.service(RootResourceService::<PrincipalResource>::default().actix_resource())
.service(
web::scope("/user").service(
@@ -70,7 +76,7 @@ pub fn configure_dav<AP: AuthenticationProvider, A: AddressbookStore + ?Sized>(
.service(
web::scope("/{addressbook}")
.service(
AddressbookResourceService::<A>::new(store.clone())
AddressbookResourceService::<A, S>::new(store.clone())
.actix_resource(),
)
.service(

View File

@@ -2,6 +2,7 @@ pub mod depth_header;
pub mod error;
pub mod namespace;
pub mod privileges;
pub mod push;
pub mod resource;
pub mod resources;
pub mod xml;

View File

@@ -0,0 +1,80 @@
use rustical_xml::{XmlDeserialize, XmlRootTag, XmlSerialize};
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub enum Transport {
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
WebPush,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct Transports {
#[xml(flatten, ty = "untagged")]
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
transports: Vec<Transport>,
}
impl Default for Transports {
fn default() -> Self {
Self {
transports: vec![Transport::WebPush],
}
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
pub struct WebPushSubscription {
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
pub push_resource: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
pub struct SubscriptionElement {
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
pub web_push_subscription: WebPushSubscription,
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = b"push-register")]
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
pub struct PushRegister {
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
pub subscription: SubscriptionElement,
#[xml(ns = "crate::namespace::NS_DAVPUSH")]
pub expires: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use rustical_xml::XmlDocument;
#[test]
fn test_xml_push_register() {
let push_register = PushRegister::parse_str(
r#"
<?xml version="1.0" encoding="utf-8" ?>
<push-register xmlns="https://bitfire.at/webdav-push">
<subscription>
<web-push-subscription>
<push-resource>https://up.example.net/yohd4yai5Phiz1wi</push-resource>
</web-push-subscription>
</subscription>
<expires>Wed, 20 Dec 2023 10:03:31 GMT</expires>
</push-register>
"#,
)
.unwrap();
assert_eq!(
push_register,
PushRegister {
subscription: SubscriptionElement {
web_push_subscription: WebPushSubscription {
push_resource: "https://up.example.net/yohd4yai5Phiz1wi".to_owned()
}
},
expires: Some("Wed, 20 Dec 2023 10:03:31 GMT".to_owned())
}
)
}
}

View File

@@ -14,3 +14,4 @@ serde = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
derive_more.workspace = true

View File

@@ -1,7 +1,12 @@
use super::{ChangeOperation, SqliteStore};
use super::ChangeOperation;
use async_trait::async_trait;
use rustical_store::{AddressObject, Addressbook, AddressbookStore};
use sqlx::{Sqlite, Transaction};
use derive_more::derive::Constructor;
use rustical_store::{
synctoken::format_synctoken, AddressObject, Addressbook, AddressbookStore, CollectionOperation,
CollectionOperationDomain, CollectionOperationType, Error,
};
use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender;
use tracing::instrument;
#[derive(Debug, Clone)]
@@ -25,16 +30,21 @@ async fn log_object_operation(
addressbook_id: &str,
object_id: &str,
operation: ChangeOperation,
) -> Result<(), sqlx::Error> {
sqlx::query!(
) -> Result<String, sqlx::Error> {
struct Synctoken {
synctoken: i64,
}
let Synctoken { synctoken } = sqlx::query_as!(
Synctoken,
r#"
UPDATE addressbooks
SET synctoken = synctoken + 1
WHERE (principal, id) = (?1, ?2)"#,
WHERE (principal, id) = (?1, ?2)
RETURNING synctoken"#,
principal,
addressbook_id
)
.execute(&mut **tx)
.fetch_one(&mut **tx)
.await?;
sqlx::query!(
@@ -50,11 +60,17 @@ async fn log_object_operation(
)
.execute(&mut **tx)
.await?;
Ok(())
Ok(format_synctoken(synctoken))
}
#[derive(Debug, Constructor)]
pub struct SqliteAddressbookStore {
db: SqlitePool,
sender: Sender<CollectionOperation>,
}
#[async_trait]
impl AddressbookStore for SqliteStore {
impl AddressbookStore for SqliteAddressbookStore {
#[instrument]
async fn get_addressbook(
&self,
@@ -165,6 +181,12 @@ impl AddressbookStore for SqliteStore {
addressbook_id: &str,
use_trashbin: bool,
) -> Result<(), rustical_store::Error> {
let addressbook = match self.get_addressbook(principal, addressbook_id).await {
Ok(addressbook) => Some(addressbook),
Err(Error::NotFound) => None,
Err(err) => return Err(err),
};
match use_trashbin {
true => {
sqlx::query!(
@@ -185,6 +207,17 @@ impl AddressbookStore for SqliteStore {
.map_err(crate::Error::from)?;
}
};
if let Some(addressbook) = addressbook {
// TODO: Watch for errors here?
let _ = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Delete,
domain: CollectionOperationDomain::Addressbook,
topic: addressbook.push_topic,
sync_token: None,
});
}
Ok(())
}
@@ -320,7 +353,7 @@ impl AddressbookStore for SqliteStore {
.await
.map_err(crate::Error::from)?;
log_object_operation(
let synctoken = log_object_operation(
&mut tx,
&principal,
&addressbook_id,
@@ -330,6 +363,17 @@ impl AddressbookStore for SqliteStore {
.await
.map_err(crate::Error::from)?;
// TODO: Watch for errors here?
let _ = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
topic: self
.get_addressbook(&principal, &addressbook_id)
.await?
.push_topic,
sync_token: Some(synctoken),
});
tx.commit().await.map_err(crate::Error::from)?;
Ok(())
}
@@ -366,7 +410,7 @@ impl AddressbookStore for SqliteStore {
.map_err(crate::Error::from)?;
}
};
log_object_operation(
let synctoken = log_object_operation(
&mut tx,
principal,
addressbook_id,
@@ -375,7 +419,19 @@ impl AddressbookStore for SqliteStore {
)
.await
.map_err(crate::Error::from)?;
tx.commit().await.map_err(crate::Error::from)?;
// TODO: Watch for errors here?
let _ = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
topic: self
.get_addressbook(principal, addressbook_id)
.await?
.push_topic,
sync_token: Some(synctoken),
});
Ok(())
}
@@ -397,7 +453,7 @@ impl AddressbookStore for SqliteStore {
.execute(&mut *tx)
.await.map_err(crate::Error::from)?;
log_object_operation(
let synctoken = log_object_operation(
&mut tx,
principal,
addressbook_id,
@@ -407,6 +463,18 @@ impl AddressbookStore for SqliteStore {
.await
.map_err(crate::Error::from)?;
tx.commit().await.map_err(crate::Error::from)?;
// TODO: Watch for errors here?
let _ = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
topic: self
.get_addressbook(principal, addressbook_id)
.await?
.push_topic,
sync_token: Some(synctoken),
});
Ok(())
}
}

View File

@@ -1,5 +1,6 @@
use super::ChangeOperation;
use async_trait::async_trait;
use derive_more::derive::Constructor;
use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarObject, CalendarStore, Error};
use rustical_store::{CollectionOperation, CollectionOperationType};
@@ -65,18 +66,12 @@ async fn log_object_operation(
Ok(format_synctoken(synctoken))
}
#[derive(Debug)]
#[derive(Debug, Constructor)]
pub struct SqliteCalendarStore {
db: SqlitePool,
sender: Sender<CollectionOperation>,
}
impl SqliteCalendarStore {
pub fn new(db: SqlitePool, sender: Sender<CollectionOperation>) -> Self {
Self { db, sender }
}
}
#[async_trait]
impl CalendarStore for SqliteCalendarStore {
#[instrument]

View File

@@ -37,11 +37,16 @@ pub fn make_app<
auth_provider.clone(),
cal_store.clone(),
addr_store.clone(),
subscription_store,
subscription_store.clone(),
)
}))
.service(web::scope("/carddav").configure(|cfg| {
rustical_carddav::configure_dav(cfg, auth_provider.clone(), addr_store.clone())
rustical_carddav::configure_dav(
cfg,
auth_provider.clone(),
addr_store.clone(),
subscription_store,
)
}))
.service(
web::scope("/.well-known")

View File

@@ -9,6 +9,7 @@ use config::{DataStoreConfig, SqliteDataStoreConfig};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::auth::StaticUserStore;
use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore};
use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore;
use rustical_store_sqlite::calendar_store::SqliteCalendarStore;
use rustical_store_sqlite::{create_db_pool, SqliteStore};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
@@ -56,7 +57,7 @@ async fn get_data_stores(
// Channel to watch for changes (for DAV Push)
let (send, recv) = tokio::sync::mpsc::channel(1000);
let addressbook_store = Arc::new(SqliteStore::new(db.clone()));
let addressbook_store = Arc::new(SqliteAddressbookStore::new(db.clone(), send.clone()));
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send));
let subscription_store = Arc::new(SqliteStore::new(db.clone()));
(addressbook_store, cal_store, subscription_store, recv)
@@ -107,7 +108,6 @@ async fn main() -> Result<()> {
tokio::spawn(async move {
let subscription_store = subscription_store_clone.clone();
while let Some(message) = update_recv.recv().await {
dbg!(&message);
if let Ok(subscribers) =
subscription_store.get_subscriptions(&message.topic).await
{