changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -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());
}
}