diff --git a/crates/caldav/src/calendar/methods/mod.rs b/crates/caldav/src/calendar/methods/mod.rs index 436ff41..bd97f27 100644 --- a/crates/caldav/src/calendar/methods/mod.rs +++ b/crates/caldav/src/calendar/methods/mod.rs @@ -1,2 +1,3 @@ pub mod mkcalendar; +pub mod post; pub mod report; diff --git a/crates/caldav/src/calendar/methods/post.rs b/crates/caldav/src/calendar/methods/post.rs new file mode 100644 index 0000000..28aa1ab --- /dev/null +++ b/crates/caldav/src/calendar/methods/post.rs @@ -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, +} + +#[instrument(parent = root_span.id(), skip(store, subscription_store, root_span, req))] +pub async fn route_post( + path: Path<(String, String)>, + body: String, + user: User, + store: Data, + subscription_store: Data, + root_span: RootSpan, + req: HttpRequest, +) -> Result { + 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#" + + + + + https://up.example.net/yohd4yai5Phiz1wi + + + Wed, 20 Dec 2023 10:03:31 GMT + + "#, + ) + .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()) + } + ) + } +} diff --git a/crates/caldav/src/calendar/resource.rs b/crates/caldav/src/calendar/resource.rs index 06af369..78c4d4c 100644 --- a/crates/caldav/src/calendar/resource.rs +++ b/crates/caldav/src/calendar/resource.rs @@ -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 { +pub struct CalendarResourceService { cal_store: Arc, + __phantom_sub: PhantomData, } -impl CalendarResourceService { +impl CalendarResourceService { pub fn new(cal_store: Arc) -> Self { - Self { cal_store } + Self { + cal_store, + __phantom_sub: PhantomData, + } } } #[async_trait(?Send)] -impl ResourceService for CalendarResourceService { +impl ResourceService + for CalendarResourceService +{ type MemberType = CalendarObjectResource; type PathComponents = (String, String); // principal, calendar_id type Resource = CalendarResource; @@ -332,5 +340,6 @@ impl ResourceService for CalendarResourceService { res.route(report_method.to(route_report_calendar::)) .route(mkcalendar_method.to(route_mkcalendar::)) + .post(route_post::) } } diff --git a/crates/caldav/src/lib.rs b/crates/caldav/src/lib.rs index d59374c..93d06ec 100644 --- a/crates/caldav/src/lib.rs +++ b/crates/caldav/src/lib.rs @@ -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, store: Arc, addr_store: Arc, + subscription_store: Arc, ) { 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::::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::()), ); } diff --git a/crates/caldav/src/subscription.rs b/crates/caldav/src/subscription.rs new file mode 100644 index 0000000..acf6f73 --- /dev/null +++ b/crates/caldav/src/subscription.rs @@ -0,0 +1,20 @@ +use actix_web::{ + web::{self, Data, Path}, + HttpResponse, +}; +use rustical_store::SubscriptionStore; + +async fn handle_delete( + store: Data, + path: Path, +) -> Result { + let id = path.into_inner(); + store.delete_subscription(&id).await?; + Ok(HttpResponse::NoContent().body("Unregistered")) +} + +pub fn subscription_resource() -> actix_web::Resource { + web::resource("/subscription/{id}") + .name("subscription") + .delete(handle_delete::) +} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 8cb16ac..dea7f87 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -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}; diff --git a/crates/store/src/subscription_store.rs b/crates/store/src/subscription_store.rs new file mode 100644 index 0000000..12f0f0f --- /dev/null +++ b/crates/store/src/subscription_store.rs @@ -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, Error>; + async fn get_subscription(&self, id: &str) -> Result; + /// Returns whether a subscription under the id already existed + async fn upsert_subscription(&self, sub: Subscription) -> Result; + async fn delete_subscription(&self, id: &str) -> Result<(), Error>; +} diff --git a/crates/store_sqlite/migrations/3_subscriptions.sql b/crates/store_sqlite/migrations/3_subscriptions.sql new file mode 100644 index 0000000..9116f48 --- /dev/null +++ b/crates/store_sqlite/migrations/3_subscriptions.sql @@ -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) +); diff --git a/crates/store_sqlite/src/lib.rs b/crates/store_sqlite/src/lib.rs index 827a051..2e79d22 100644 --- a/crates/store_sqlite/src/lib.rs +++ b/crates/store_sqlite/src/lib.rs @@ -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; diff --git a/crates/store_sqlite/src/subscription_store.rs b/crates/store_sqlite/src/subscription_store.rs new file mode 100644 index 0000000..5d57300 --- /dev/null +++ b/crates/store_sqlite/src/subscription_store.rs @@ -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, 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 { + 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 { + 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(()) + } +} diff --git a/src/app.rs b/src/app.rs index 058febf..4a5519f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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( +pub fn make_app< + AS: AddressbookStore + ?Sized, + CS: CalendarStore + ?Sized, + S: SubscriptionStore + ?Sized, +>( addr_store: Arc, cal_store: Arc, + subscription_store: Arc, auth_provider: Arc, frontend_config: FrontendConfig, ) -> App< @@ -32,6 +38,7 @@ pub fn make_app( auth_provider.clone(), cal_store.clone(), addr_store.clone(), + subscription_store, ) })) .service(web::scope("/carddav").configure(|cfg| { diff --git a/src/main.rs b/src/main.rs index a46a2cf..f0407b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, Arc)> { +) -> Result<( + Arc, + Arc, + Arc, +)> { 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(), )