use loco_rs::prelude::*; use serde_json::Value; use web_push::{ ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder, }; use crate::models::_entities::site_settings; const ENV_PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: &str = "PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY"; const ENV_LEGACY_WEB_PUSH_VAPID_PUBLIC_KEY: &str = "TERMI_WEB_PUSH_VAPID_PUBLIC_KEY"; const ENV_WEB_PUSH_VAPID_PRIVATE_KEY: &str = "TERMI_WEB_PUSH_VAPID_PRIVATE_KEY"; const ENV_WEB_PUSH_VAPID_SUBJECT: &str = "TERMI_WEB_PUSH_VAPID_SUBJECT"; fn env_value(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn configured_value(value: Option<&String>) -> Option { value.and_then(|item| { let trimmed = item.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) } pub fn public_key(settings: &site_settings::Model) -> Option { configured_value(settings.web_push_vapid_public_key.as_ref()) .or_else(|| env_value(ENV_PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY)) .or_else(|| env_value(ENV_LEGACY_WEB_PUSH_VAPID_PUBLIC_KEY)) } pub fn private_key(settings: &site_settings::Model) -> Option { configured_value(settings.web_push_vapid_private_key.as_ref()) .or_else(|| env_value(ENV_WEB_PUSH_VAPID_PRIVATE_KEY)) } pub fn vapid_subject(settings: &site_settings::Model) -> Option { configured_value(settings.web_push_vapid_subject.as_ref()) .or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT)) } fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String { vapid_subject(settings) .or_else(|| { site_url .map(str::trim) .filter(|value| value.starts_with("http://") || value.starts_with("https://")) .map(ToString::to_string) }) .unwrap_or_else(|| "mailto:noreply@example.com".to_string()) } pub fn public_key_configured(settings: &site_settings::Model) -> bool { public_key(settings).is_some() } pub fn private_key_configured(settings: &site_settings::Model) -> bool { private_key(settings).is_some() } pub fn is_enabled(settings: &site_settings::Model) -> bool { public_key_configured(settings) && private_key_configured(settings) } pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result { let subscription = metadata .and_then(Value::as_object) .and_then(|object| object.get("subscription")) .cloned() .ok_or_else(|| Error::BadRequest("browser push metadata 缺少 subscription".to_string()))?; serde_json::from_value::(subscription) .map_err(|_| Error::BadRequest("browser push metadata 非法".to_string())) } pub async fn send_payload( settings: &site_settings::Model, subscription_info: &SubscriptionInfo, payload: &[u8], urgency: Option, ttl: u32, site_url: Option<&str>, ) -> Result<()> { let private_key = private_key(settings) .ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?; let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info) .map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?; signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url)); let signature = signature_builder .build() .map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?; let mut builder = WebPushMessageBuilder::new(subscription_info); builder.set_ttl(ttl); if let Some(urgency) = urgency { builder.set_urgency(urgency); } builder.set_payload(ContentEncoding::Aes128Gcm, payload); builder.set_vapid_signature(signature); let client = HyperWebPushClient::new(); let message = builder .build() .map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?; client .send(message) .await .map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?; Ok(()) }