Files
web-petting/src/web_push.rs
T
Ultradesu d6e6075469
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
Fixed notifications VAPID
2026-08-08 11:21:23 +01:00

179 lines
6.4 KiB
Rust

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<VapidConfig> {
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::<Vec<_>>(),
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::<Vec<_>>(),
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<u8>,
config: &VapidConfig,
) -> Result<reqwest::StatusCode, Box<dyn std::error::Error + Send + Sync>> {
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())
}