179 lines
6.4 KiB
Rust
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())
|
|
}
|