Dav Push: Logic to register subscriptions

This commit is contained in:
Lennart
2025-01-12 20:39:53 +01:00
parent 185eb8bddd
commit 5b418ec583
12 changed files with 261 additions and 15 deletions

View File

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

View File

@@ -0,0 +1,115 @@
use crate::Error;
use actix_web::http::header;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse};
use rustical_store::auth::User;
use rustical_store::{CalendarStore, Subscription, SubscriptionStore};
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
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)>,
body: String,
user: User,
store: Data<C>,
subscription_store: Data<S>,
root_span: RootSpan,
req: HttpRequest,
) -> Result<HttpResponse, Error> {
let (principal, cal_id) = path.into_inner();
if principal != user.id {
return Err(Error::Unauthorized);
}
let calendar = store.get_calendar(&principal, &cal_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: calendar.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())
}
#[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

@@ -1,4 +1,5 @@
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,
@@ -15,8 +16,9 @@ use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{Resource, ResourceService};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::User;
use rustical_store::{Calendar, CalendarStore};
use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::marker::PhantomData;
use std::str::FromStr;
use std::sync::Arc;
use strum::{EnumDiscriminants, EnumString, IntoStaticStr, VariantNames};
@@ -254,18 +256,24 @@ impl Resource for CalendarResource {
}
}
pub struct CalendarResourceService<C: CalendarStore + ?Sized> {
pub struct CalendarResourceService<C: CalendarStore + ?Sized, S: SubscriptionStore + ?Sized> {
cal_store: Arc<C>,
__phantom_sub: PhantomData<S>,
}
impl<C: CalendarStore + ?Sized> CalendarResourceService<C> {
impl<C: CalendarStore + ?Sized, S: SubscriptionStore + ?Sized> CalendarResourceService<C, S> {
pub fn new(cal_store: Arc<C>) -> Self {
Self { cal_store }
Self {
cal_store,
__phantom_sub: PhantomData,
}
}
}
#[async_trait(?Send)]
impl<C: CalendarStore + ?Sized> ResourceService for CalendarResourceService<C> {
impl<C: CalendarStore + ?Sized, S: SubscriptionStore + ?Sized> ResourceService
for CalendarResourceService<C, S>
{
type MemberType = CalendarObjectResource;
type PathComponents = (String, String); // principal, calendar_id
type Resource = CalendarResource;
@@ -332,5 +340,6 @@ impl<C: CalendarStore + ?Sized> ResourceService for CalendarResourceService<C> {
res.route(report_method.to(route_report_calendar::<C>))
.route(mkcalendar_method.to(route_mkcalendar::<C>))
.post(route_post::<C, S>)
}
}

View File

@@ -11,14 +11,16 @@ use principal::{PrincipalResource, PrincipalResourceService};
use rustical_dav::resource::{NamedRoute, ResourceService, ResourceServiceRoute};
use rustical_dav::resources::RootResourceService;
use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider};
use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore};
use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore, SubscriptionStore};
use std::sync::Arc;
use subscription::subscription_resource;
pub mod calendar;
pub mod calendar_object;
pub mod calendar_set;
pub mod error;
pub mod principal;
mod subscription;
pub use error::Error;
@@ -30,11 +32,13 @@ pub fn configure_dav<
AP: AuthenticationProvider,
AS: AddressbookStore + ?Sized,
C: CalendarStore + ?Sized,
S: SubscriptionStore + ?Sized,
>(
cfg: &mut web::ServiceConfig,
auth_provider: Arc<AP>,
store: Arc<C>,
addr_store: Arc<AS>,
subscription_store: Arc<S>,
) {
let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store));
cfg.service(
@@ -62,6 +66,7 @@ pub fn configure_dav<
)
.app_data(Data::from(store.clone()))
.app_data(Data::from(birthday_store.clone()))
.app_data(Data::from(subscription_store))
.service(RootResourceService::<PrincipalResource>::default().actix_resource())
.service(
web::scope("/user").service(
@@ -74,7 +79,7 @@ pub fn configure_dav<
.service(
web::scope("/{calendar}")
.service(
ResourceServiceRoute(CalendarResourceService::new(store.clone()))
ResourceServiceRoute(CalendarResourceService::<_, S>::new(store.clone()))
)
.service(web::scope("/{object}").service(CalendarObjectResourceService::new(store.clone()).actix_resource()
))
@@ -85,13 +90,13 @@ pub fn configure_dav<
.service(
web::scope("/{calendar}")
.service(
ResourceServiceRoute(CalendarResourceService::new(birthday_store.clone()))
ResourceServiceRoute(CalendarResourceService::<_, S>::new(birthday_store.clone()))
)
.service(web::scope("/{object}").service(CalendarObjectResourceService::new(birthday_store.clone()).actix_resource()
))
)
)
),
),
).service(subscription_resource::<S>()),
);
}

