@@ -7,7 +7,7 @@ use web_push::{
|
||||
|
||||
use config::PushConfig;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::PushSubscriptionQueryPort;
|
||||
use domain::ports::{PushSubscriptionCommandPort, PushSubscriptionQueryPort};
|
||||
use domain::push::PushSubscription;
|
||||
use domain::user::UserId;
|
||||
|
||||
@@ -16,12 +16,24 @@ pub struct WebPushSender {
|
||||
vapid_private_key: String,
|
||||
vapid_subject: String,
|
||||
subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
subscription_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||
}
|
||||
|
||||
/// What happened to a single subscription during a send.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Delivery {
|
||||
Sent,
|
||||
/// The subscription is permanently unusable and has been dropped.
|
||||
Removed,
|
||||
/// Transient failure; the subscription is kept for the next attempt.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl WebPushSender {
|
||||
pub fn new(
|
||||
config: &PushConfig,
|
||||
subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
subscription_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let private_key = required_config(&config.vapid_private_key, "vapid_private_key")?;
|
||||
let subject = required_config(&config.vapid_subject, "vapid_subject")?;
|
||||
@@ -35,6 +47,7 @@ impl WebPushSender {
|
||||
vapid_private_key: private_key.to_string(),
|
||||
vapid_subject: subject.to_string(),
|
||||
subscription_query,
|
||||
subscription_command,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,20 +77,72 @@ impl WebPushSender {
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to sign push message: {e}")))
|
||||
}
|
||||
|
||||
async fn deliver(&self, sub: &PushSubscription, payload: &str) -> Result<(), DomainError> {
|
||||
let subscription_info = SubscriptionInfo::new(sub.endpoint(), sub.p256dh(), sub.auth());
|
||||
let signature = self.sign_for_subscription(&subscription_info)?;
|
||||
let message = build_push_message(&subscription_info, signature, payload)?;
|
||||
let req = into_reqwest(&self.client, message);
|
||||
async fn deliver(&self, sub: &PushSubscription, payload: &str) -> Delivery {
|
||||
// Subscriptions stored before the SPA switched to base64url hold standard
|
||||
// base64 keys; web-push only accepts base64url, so normalise on read.
|
||||
let subscription_info = SubscriptionInfo::new(
|
||||
sub.endpoint().to_string(),
|
||||
normalize_base64url(sub.p256dh()),
|
||||
normalize_base64url(sub.auth()),
|
||||
);
|
||||
|
||||
match req.send().await {
|
||||
Ok(response) => log_response(response, sub).await,
|
||||
let message = match self
|
||||
.sign_for_subscription(&subscription_info)
|
||||
.and_then(|sig| build_push_message(&subscription_info, sig, payload))
|
||||
{
|
||||
Ok(message) => message,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
endpoint = sub.endpoint(),
|
||||
error = %e,
|
||||
"push subscription has unusable keys, dropping it"
|
||||
);
|
||||
self.forget(sub).await;
|
||||
return Delivery::Removed;
|
||||
}
|
||||
};
|
||||
|
||||
match into_reqwest(&self.client, message).send().await {
|
||||
Ok(response) => self.handle_response(response, sub).await,
|
||||
Err(e) => {
|
||||
tracing::warn!(endpoint = sub.endpoint(), error = %e, "failed to send push");
|
||||
Delivery::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
async fn handle_response(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
sub: &PushSubscription,
|
||||
) -> Delivery {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
tracing::info!(endpoint = sub.endpoint(), "push notification sent");
|
||||
return Delivery::Sent;
|
||||
}
|
||||
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(endpoint = sub.endpoint(), %status, body, "push endpoint rejected");
|
||||
|
||||
// 404/410 mean the push service has retired this endpoint for good.
|
||||
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||
self.forget(sub).await;
|
||||
return Delivery::Removed;
|
||||
}
|
||||
|
||||
Delivery::Failed
|
||||
}
|
||||
|
||||
async fn forget(&self, sub: &PushSubscription) {
|
||||
if let Err(e) = self
|
||||
.subscription_command
|
||||
.delete_by_endpoint(sub.endpoint())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(endpoint = sub.endpoint(), error = %e, "failed to drop push subscription");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +157,26 @@ impl domain::ports::ReminderSenderPort for WebPushSender {
|
||||
}
|
||||
|
||||
let payload = reminder_payload();
|
||||
let mut sent = 0usize;
|
||||
let mut removed = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
// One broken subscription must never stop the others from being delivered.
|
||||
for sub in &subscriptions {
|
||||
self.deliver(sub, &payload).await?;
|
||||
match self.deliver(sub, &payload).await {
|
||||
Delivery::Sent => sent += 1,
|
||||
Delivery::Removed => removed += 1,
|
||||
Delivery::Failed => failed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(%user_id, sent, removed, failed, "push reminder dispatched");
|
||||
|
||||
if sent == 0 {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"no push notification could be delivered ({removed} stale subscription(s) dropped, \
|
||||
{failed} failed); re-enable notifications on your devices"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -126,6 +208,20 @@ fn base64_url_encode(input: &[u8]) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
|
||||
}
|
||||
|
||||
/// Rewrite a standard-base64 key into the unpadded base64url form web-push expects.
|
||||
/// Already-base64url input passes through unchanged.
|
||||
fn normalize_base64url(key: &str) -> String {
|
||||
key.trim()
|
||||
.trim_end_matches('=')
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'+' => '-',
|
||||
'/' => '_',
|
||||
other => other,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reminder_payload() -> String {
|
||||
serde_json::json!({
|
||||
"title": "K-Mood",
|
||||
@@ -177,12 +273,53 @@ fn into_reqwest(client: &reqwest::Client, message: WebPushMessage) -> reqwest::R
|
||||
req
|
||||
}
|
||||
|
||||
async fn log_response(response: reqwest::Response, sub: &PushSubscription) {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
tracing::info!(endpoint = sub.endpoint(), "push notification sent");
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(endpoint = sub.endpoint(), %status, body, "push endpoint rejected");
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A real browser key, in both encodings the SPA has used.
|
||||
const P256DH_URL: &str =
|
||||
"BIM-sgr6FjcgJEXAWF1FysKt3Ua19-HfezOPbDVCpvQvGjvH4ITJCVqIZycQO9LFrJA3cLwDgZzf__tpMu9sJMs";
|
||||
const P256DH_STD: &str =
|
||||
"BIM+sgr6FjcgJEXAWF1FysKt3Ua19+HfezOPbDVCpvQvGjvH4ITJCVqIZycQO9LFrJA3cLwDgZzf//tpMu9sJMs=";
|
||||
const AUTH_URL: &str = "q5Ph0_85t6y2GhRWxzDnyw";
|
||||
const AUTH_STD: &str = "q5Ph0/85t6y2GhRWxzDnyw==";
|
||||
const ENDPOINT: &str = "https://jmt17.google.com/fcm/send/eLUei3g4D0M";
|
||||
|
||||
#[test]
|
||||
fn normalizes_legacy_standard_base64_keys() {
|
||||
assert_eq!(normalize_base64url(P256DH_STD), P256DH_URL);
|
||||
assert_eq!(normalize_base64url(AUTH_STD), AUTH_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_base64url_keys_untouched() {
|
||||
assert_eq!(normalize_base64url(P256DH_URL), P256DH_URL);
|
||||
assert_eq!(normalize_base64url(AUTH_URL), AUTH_URL);
|
||||
}
|
||||
|
||||
/// Legacy rows used to abort the whole send with "invalid cryptographic keys".
|
||||
#[test]
|
||||
fn legacy_keys_encrypt_after_normalization() {
|
||||
for (p256dh, auth) in [(P256DH_STD, AUTH_STD), (P256DH_URL, AUTH_URL)] {
|
||||
let info = SubscriptionInfo::new(
|
||||
ENDPOINT.to_string(),
|
||||
normalize_base64url(p256dh),
|
||||
normalize_base64url(auth),
|
||||
);
|
||||
let payload = reminder_payload();
|
||||
let mut builder = WebPushMessageBuilder::new(&info);
|
||||
builder.set_payload(ContentEncoding::Aes128Gcm, payload.as_bytes());
|
||||
assert!(builder.build().is_ok(), "failed for {p256dh}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_standard_base64_keys_are_rejected_by_web_push() {
|
||||
let info = SubscriptionInfo::new(ENDPOINT, P256DH_STD, AUTH_STD);
|
||||
let payload = reminder_payload();
|
||||
let mut builder = WebPushMessageBuilder::new(&info);
|
||||
builder.set_payload(ContentEncoding::Aes128Gcm, payload.as_bytes());
|
||||
assert!(builder.build().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user