Skip to content
Clone
acbf7ea
171 lines 6.8 KB Rust
//! 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<String>,
#[serde(default)]
pub icon: Option<String>,
/// 通知合并键;同一房间的连续消息只保留最后一条横幅。
#[serde(default)]
pub tag: Option<String>,
/// 点击通知要打开的绝对 URL。
pub url: String,
}
/// 服务端 VAPID 密钥对,来自 `[push]` 配置。
///
/// `ES256KeyPair` 不实现 `Clone`,所以共享的是 `Arc<VapidKeys>`;签名是纯计算,
/// 多 worker 共用一份密钥没有任何并发问题。
pub struct VapidKeys {
key_pair: ES256KeyPair,
subject: String,
}
impl VapidKeys {
/// 用真实曲线运算确认公私钥成对:配置校验只看形状,配错私钥时应该让进程
/// 在启动阶段就停下,而不是给每台设备都签出无效的 VAPID 头。
pub fn from_config(config: &config::push::PushConfig) -> Result<Self, PushError> {
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::<PublicKey>::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<http::Request<Vec<u8>>, 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<DeliveryOutcome, PushError> {
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 = export::telemetry::http_client_span("POST", &request.uri().to_string());
let response = export::telemetry::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,
}
}