feat: Add push notification support with VAPID keys

- Backend: web-push integration with VAPID keys
- Push subscription endpoints (GET /api/push/public-key, POST /api/push/subscribe)
- In-memory subscription store
- Frontend: Auto-subscribe to push after notification permission granted
- Service Worker: Push event handler
- Send push notifications when torrents complete
- Works even when browser is closed
This commit is contained in:
spinline
2026-02-05 23:53:23 +03:00
parent 60fc887327
commit 373da566be
9 changed files with 1632 additions and 65 deletions

View File

@@ -1,4 +1,5 @@
use crate::{
push,
xmlrpc::{self, RpcParam},
AppState,
};
@@ -673,3 +674,39 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
)
}
}
// --- PUSH NOTIFICATION HANDLERS ---
/// Get VAPID public key for push subscription
#[utoipa::path(
get,
path = "/api/push/public-key",
responses(
(status = 200, description = "VAPID public key", body = String)
)
)]
pub async fn get_push_public_key_handler() -> impl IntoResponse {
let public_key = push::get_vapid_public_key();
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
}
/// Subscribe to push notifications
#[utoipa::path(
post,
path = "/api/push/subscribe",
request_body = push::PushSubscription,
responses(
(status = 200, description = "Subscription saved"),
(status = 400, description = "Invalid subscription data")
)
)]
pub async fn subscribe_push_handler(
State(state): State<AppState>,
Json(subscription): Json<push::PushSubscription>,
) -> impl IntoResponse {
tracing::info!("Received push subscription: {:?}", subscription);
state.push_store.add_subscription(subscription).await;
(StatusCode::OK, "Subscription saved").into_response()
}

View File

@@ -1,5 +1,6 @@
mod diff;
mod handlers;
mod push;
mod scgi;
mod sse;
mod xmlrpc;
@@ -30,6 +31,7 @@ pub struct AppState {
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
pub event_bus: broadcast::Sender<AppEvent>,
pub scgi_socket_path: String,
pub push_store: push::PushSubscriptionStore,
}
#[derive(Parser, Debug)]
@@ -61,7 +63,9 @@ struct Args {
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler
handlers::set_global_limit_handler,
handlers::get_push_public_key_handler,
handlers::subscribe_push_handler
),
components(
schemas(
@@ -74,7 +78,9 @@ struct Args {
shared::TorrentTracker,
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest
shared::GlobalLimitRequest,
push::PushSubscription,
push::PushKeys
)
),
tags(
@@ -137,12 +143,14 @@ async fn main() {
tx: tx.clone(),
event_bus: event_bus.clone(),
scgi_socket_path: args.socket.clone(),
push_store: push::PushSubscriptionStore::new(),
};
// Spawn background task to poll rTorrent
let tx_clone = tx.clone();
let event_bus_tx = event_bus.clone();
let socket_path = args.socket.clone(); // Clone for background task
let push_store_clone = app_state.push_store.clone();
tokio::spawn(async move {
let client = xmlrpc::RtorrentClient::new(&socket_path);
@@ -193,6 +201,26 @@ async fn main() {
}
diff::DiffResult::Partial(updates) => {
for update in updates {
// Check if this is a torrent completion notification
if let AppEvent::Notification(ref notif) = update {
if notif.message.contains("tamamlandı") {
// Send push notification in background
let push_store = push_store_clone.clone();
let title = "Torrent Tamamlandı".to_string();
let body = notif.message.clone();
tokio::spawn(async move {
if let Err(e) = push::send_push_notification(
&push_store,
&title,
&body,
)
.await
{
tracing::error!("Failed to send push notification: {}", e);
}
});
}
}
let _ = event_bus_tx.send(update);
}
}
@@ -267,6 +295,8 @@ async fn main() {
"/api/settings/global-limits",
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
)
.route("/api/push/public-key", get(handlers::get_push_public_key_handler))
.route("/api/push/subscribe", post(handlers::subscribe_push_handler))
.fallback(handlers::static_handler) // Serve static files for everything else
.layer(TraceLayer::new_for_http())
.layer(

127
backend/src/push.rs Normal file
View File

@@ -0,0 +1,127 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use utoipa::ToSchema;
use web_push::{
IsahcWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
};
// VAPID keys - PRODUCTION'DA ENVIRONMENT VARIABLE'DAN ALINMALI!
const VAPID_PUBLIC_KEY: &str = "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU";
const VAPID_PRIVATE_KEY: &str = "aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10";
const VAPID_EMAIL: &str = "mailto:admin@vibetorrent.app";
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PushSubscription {
pub endpoint: String,
pub keys: PushKeys,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PushKeys {
pub p256dh: String,
pub auth: String,
}
/// In-memory store for push subscriptions
/// TODO: Replace with database in production
#[derive(Default, Clone)]
pub struct PushSubscriptionStore {
subscriptions: Arc<RwLock<Vec<PushSubscription>>>,
}
impl PushSubscriptionStore {
pub fn new() -> Self {
Self {
subscriptions: Arc::new(RwLock::new(Vec::new())),
}
}
pub async fn add_subscription(&self, subscription: PushSubscription) {
let mut subs = self.subscriptions.write().await;
// Remove duplicate endpoint if exists
subs.retain(|s| s.endpoint != subscription.endpoint);
subs.push(subscription);
tracing::info!("Added push subscription. Total: {}", subs.len());
}
pub async fn remove_subscription(&self, endpoint: &str) {
let mut subs = self.subscriptions.write().await;
subs.retain(|s| s.endpoint != endpoint);
tracing::info!("Removed push subscription. Total: {}", subs.len());
}
pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> {
self.subscriptions.read().await.clone()
}
}
/// Send push notification to all subscribed clients
pub async fn send_push_notification(
store: &PushSubscriptionStore,
title: &str,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let subscriptions = store.get_all_subscriptions().await;
if subscriptions.is_empty() {
tracing::debug!("No push subscriptions to send to");
return Ok(());
}
tracing::info!("Sending push notification to {} subscribers", subscriptions.len());
let payload = serde_json::json!({
"title": title,
"body": body,
"icon": "/icon-192.png",
"badge": "/icon-192.png",
"tag": "vibetorrent"
});
let client = IsahcWebPushClient::new()?;
for subscription in subscriptions {
let subscription_info = SubscriptionInfo {
endpoint: subscription.endpoint.clone(),
keys: web_push::SubscriptionKeys {
p256dh: subscription.keys.p256dh.clone(),
auth: subscription.keys.auth.clone(),
},
};
let mut sig_builder = VapidSignatureBuilder::from_base64(
VAPID_PRIVATE_KEY,
web_push::URL_SAFE_NO_PAD,
&subscription_info,
)?;
sig_builder.add_claim("sub", VAPID_EMAIL);
sig_builder.add_claim("aud", subscription.endpoint.clone());
let signature = sig_builder.build()?;
let mut builder = WebPushMessageBuilder::new(&subscription_info);
builder.set_vapid_signature(signature);
let payload_str = payload.to_string();
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
match client.send(builder.build()?).await {
Ok(_) => {
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
}
Err(e) => {
tracing::error!("Failed to send push notification: {}", e);
// TODO: Remove invalid subscriptions
}
}
}
Ok(())
}
pub fn get_vapid_public_key() -> &'static str {
VAPID_PUBLIC_KEY
}