92 lines
3.2 KiB
Rust
92 lines
3.2 KiB
Rust
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<ES256KeyPair>,
|
|
sender_email: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct PushPayload {
|
|
pub title: String,
|
|
pub body: String,
|
|
pub url: Option<String>,
|
|
pub icon: Option<String>,
|
|
}
|
|
|
|
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<Self> {
|
|
// 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(())
|
|
}
|
|
}
|