use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use cot::db::{Database, Model, query}; use serde_json::json; use web_push_native::jwt_simple::algorithms::ES256KeyPair; use web_push_native::p256::PublicKey; use web_push_native::{Auth, WebPushBuilder}; use crate::models::{Client, PushSubscription, Setting, Visit}; pub struct VapidConfig { pub public_key: String, private_key: String, subject: String, } pub async fn load_config(db: &Database) -> Option { let settings = Setting::objects().all(db).await.ok()?; let value = |key: &str| { settings .iter() .find(|setting| setting.key == key) .map(|setting| setting.value.trim().to_string()) .filter(|value| !value.is_empty()) }; let strip_assignment = |value: String, name: &str| { value .strip_prefix(&format!("{name}=")) .unwrap_or(&value) .trim() .to_string() }; let public_key = strip_assignment(value("vapid_public_key")?, "WEB_PETTING_VAPID_PUBLIC_KEY"); let private_key = strip_assignment(value("vapid_private_key")?, "WEB_PETTING_VAPID_PRIVATE_KEY"); let subject = strip_assignment( value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string()), "WEB_PETTING_VAPID_SUBJECT", ); let public_bytes = URL_SAFE_NO_PAD.decode(&public_key).ok()?; let private_bytes = URL_SAFE_NO_PAD.decode(&private_key).ok()?; if public_bytes.len() != 65 || public_bytes.first() != Some(&4) || private_bytes.len() != 32 { tracing::warn!( public_key_bytes = public_bytes.len(), private_key_bytes = private_bytes.len(), "invalid VAPID configuration in database" ); return None; } Some(VapidConfig { public_key, private_key, subject, }) } pub async fn initialize(db: &Database) { if load_config(db).await.is_some() { tracing::info!("VAPID configuration loaded from database"); } else { tracing::info!("VAPID configuration is not set; client Web Push is disabled"); } } pub async fn notify_visit_completed(db: &Database, visit: &Visit) { let setting_key = "client_notifications_enabled".to_string(); let enabled = query!(Setting, $key == setting_key) .get(db) .await .ok() .flatten() .map(|setting| setting.value == "true") .unwrap_or(false); let Some(config) = load_config(db).await else { return; }; if !enabled { return; } let client_id = visit.client_id.primary_key().unwrap(); let client = match query!(Client, $id == client_id).get(db).await { Ok(Some(client)) => client, _ => return, }; let active = "active".to_string(); let subscriptions = match query!(PushSubscription, $status == active).all(db).await { Ok(items) => items .into_iter() .filter(|item| item.client_id.primary_key().unwrap() == client_id) .collect::>(), Err(error) => { tracing::warn!(%error, "failed to load Web Push subscriptions"); return; } }; let mut visible_visits = match Visit::objects().all(db).await { Ok(visits) => visits .into_iter() .filter(|item| { item.client_id.primary_key().unwrap() == client_id && item.status != "cancelled" && item.status != "deleted" }) .collect::>(), Err(_) => Vec::new(), }; visible_visits.sort_by(|a, b| { b.visit_date .cmp(&a.visit_date) .then(b.time_start.cmp(&a.time_start)) }); let page = visible_visits .iter() .position(|item| item.id.unwrap() == visit.id.unwrap()) .map(|index| index / 10 + 1) .unwrap_or(1); for mut subscription in subscriptions { let is_ru = subscription.language == "ru"; let date = visit.visit_date.format("%d.%m.%Y"); let body = if is_ru { format!("Визит {date} завершён. Нажмите для просмотра медиа и комментариев.") } else { format!("Visit {date} is complete. Click to view media and comments.") }; let payload = json!({ "title": if is_ru { "Визит завершён" } else { "Visit completed" }, "body": body, "url": format!("/client/{}?page={}#visit-{}", client.media_token, page, visit.id.unwrap()), "tag": format!("visit-{}", visit.id.unwrap()), }); match send(&subscription, payload.to_string().into_bytes(), &config).await { Ok(status) if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE => { subscription.status = "archived".to_string(); subscription.updated_at = chrono::Utc::now().naive_utc(); if let Err(error) = subscription.save(db).await { tracing::warn!(%error, "failed to archive expired Web Push subscription"); } } Ok(status) if status.is_success() => {} Ok(status) => tracing::warn!(%status, "Web Push gateway rejected notification"), Err(error) => tracing::warn!(%error, "failed to send Web Push notification"), } } } async fn send( subscription: &PushSubscription, content: Vec, config: &VapidConfig, ) -> Result> { let private = URL_SAFE_NO_PAD.decode(&config.private_key)?; let key_pair = ES256KeyPair::from_bytes(&private)?; let p256dh = URL_SAFE_NO_PAD.decode(&subscription.p256dh)?; let auth = URL_SAFE_NO_PAD.decode(&subscription.auth)?; let builder = WebPushBuilder::new( subscription.endpoint.parse()?, PublicKey::from_sec1_bytes(&p256dh)?, Auth::clone_from_slice(&auth), ) .with_vapid(&key_pair, &config.subject); let request = builder.build(content)?; let (parts, body) = request.into_parts(); let response = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()? .request(parts.method, parts.uri.to_string()) .headers(parts.headers) .body(body) .send() .await?; Ok(response.status()) }