Files
termi-blog/backend/src/services/web_push.rs
limitcool 9665c933b5
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
feat: update tag and timeline share panel copy for clarity and conciseness
style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
2026-04-02 23:05:49 +08:00

121 lines
4.3 KiB
Rust

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<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn configured_value(value: Option<&String>) -> Option<String> {
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<String> {
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<String> {
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<String> {
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<SubscriptionInfo> {
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::<SubscriptionInfo>(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<Urgency>,
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(())
}