View File

@@ -0,0 +1,20 @@
use actix_web::{
web::{self, Data, Path},
HttpResponse,
};
use rustical_store::SubscriptionStore;
async fn handle_delete<S: SubscriptionStore + ?Sized>(
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 + ?Sized>() -> actix_web::Resource {
web::resource("/subscription/{id}")
.name("subscription")
.delete(handle_delete::<S>)
}

View File

@@ -6,11 +6,13 @@ pub use error::Error;
pub mod auth;
pub mod calendar;
mod contact_birthday_store;
mod subscription_store;
pub mod synctoken;
pub use addressbook_store::AddressbookStore;
pub use calendar_store::CalendarStore;
pub use contact_birthday_store::ContactBirthdayStore;
pub use subscription_store::*;
pub use addressbook::{AddressObject, Addressbook};
pub use calendar::{Calendar, CalendarObject};

View File

@@ -0,0 +1,19 @@
use crate::Error;
use async_trait::async_trait;
use chrono::NaiveDateTime;
pub struct Subscription {
pub id: String,
pub topic: String,
pub expiration: NaiveDateTime,
pub push_resource: String,
}
#[async_trait(?Send)]
pub trait SubscriptionStore: Send + Sync + 'static {
async fn get_subscriptions(&self, topic: &str) -> Result<Vec<Subscription>, Error>;
async fn get_subscription(&self, id: &str) -> Result<Subscription, Error>;
/// Returns whether a subscription under the id already existed
async fn upsert_subscription(&self, sub: Subscription) -> Result<bool, Error>;
async fn delete_subscription(&self, id: &str) -> Result<(), Error>;
}

View File

@@ -0,0 +1,7 @@
CREATE TABLE subscriptions (
id TEXT NOT NULL,
topic TEXT NOT NULL,
expiration DATETIME NOT NULL,
push_resource TEXT NOT NULL,
PRIMARY KEY (id)
);

View File

@@ -4,6 +4,7 @@ use sqlx::{sqlite::SqliteConnectOptions, Pool, Sqlite, SqlitePool};
pub mod addressbook_store;
pub mod calendar_store;
pub mod error;
pub mod subscription_store;
pub use error::Error;

View File

