//! Web Push 投递核心:VAPID 签名、载荷加密、HTTP 提交与失败分级。 //! //! 这一层只负责"把一段 JSON 变成系统通知":业务闸门(偏好位、在线状态、 //! 静音)都在 `worker` 里,这里拿到的就是"确定要投"的载荷。 use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::{Deserialize, Serialize}; use web_push_native::jwt_simple::algorithms::ES256KeyPair; use web_push_native::jwt_simple::prelude::ECDSAP256PublicKeyLike as _; use web_push_native::p256::elliptic_curve::sec1::FromEncodedPoint; use web_push_native::p256::{EncodedPoint, PublicKey}; use web_push_native::{Auth, WebPushBuilder}; use crate::error::PushError; /// 系统通知携带的载荷;Service Worker 的 `showNotification` 直接消费。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationPayload { pub title: String, #[serde(default)] pub body: Option, #[serde(default)] pub icon: Option, /// 通知合并键;同一房间的连续消息只保留最后一条横幅。 #[serde(default)] pub tag: Option, /// 点击通知要打开的绝对 URL。 pub url: String, } /// 服务端 VAPID 密钥对,来自 `[push]` 配置。 /// /// `ES256KeyPair` 不实现 `Clone`,所以共享的是 `Arc`;签名是纯计算, /// 多 worker 共用一份密钥没有任何并发问题。 pub struct VapidKeys { key_pair: ES256KeyPair, subject: String, } impl VapidKeys { /// 用真实曲线运算确认公私钥成对:配置校验只看形状,配错私钥时应该让进程 /// 在启动阶段就停下,而不是给每台设备都签出无效的 VAPID 头。 pub fn from_config(config: &config::push::PushConfig) -> Result { let private = config .private_key_bytes() .ok_or_else(|| PushError::Config("push.vapid_private_key is unreadable".into()))?; let public = config .public_key_bytes() .ok_or_else(|| PushError::Config("push.vapid_public_key is unreadable".into()))?; let key_pair = ES256KeyPair::from_bytes(&private) .map_err(|error| PushError::Config(format!("invalid VAPID private key: {error}")))?; let derived = key_pair.public_key().public_key().to_bytes_uncompressed(); if derived != public { return Err(PushError::Config( "push.vapid_private_key does not match push.vapid_public_key".into(), )); } Ok(Self { key_pair, subject: config.vapid_subject.trim().to_string(), }) } pub fn subject(&self) -> &str { &self.subject } } /// 一条浏览器订阅的密钥材料(`user_push_subscription` 的三列)。 pub struct SubscriptionKeys { pub endpoint: String, pub p256dh_key: String, pub auth_key: String, } /// 浏览器给的 `p256dh` / `auth` 是 base64url(可能带填充),这里解成曲线点与密钥。 fn subscription_material(p256dh: &str, auth: &str) -> Result<(PublicKey, Auth), PushError> { let decoded = URL_SAFE_NO_PAD .decode(p256dh.trim().trim_end_matches('=')) .map_err(|error| PushError::Subscription(format!("invalid p256dh: {error}")))?; let point = EncodedPoint::from_bytes(&decoded) .map_err(|_| PushError::Subscription("p256dh is not a SEC1 point".into()))?; let ua_public = Option::::from(PublicKey::from_encoded_point(&point)) .ok_or_else(|| PushError::Subscription("p256dh is not on the curve".into()))?; let auth_bytes = URL_SAFE_NO_PAD .decode(auth.trim().trim_end_matches('=')) .map_err(|error| PushError::Subscription(format!("invalid auth key: {error}")))?; let mut ua_auth = Auth::default(); if auth_bytes.len() != ua_auth.len() { return Err(PushError::Subscription(format!( "auth key must be {} bytes, got {}", ua_auth.len(), auth_bytes.len() ))); } ua_auth.copy_from_slice(&auth_bytes); Ok((ua_public, ua_auth)) } /// 把一段载荷加密成可以提交给推送服务的请求。 /// /// 生成的请求已经带上 RFC8030/8292 要求的全部头部(`TTL`、 /// `Content-Encoding`、`Content-Type`、VAPID `Authorization`),调用方只需把 /// 它原样送出去:自己再拼一遍头部只会让两处约定漂移。 pub fn build_request( keys: &VapidKeys, subscription: &SubscriptionKeys, payload: &NotificationPayload, ttl_secs: u64, ) -> Result>, PushError> { let (ua_public, ua_auth) = subscription_material(&subscription.p256dh_key, &subscription.auth_key)?; let endpoint: http::Uri = subscription .endpoint .trim() .parse() .map_err(|_| PushError::Subscription("endpoint is not a valid URL".into()))?; let body = serde_json::to_vec(payload) .map_err(|error| PushError::Internal(format!("payload encode: {error}")))?; WebPushBuilder::new(endpoint, ua_public, ua_auth) .with_valid_duration(Duration::from_secs(ttl_secs.max(1))) .with_vapid(&keys.key_pair, keys.subject()) .build(body) .map_err(|error| PushError::Internal(format!("web push build: {error}"))) } /// 加密并提交一条推送;返回值是推送服务的判定。 pub async fn deliver( client: &reqwest::Client, keys: &VapidKeys, subscription: &SubscriptionKeys, payload: &NotificationPayload, ttl_secs: u64, ) -> Result { let request = build_request(keys, subscription, payload, ttl_secs)?; let mut outbound = client.post(request.uri().to_string()); for (name, value) in request.headers() { outbound = outbound.header(name.as_str(), value.as_bytes()); } let span = oltp::client::http_client_span("POST", &request.uri().to_string()); let response = oltp::client::send(span, outbound.body(request.into_body())) .await .map_err(|error| PushError::Transport(error.to_string()))?; Ok(classify_status(response.status().as_u16())) } /// 推送服务对一条投递的判定;worker 据此决定重试、丢弃还是删除订阅。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeliveryOutcome { Delivered, /// 订阅已失效(`404` / `410`):调用方删除订阅行,不再重试。 Gone, /// 暂时性失败(网络错误、`408`、`429`、`5xx`):可以重试。 Retry, /// 永久性失败(其余 `4xx`):重试没有意义,直接进 DLQ。 Rejected, } pub fn classify_status(status: u16) -> DeliveryOutcome { match status { 200..=299 => DeliveryOutcome::Delivered, 404 | 410 => DeliveryOutcome::Gone, 408 | 429 => DeliveryOutcome::Retry, 400..=499 => DeliveryOutcome::Rejected, _ => DeliveryOutcome::Retry, } }