use std::sync::Arc; use anyhow::{bail, Context}; use base64ct::{Base64UrlUnpadded, Encoding}; use serde::Serialize; use web_push_native::{ jwt_simple::algorithms::ES256KeyPair, p256::PublicKey, Auth, WebPushBuilder, }; #[derive(Clone)] pub struct WebPushService { http: reqwest::Client, vapid_key_pair: Arc, sender_email: String, } #[derive(Debug, Clone, Serialize)] pub struct PushPayload { pub title: String, pub body: String, pub url: Option, pub icon: Option, } impl WebPushService { /// Create a new WebPush service with VAPID keys. /// - `_vapid_public_key`: Base64url-encoded P-256 public key (derived from private key) /// - `vapid_private_key`: Base64url-encoded P-256 private key /// - `sender_email`: Contact email for VAPID (e.g. "mailto:admin@example.com") pub fn new( _vapid_public_key: String, vapid_private_key: String, sender_email: String, ) -> anyhow::Result { // The VAPID private key bytes are used to create the ES256KeyPair. // The public key is derived from it, so we don't need to validate the public key separately. let key_bytes = Base64UrlUnpadded::decode_vec(&vapid_private_key) .context("Failed to decode VAPID private key")?; let vapid_key_pair = ES256KeyPair::from_bytes(&key_bytes).context("Invalid VAPID private key")?; Ok(Self { http: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .context("Failed to build HTTP client")?, vapid_key_pair: Arc::new(vapid_key_pair), sender_email, }) } /// Send a push notification to a browser subscription. pub async fn send( &self, endpoint: &str, p256dh: &str, auth: &str, payload: &PushPayload, ) -> anyhow::Result<()> { let endpoint_uri: http::Uri = endpoint .parse() .with_context(|| format!("Invalid endpoint URL: {}", endpoint))?; let ua_public_bytes = Base64UrlUnpadded::decode_vec(p256dh) .with_context(|| format!("Failed to decode p256dh: {}", p256dh))?; let ua_public = PublicKey::from_sec1_bytes(&ua_public_bytes) .with_context(|| "Invalid p256dh key")?; let auth_bytes = Base64UrlUnpadded::decode_vec(auth) .with_context(|| format!("Failed to decode auth: {}", auth))?; let ua_auth = Auth::clone_from_slice(&auth_bytes); let payload_bytes = serde_json::to_vec(payload)?; let request = WebPushBuilder::new(endpoint_uri, ua_public, ua_auth) .with_vapid(&self.vapid_key_pair, &self.sender_email) .build(payload_bytes)?; let reqwest_request = reqwest::Request::try_from(request) .context("Failed to convert web-push request")?; let response = self.http.execute(reqwest_request).await?; let status = response.status(); if !status.is_success() && status.as_u16() != 201 { let body = response.text().await.unwrap_or_default(); bail!("WebPush failed: {} - {}", status, body); } Ok(()) } }