use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::Deserialize; /// Web Push(RFC8030 + RFC8292)投递配置。 /// /// `vapid_private_key` / `vapid_public_key` 都是 base64url(无填充)的 P-256 /// 密钥材料:私钥是 32 字节标量,公钥是 65 字节非压缩点。这里只做形状校验 /// (长度与编码),密钥本身能否构成一对由 `push` crate 在 worker 启动时用 /// 真正的曲线运算确认——那一步失败会让 apihost 直接退出,不静默丢推送。 #[derive(Clone, Debug, Deserialize)] #[serde(default)] pub struct PushConfig { pub enabled: bool, pub vapid_public_key: String, pub vapid_private_key: String, /// VAPID `sub` 声明:通常是 `mailto:ops@example.com` 或站点 URL。 pub vapid_subject: String, pub workers: usize, pub max_attempts: u32, pub retry_backoff_base_secs: u64, pub request_timeout_secs: u64, /// 单个任务内并发投递的设备数。 pub delivery_concurrency: usize, /// 单个任务的总时间预算;超出后取消本次尝试并重投未完成的设备。 pub job_timeout_secs: u64, /// 推送消息在推送服务上的存活时间(TTL 头)。 pub ttl_secs: u64, } impl Default for PushConfig { fn default() -> Self { Self { enabled: false, vapid_public_key: String::new(), vapid_private_key: String::new(), vapid_subject: String::new(), workers: 2, max_attempts: 3, retry_backoff_base_secs: 30, request_timeout_secs: 10, delivery_concurrency: 4, job_timeout_secs: 25, ttl_secs: 86_400, } } } impl PushConfig { pub fn is_valid(&self) -> Result<(), String> { if !self.enabled { return Ok(()); } let public = decode_key(&self.vapid_public_key) .ok_or_else(|| "push.vapid_public_key must be base64url".to_string())?; if public.len() != 65 || public.first() != Some(&4) { return Err( "push.vapid_public_key must be a 65-byte uncompressed P-256 point".to_string(), ); } let private = decode_key(&self.vapid_private_key) .ok_or_else(|| "push.vapid_private_key must be base64url".to_string())?; if private.len() != 32 { return Err("push.vapid_private_key must decode to a 32-byte scalar".to_string()); } if !self.vapid_subject.trim().starts_with("mailto:") && !self.vapid_subject.trim().starts_with("https://") { return Err("push.vapid_subject must be a mailto: address or https:// URL".to_string()); } if self.workers == 0 || self.max_attempts == 0 || self.request_timeout_secs == 0 || self.delivery_concurrency == 0 || self.job_timeout_secs == 0 || self.ttl_secs == 0 { return Err("push numeric settings must be greater than zero".to_string()); } if self.job_timeout_secs < self.request_timeout_secs { return Err( "push.job_timeout_secs must be at least push.request_timeout_secs".to_string(), ); } Ok(()) } pub fn public_key_bytes(&self) -> Option> { decode_key(&self.vapid_public_key) } pub fn private_key_bytes(&self) -> Option> { decode_key(&self.vapid_private_key) } } fn decode_key(raw: &str) -> Option> { let trimmed = raw.trim(); if trimmed.is_empty() { return None; } URL_SAFE_NO_PAD .decode(trimmed.trim_end_matches('=')) .ok() .filter(|bytes| !bytes.is_empty()) }