@@ -0,0 +1,51 @@
use crate::SqliteStore;
use async_trait::async_trait;
use rustical_store::{Error, Subscription, SubscriptionStore};
#[async_trait(?Send)]
impl SubscriptionStore for SqliteStore {
async fn get_subscriptions(&self, topic: &str) -> Result<Vec<Subscription>, Error> {
Ok(sqlx::query_as!(
Subscription,
r#"SELECT id, topic, expiration, push_resource
FROM subscriptions
WHERE (topic) = (?)"#,
topic
)
.fetch_all(&self.db)
.await
.map_err(crate::Error::from)?)
}
async fn get_subscription(&self, id: &str) -> Result<Subscription, Error> {
Ok(sqlx::query_as!(
Subscription,
r#"SELECT id, topic, expiration, push_resource
FROM subscriptions
WHERE (id) = (?)"#,
id
)
.fetch_one(&self.db)
.await
.map_err(crate::Error::from)?)
}
async fn upsert_subscription(&self, sub: Subscription) -> Result<bool, Error> {
sqlx::query!(
r#"INSERT OR REPLACE INTO subscriptions (id, topic, expiration, push_resource) VALUES (?, ?, ?, ?)"#,
sub.id,
sub.topic,
sub.expiration,
sub.push_resource
).execute(&self.db).await.map_err(crate::Error::from)?;
// TODO: Correctly return whether a subscription already existed
Ok(false)
}
async fn delete_subscription(&self, id: &str) -> Result<(), Error> {
sqlx::query!(r#"DELETE FROM subscriptions WHERE id = ? "#, id)
.execute(&self.db)
.await
.map_err(crate::Error::from)?;
Ok(())
}
}

View File

@@ -4,13 +4,19 @@ use actix_web::middleware::NormalizePath;
use actix_web::{web, App};
use rustical_frontend::{configure_frontend, FrontendConfig};
use rustical_store::auth::AuthenticationProvider;
use rustical_store::{AddressbookStore, CalendarStore};
use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore};
use rustical_store_sqlite::subscription_store;
use std::sync::Arc;
use tracing_actix_web::TracingLogger;
pub fn make_app<AS: AddressbookStore + ?Sized, CS: CalendarStore + ?Sized>(
pub fn make_app<
AS: AddressbookStore + ?Sized,
CS: CalendarStore + ?Sized,
S: SubscriptionStore + ?Sized,
>(
addr_store: Arc<AS>,
cal_store: Arc<CS>,
subscription_store: Arc<S>,
auth_provider: Arc<impl AuthenticationProvider>,
frontend_config: FrontendConfig,
) -> App<
@@ -32,6 +38,7 @@ pub fn make_app<AS: AddressbookStore + ?Sized, CS: CalendarStore + ?Sized>(
auth_provider.clone(),
cal_store.clone(),
addr_store.clone(),
subscription_store,
)
}))
.service(web::scope("/carddav").configure(|cfg| {

View File

@@ -7,7 +7,7 @@ use clap::{Parser, Subcommand};
use commands::{cmd_gen_config, cmd_pwhash};
use config::{DataStoreConfig, SqliteDataStoreConfig};
use rustical_store::auth::StaticUserStore;
use rustical_store::{AddressbookStore, CalendarStore};
use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore};
use rustical_store_sqlite::{create_db_pool, SqliteStore};
use setup_tracing::setup_tracing;
use std::fs;
@@ -39,12 +39,20 @@ enum Command {
async fn get_data_stores(
migrate: bool,
config: &DataStoreConfig,
) -> Result<(Arc<dyn AddressbookStore>, Arc<dyn CalendarStore>)> {
) -> Result<(
Arc<dyn AddressbookStore>,
Arc<dyn CalendarStore>,
Arc<dyn SubscriptionStore>,
)> {
Ok(match &config {
DataStoreConfig::Sqlite(SqliteDataStoreConfig { db_url }) => {
let db = create_db_pool(db_url, migrate).await?;
let sqlite_store = Arc::new(SqliteStore::new(db));
(sqlite_store.clone(), sqlite_store.clone())
(
sqlite_store.clone(),
sqlite_store.clone(),
sqlite_store.clone(),
)
}
})
}
@@ -61,7 +69,7 @@ async fn main() -> Result<()> {
setup_tracing(&config.tracing);
let (addr_store, cal_store) =
let (addr_store, cal_store, subscription_store) =
get_data_stores(!args.no_migrations, &config.data_store).await?;
let user_store = Arc::new(match config.auth {
@@ -72,6 +80,7 @@ async fn main() -> Result<()> {
make_app(
addr_store.clone(),
cal_store.clone(),
subscription_store.clone(),
user_store.clone(),
config.frontend.clone(),
)