diff options
| -rw-r--r-- | .sqlx/query-2262a6d3af5b470a397f3371bbd9e0da6f99ea037bc5c4cb8c468fbd977b9543.json | 44 | ||||
| -rw-r--r-- | .sqlx/query-81023ce5ac1032bc853ea5ec03e903a30d10a1cc4c09f993c91f636e0793e225.json | 12 | ||||
| -rw-r--r-- | .sqlx/query-8bd26c3ccd7ae16380758ba7061943a09f933a996e8e74373047c45f6d5ad63e.json | 12 | ||||
| -rw-r--r-- | migrations/20250724_subscription.sql | 17 | ||||
| -rw-r--r-- | migrations/20250725023155_push_notification.sql | 15 | ||||
| -rw-r--r-- | src/app.rs | 19 | ||||
| -rw-r--r-- | src/cli.rs | 22 | ||||
| -rw-r--r-- | src/push/app.rs | 138 | ||||
| -rw-r--r-- | src/push/handlers/echo.rs | 20 | ||||
| -rw-r--r-- | src/push/handlers/mod.rs | 79 | ||||
| -rw-r--r-- | src/push/handlers/register.rs | 40 | ||||
| -rw-r--r-- | src/push/handlers/unregister.rs | 16 | ||||
| -rw-r--r-- | src/push/id.rs | 27 | ||||
| -rw-r--r-- | src/push/mod.rs | 6 | ||||
| -rw-r--r-- | src/push/repo.rs | 81 | ||||
| -rw-r--r-- | src/push/vapid.rs | 28 | ||||
| -rw-r--r-- | src/routes.rs | 9 |
17 files changed, 444 insertions, 141 deletions
diff --git a/.sqlx/query-2262a6d3af5b470a397f3371bbd9e0da6f99ea037bc5c4cb8c468fbd977b9543.json b/.sqlx/query-2262a6d3af5b470a397f3371bbd9e0da6f99ea037bc5c4cb8c468fbd977b9543.json new file mode 100644 index 0000000..f83a179 --- /dev/null +++ b/.sqlx/query-2262a6d3af5b470a397f3371bbd9e0da6f99ea037bc5c4cb8c468fbd977b9543.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n user as \"user: user::Id\",\n endpoint,\n key_p256dh,\n key_auth\n from subscription\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user: user::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "key_p256dh", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "key_auth", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "2262a6d3af5b470a397f3371bbd9e0da6f99ea037bc5c4cb8c468fbd977b9543" +} diff --git a/.sqlx/query-81023ce5ac1032bc853ea5ec03e903a30d10a1cc4c09f993c91f636e0793e225.json b/.sqlx/query-81023ce5ac1032bc853ea5ec03e903a30d10a1cc4c09f993c91f636e0793e225.json new file mode 100644 index 0000000..1dad656 --- /dev/null +++ b/.sqlx/query-81023ce5ac1032bc853ea5ec03e903a30d10a1cc4c09f993c91f636e0793e225.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into subscription (id, user, endpoint, key_p256dh, key_auth)\n values ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "81023ce5ac1032bc853ea5ec03e903a30d10a1cc4c09f993c91f636e0793e225" +} diff --git a/.sqlx/query-8bd26c3ccd7ae16380758ba7061943a09f933a996e8e74373047c45f6d5ad63e.json b/.sqlx/query-8bd26c3ccd7ae16380758ba7061943a09f933a996e8e74373047c45f6d5ad63e.json new file mode 100644 index 0000000..811689f --- /dev/null +++ b/.sqlx/query-8bd26c3ccd7ae16380758ba7061943a09f933a996e8e74373047c45f6d5ad63e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from subscription\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8bd26c3ccd7ae16380758ba7061943a09f933a996e8e74373047c45f6d5ad63e" +} diff --git a/migrations/20250724_subscription.sql b/migrations/20250724_subscription.sql deleted file mode 100644 index 368557a..0000000 --- a/migrations/20250724_subscription.sql +++ /dev/null @@ -1,17 +0,0 @@ -create table subscription ( - id text - not null - primary key, - user text - not null - references user (id), - endpoint text - unique - not null, - key_p256dh text - not null, - key_auth text - not null, - expiration_time text, -); - diff --git a/migrations/20250725023155_push_notification.sql b/migrations/20250725023155_push_notification.sql new file mode 100644 index 0000000..374e152 --- /dev/null +++ b/migrations/20250725023155_push_notification.sql @@ -0,0 +1,15 @@ +create table subscription ( + id text + not null + primary key, + user text + not null + references user (id), + endpoint text + unique + not null, + key_p256dh text + not null, + key_auth text + not null +); @@ -1,14 +1,15 @@ -use sqlx::sqlite::SqlitePool; - use crate::{ boot::app::Boot, conversation::app::Conversations, event::{self, app::Events}, invite::app::Invites, message::app::Messages, + push::app::Push, setup::app::Setup, token::{self, app::Tokens}, }; +use sqlx::sqlite::SqlitePool; +use web_push::PartialVapidSignatureBuilder; #[cfg(test)] use crate::user::app::Users; @@ -18,16 +19,24 @@ pub struct App { db: SqlitePool, events: event::Broadcaster, token_events: token::Broadcaster, + vapid_public_key: String, + vapid_signer: PartialVapidSignatureBuilder, } impl App { - pub fn from(db: SqlitePool) -> Self { + pub fn from( + db: SqlitePool, + vapid_public_key: String, + vapid_signer: PartialVapidSignatureBuilder, + ) -> Self { let events = event::Broadcaster::default(); let token_events = token::Broadcaster::default(); Self { db, events, token_events, + vapid_public_key, + vapid_signer, } } } @@ -58,6 +67,10 @@ impl App { Messages::new(&self.db, &self.events) } + pub fn push(&self) -> Push { + Push::new(&self.db, &self.vapid_public_key, &self.vapid_signer) + } + pub const fn setup(&self) -> Setup { Setup::new(&self.db, &self.events) } @@ -16,7 +16,9 @@ use tokio::net; use crate::{ app::App, - clock, db, routes, + clock, db, + push::vapid, + routes, umask::{self, Umask}, }; @@ -26,10 +28,8 @@ use crate::{ /// arguments for the `pilcrow` server: /// /// ```no_run -/// # use pilcrow::cli::Error; -/// # /// # #[tokio::main] -/// # async fn main() -> Result<(), Error> { +/// # async fn main() -> Result<(), impl std::error::Error> { /// use clap::Parser; /// use pilcrow::cli::Args; /// @@ -67,6 +67,14 @@ pub struct Args { /// upgrades #[arg(short = 'D', long, env, default_value = "sqlite://pilcrow.db.backup")] backup_database_url: String, + + /// Base64-encoded VAPID public key for web push notifications. + #[arg(long, env)] + vapid_public_key: String, + + /// Base64-encoded VAPID public key for web push notifications. + #[arg(long, env)] + vapid_private_key: vapid::PrivateKey, } impl Args { @@ -90,7 +98,10 @@ impl Args { self.umask.set(); let pool = self.pool().await?; - let app = App::from(pool); + let vapid_public_key = self.vapid_public_key.clone(); + let vapid_signer = self.vapid_private_key.as_signature_builder()?; + + let app = App::from(pool, vapid_public_key, vapid_signer); let app = routes::routes(&app) .route_layer(middleware::from_fn(clock::middleware)) .route_layer(middleware::map_response(Self::server_info())) @@ -141,6 +152,7 @@ fn started_msg(listener: &net::TcpListener) -> io::Result<String> { #[error(transparent)] enum Error { Io(#[from] io::Error), + WebPush(#[from] web_push::WebPushError), Database(#[from] db::Error), Umask(#[from] umask::Error), } diff --git a/src/push/app.rs b/src/push/app.rs new file mode 100644 index 0000000..2d6e15c --- /dev/null +++ b/src/push/app.rs @@ -0,0 +1,138 @@ +use sqlx::SqlitePool; +use web_push::{ + ContentEncoding, IsahcWebPushClient, PartialVapidSignatureBuilder, SubscriptionInfo, + WebPushClient, WebPushMessageBuilder, +}; + +use super::{Id, repo::Provider as _}; + +use crate::{ + db::NotFound as _, + user::{self, User}, +}; + +pub struct Push<'a> { + db: &'a SqlitePool, + vapid_public_key: &'a str, + vapid_signer: &'a PartialVapidSignatureBuilder, +} + +impl<'a> Push<'a> { + pub const fn new( + db: &'a SqlitePool, + vapid_public_key: &'a str, + vapid_signer: &'a PartialVapidSignatureBuilder, + ) -> Self { + Self { + db, + vapid_public_key, + vapid_signer, + } + } + + pub fn public_key(&self) -> &str { + self.vapid_public_key + } + + pub async fn register( + &self, + user: &User, + subscription: &SubscriptionInfo, + ) -> Result<Id, RegisterError> { + let mut tx = self.db.begin().await?; + let id = tx.subscriptions().create(user, subscription).await?; + tx.commit().await?; + + Ok(id) + } + + pub async fn echo( + &self, + user: &User, + subscription: &Id, + message: &str, + ) -> Result<(), EchoError> { + let mut tx = self.db.begin().await?; + let subscription = tx + .subscriptions() + .by_id(subscription) + .await + .not_found(|| EchoError::NotFound(subscription.clone()))?; + if subscription.user != user.id { + return Err(EchoError::NotSubscriber(subscription.id, user.id.clone())); + } + + tx.commit().await?; + + self.send(&subscription.info, message).await?; + + Ok(()) + } + + async fn send(&self, subscription: &SubscriptionInfo, message: &str) -> Result<(), EchoError> { + let sig_builder = self + .vapid_signer + .clone() + .add_sub_info(subscription) + .build()?; + + let payload = message.as_bytes(); + + let mut message_builder = WebPushMessageBuilder::new(subscription); + message_builder.set_payload(ContentEncoding::Aes128Gcm, payload); + message_builder.set_vapid_signature(sig_builder); + let message = message_builder.build()?; + + let client = IsahcWebPushClient::new()?; + client.send(message).await?; + + Ok(()) + } + + pub async fn unregister(&self, user: &User, subscription: &Id) -> Result<(), UnregisterError> { + let mut tx = self.db.begin().await?; + let subscription = tx + .subscriptions() + .by_id(subscription) + .await + .not_found(|| UnregisterError::NotFound(subscription.clone()))?; + if subscription.user != user.id { + return Err(UnregisterError::NotSubscriber( + subscription.id, + user.id.clone(), + )); + } + tx.subscriptions().delete(&subscription).await?; + tx.commit().await?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RegisterError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum EchoError { + #[error("subscription {0} not found")] + NotFound(Id), + #[error("user {1} is not the subscriber for subscription {0}")] + NotSubscriber(Id, user::Id), + #[error(transparent)] + WebPush(#[from] web_push::WebPushError), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum UnregisterError { + #[error("subscription {0} not found")] + NotFound(Id), + #[error("user {1} is not the subscriber for subscription {0}")] + NotSubscriber(Id, user::Id), + #[error(transparent)] + Database(#[from] sqlx::Error), +} diff --git a/src/push/handlers/echo.rs b/src/push/handlers/echo.rs new file mode 100644 index 0000000..4b4de57 --- /dev/null +++ b/src/push/handlers/echo.rs @@ -0,0 +1,20 @@ +use axum::extract::{Json, State}; + +use crate::{app::App, push::Id, token::extract::Identity}; + +#[derive(serde::Deserialize)] +pub struct Request { + subscription: Id, + msg: String, +} + +pub async fn handler( + State(app): State<App>, + identity: Identity, + Json(request): Json<Request>, +) -> Result<(), crate::error::Internal> { + let Request { subscription, msg } = request; + app.push().echo(&identity.user, &subscription, &msg).await?; + + Ok(()) +} diff --git a/src/push/handlers/mod.rs b/src/push/handlers/mod.rs index e4a531b..90edaa7 100644 --- a/src/push/handlers/mod.rs +++ b/src/push/handlers/mod.rs @@ -1,74 +1,15 @@ -use std::env; +use axum::extract::State; -use axum::{ - extract::{Json}, -}; +use crate::app::App; -use web_push::{ - SubscriptionInfo, - VapidSignatureBuilder, - WebPushMessageBuilder, - ContentEncoding, - WebPushClient, - IsahcWebPushClient, -}; +mod echo; +mod register; +mod unregister; +pub use echo::handler as echo; +pub use register::handler as register; +pub use unregister::handler as unregister; -pub async fn vapid() -> String { - let vapid_public_key = env::var("VAPID_PUBLIC_KEY").unwrap_or_default(); - String::from(vapid_public_key) -} - - -pub async fn register() -> String { - String::from("OK") -} - - -pub async fn unregister() -> String { - String::from("OK") -} - -async fn push_message( - endpoint: String, - keys: Keys, - message: &String, -) -> Result<(), crate::error::Internal> { - let content = message.as_bytes(); - - let subscription_info = SubscriptionInfo::new(endpoint, keys.p256dh, keys.auth); - // This will need to come from the DB eventually: - let private_key = String::from(env::var("VAPID_PRIVATE_KEY").unwrap_or_default()); - let sig_builder = VapidSignatureBuilder::from_base64(&private_key, &subscription_info)?.build()?; - let mut builder = WebPushMessageBuilder::new(&subscription_info); - builder.set_payload(ContentEncoding::Aes128Gcm, content); - builder.set_vapid_signature(sig_builder); - let client = IsahcWebPushClient::new()?; - client.send(builder.build()?).await?; - - Ok(()) -} - - -#[axum::debug_handler] -pub async fn echo( - Json(payload): Json<PushPayload>, -) -> Result<(), crate::error::Internal> { - push_message(payload.endpoint, payload.keys, &payload.msg).await?; - - Ok(()) -} - - -#[derive(serde::Deserialize)] -pub struct Keys { - pub p256dh: String, - pub auth: String, -} - -#[derive(serde::Deserialize)] -pub struct PushPayload { - pub msg: String, - pub endpoint: String, - pub keys: Keys, +pub async fn vapid(State(app): State<App>) -> String { + app.push().public_key().to_owned() } diff --git a/src/push/handlers/register.rs b/src/push/handlers/register.rs new file mode 100644 index 0000000..201928b --- /dev/null +++ b/src/push/handlers/register.rs @@ -0,0 +1,40 @@ +use axum::extract::{Json, State}; +use web_push::SubscriptionInfo; + +use crate::{app::App, error::Internal, push::Id, token::extract::Identity}; + +#[derive(serde::Deserialize)] +pub struct Request { + endpoint: String, + p256dh: String, + auth: String, +} + +#[derive(serde::Serialize)] +pub struct Response { + id: Id, +} + +pub async fn handler( + State(app): State<App>, + identity: Identity, + Json(request): Json<Request>, +) -> Result<Json<Response>, Internal> { + let subscription = request.into(); + + let id = app.push().register(&identity.user, &subscription).await?; + + Ok(Json(Response { id })) +} + +impl From<Request> for SubscriptionInfo { + fn from(request: Request) -> Self { + let Request { + endpoint, + p256dh, + auth, + } = request; + let info = Self::new(endpoint, p256dh, auth); + info + } +} diff --git a/src/push/handlers/unregister.rs b/src/push/handlers/unregister.rs new file mode 100644 index 0000000..a00ee92 --- /dev/null +++ b/src/push/handlers/unregister.rs @@ -0,0 +1,16 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, +}; + +use crate::{app::App, error::Internal, push::Id, token::extract::Identity}; + +pub async fn handler( + State(app): State<App>, + identity: Identity, + Path(subscription): Path<Id>, +) -> Result<StatusCode, Internal> { + app.push().unregister(&identity.user, &subscription).await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/push/id.rs b/src/push/id.rs new file mode 100644 index 0000000..b28d6ab --- /dev/null +++ b/src/push/id.rs @@ -0,0 +1,27 @@ +use std::fmt; + +use crate::id::Id as BaseId; + +// Stable identifier for a push subscription. Prefixed with `S`. +#[derive(Clone, Debug, Eq, Hash, PartialEq, sqlx::Type, serde::Deserialize, serde::Serialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct Id(BaseId); + +impl From<BaseId> for Id { + fn from(id: BaseId) -> Self { + Self(id) + } +} + +impl Id { + pub fn generate() -> Self { + BaseId::generate("S") + } +} + +impl fmt::Display for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} diff --git a/src/push/mod.rs b/src/push/mod.rs index c3d4495..c32cb27 100644 --- a/src/push/mod.rs +++ b/src/push/mod.rs @@ -1 +1,7 @@ +pub mod app; pub mod handlers; +mod id; +mod repo; +pub mod vapid; + +use id::Id; diff --git a/src/push/repo.rs b/src/push/repo.rs index 2d492ea..ddef706 100644 --- a/src/push/repo.rs +++ b/src/push/repo.rs @@ -1,9 +1,8 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; +use web_push::SubscriptionInfo; -use super::{Subscription, Id}; -use crate::{ - user::{self, User}, -} +use super::Id; +use crate::user::{self, User}; pub trait Provider { fn subscriptions(&mut self) -> Subscriptions; @@ -21,74 +20,68 @@ impl Subscriptions<'_> { pub async fn create( &mut self, user: &User, - endpoint: &String, - key_p256dh: &String, - key_auth: &String, - expiration_time: &String, - ) -> Result<Subscription, sqlx::Error> { + info: &SubscriptionInfo, + ) -> Result<Id, sqlx::Error> { let id = Id::generate(); - let subscription = sqlx::query!( + sqlx::query!( r#" - insert into subscription - (id, user, endpoint, key_p256dh, key_auth, expiration_time) - values ($1, $2, $3, $4, $5, $6) - returning - id as "id: Id", - user as "user: user::Id", - endpoint as "endpoint: String", - key_p256dh as "key_p256dh: String", - key_auth as "key_auth: String", - expiration_time as "expiration_time: String" + insert into subscription (id, user, endpoint, key_p256dh, key_auth) + values ($1, $2, $3, $4, $5) "#, id, user.id, - endpoint, - key_p256dh, - key_auth, - expiration_time, + info.endpoint, + info.keys.p256dh, + info.keys.auth, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; - Ok(subscription) + Ok(id) } - pub async fn for_user(&mut self, user: &User) -> Result<vec<Subscription>, sqlx::Error> { - let subscriptions = sqlx::query!( + pub async fn by_id(&mut self, id: &Id) -> Result<Subscription, sqlx::Error> { + let subscription = sqlx::query!( r#" select id as "id: Id", user as "user: user::Id", - endpoint as "endpoint: String", - key_p256dh as "key_p256dh: String", - key_auth as "key_auth: String", + endpoint, + key_p256dh, + key_auth from subscription - where user = $1 + where id = $1 "#, - user.id, + id, ) - .fetch_all(&mut *self.0) + .map(|row| Subscription { + id: row.id, + user: row.user, + info: SubscriptionInfo::new(row.endpoint, row.key_p256dh, row.key_auth), + }) + .fetch_one(&mut *self.0) .await?; - Ok(subscriptions) + Ok(subscription) } - pub async fn delete( - &mut self, - subscription: &Subscription, - deleted: &Instant, - ) -> Result<(), sqlx::Error> { - let id = subscription.id(); - + pub async fn delete(&mut self, subscription: &Subscription) -> Result<(), sqlx::Error> { sqlx::query!( r#" - delete from subscription where id = $1 + delete from subscription + where id = $1 "#, - id, + subscription.id, ) .execute(&mut *self.0) .await?; Ok(()) } } + +pub struct Subscription { + pub id: Id, + pub user: user::Id, + pub info: SubscriptionInfo, +} diff --git a/src/push/vapid.rs b/src/push/vapid.rs new file mode 100644 index 0000000..b13a00c --- /dev/null +++ b/src/push/vapid.rs @@ -0,0 +1,28 @@ +use std::fmt; + +use web_push::{PartialVapidSignatureBuilder, VapidSignatureBuilder, WebPushError}; + +#[derive(Clone)] +pub struct PrivateKey(String); + +impl PrivateKey { + pub fn as_signature_builder(&self) -> Result<PartialVapidSignatureBuilder, WebPushError> { + let Self(key) = self; + VapidSignatureBuilder::from_base64_no_sub(key) + } +} + +impl fmt::Debug for PrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PrivateKey").field(&"********").finish() + } +} + +impl<S> From<S> for PrivateKey +where + S: Into<String>, +{ + fn from(value: S) -> Self { + Self(value.into()) + } +} diff --git a/src/routes.rs b/src/routes.rs index 293dae3..ce60835 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,7 +4,7 @@ use axum::{ routing::{delete, get, post}, }; -use crate::{app::App, boot, conversation, event, expire, invite, message, setup, push, ui, user}; +use crate::{app::App, boot, conversation, event, expire, invite, message, push, setup, ui, user}; pub fn routes(app: &App) -> Router<App> { // UI routes that can be accessed before the administrator completes setup. @@ -26,8 +26,11 @@ pub fn routes(app: &App) -> Router<App> { let api_bootstrap = Router::new() .route("/api/setup", post(setup::handlers::setup)) .route("/api/vapid", get(push::handlers::vapid)) - .route("/api/register", post(push::handlers::register)) - .route("/api/unregister", post(push::handlers::unregister)) + .route("/api/push", post(push::handlers::register)) + .route( + "/api/push/:subscription", + delete(push::handlers::unregister), + ) .route("/api/echo", post(push::handlers::echo)); // API routes that require the administrator to complete setup first. |
