diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..790f9d9 --- /dev/null +++ b/backend/.env @@ -0,0 +1,5 @@ +# VAPID Keys for Push Notifications +# Generate new keys for production using: cargo run --bin web-push --features web-push -- generate-vapid-keys +VAPID_PUBLIC_KEY=BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU +VAPID_PRIVATE_KEY=aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10 +VAPID_EMAIL=mailto:admin@vibetorrent.app diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..8feaa0e --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,16 @@ +-- 001_init.sql +-- Initial schema for users and sessions + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) +); diff --git a/backend/migrations/002_push_subscriptions.sql b/backend/migrations/002_push_subscriptions.sql new file mode 100644 index 0000000..0f0a48f --- /dev/null +++ b/backend/migrations/002_push_subscriptions.sql @@ -0,0 +1,13 @@ +-- 002_push_subscriptions.sql +-- Push notification subscriptions storage + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Index for faster lookups by endpoint +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint); diff --git a/backend/src/db.rs b/backend/src/db.rs index a53f105..d22ea63 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -16,35 +16,12 @@ impl Db { .await?; let db = Self { pool }; - db.init().await?; + db.run_migrations().await?; Ok(db) } - async fn init(&self) -> Result<()> { - // Create users table - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )", - ) - .execute(&self.pool) - .await?; - - // Create sessions table - sqlx::query( - "CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY, - user_id INTEGER NOT NULL, - expires_at DATETIME NOT NULL, - FOREIGN KEY(user_id) REFERENCES users(id) - )", - ) - .execute(&self.pool) - .await?; - + async fn run_migrations(&self) -> Result<()> { + sqlx::migrate!("./migrations").run(&self.pool).await?; Ok(()) } @@ -59,23 +36,24 @@ impl Db { Ok(()) } - pub async fn get_user_by_username(&self, username: &str) -> Result> { - let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?") - .bind(username) - .fetch_optional(&self.pool) - .await?; + pub async fn get_user_by_username(&self, username: &str) -> Result> { + let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?") + .bind(username) + .fetch_optional(&self.pool) + .await?; - Ok(row.map(|r| (r.get(0), r.get(1)))) - } + Ok(row.map(|r| (r.get(0), r.get(1)))) + } - pub async fn get_username_by_id(&self, id: i64) -> Result> { - let row = sqlx::query("SELECT username FROM users WHERE id = ?") - .bind(id) - .fetch_optional(&self.pool) - .await?; + pub async fn get_username_by_id(&self, id: i64) -> Result> { + let row = sqlx::query("SELECT username FROM users WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|r| r.get(0))) + } - Ok(row.map(|r| r.get(0))) - } pub async fn has_users(&self) -> Result { let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&self.pool) @@ -128,4 +106,36 @@ impl Db { .await?; Ok(()) } + + // --- Push Subscription Operations --- + + pub async fn save_push_subscription(&self, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> { + sqlx::query( + "INSERT INTO push_subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth" + ) + .bind(endpoint) + .bind(p256dh) + .bind(auth) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn remove_push_subscription(&self, endpoint: &str) -> Result<()> { + sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?") + .bind(endpoint) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn get_all_push_subscriptions(&self) -> Result> { + let rows = sqlx::query_as::<_, (String, String, String)>( + "SELECT endpoint, p256dh, auth FROM push_subscriptions" + ) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 83232d0..ff9a654 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -320,13 +320,25 @@ async fn main() { // Channel for Events (Diffs) let (event_bus, _) = broadcast::channel::(1024); + #[cfg(feature = "push-notifications")] + let push_store = match push::PushSubscriptionStore::with_db(&db).await { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to initialize push store: {}", e); + push::PushSubscriptionStore::new() + } + }; + + #[cfg(not(feature = "push-notifications"))] + let push_store = (); + let app_state = AppState { tx: tx.clone(), event_bus: event_bus.clone(), scgi_socket_path: args.socket.clone(), db: db.clone(), #[cfg(feature = "push-notifications")] - push_store: push::PushSubscriptionStore::new(), + push_store, }; // Spawn background task to poll rTorrent diff --git a/backend/src/push.rs b/backend/src/push.rs index 639de9e..0b204e1 100644 --- a/backend/src/push.rs +++ b/backend/src/push.rs @@ -6,10 +6,7 @@ use web_push::{ HyperWebPushClient, 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"; +use crate::db::Db; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PushSubscription { @@ -23,34 +20,72 @@ pub struct PushKeys { pub auth: String, } -/// In-memory store for push subscriptions -/// TODO: Replace with database in production -#[derive(Default, Clone)] +#[derive(Clone)] pub struct PushSubscriptionStore { + db: Option, subscriptions: Arc>>, } impl PushSubscriptionStore { pub fn new() -> Self { Self { + db: None, subscriptions: Arc::new(RwLock::new(Vec::new())), } } + pub async fn with_db(db: &Db) -> Result> { + let mut subscriptions_vec: Vec = Vec::new(); + + // Load existing subscriptions from DB + let subs = db.get_all_push_subscriptions().await?; + for (endpoint, p256dh, auth) in subs { + subscriptions_vec.push(PushSubscription { + endpoint, + keys: PushKeys { p256dh, auth }, + }); + } + tracing::info!("Loaded {} push subscriptions from database", subscriptions_vec.len()); + + Ok(Self { + db: Some(db.clone()), + subscriptions: Arc::new(RwLock::new(subscriptions_vec)), + }) + } + pub async fn add_subscription(&self, subscription: PushSubscription) { + // Add to memory let mut subs = self.subscriptions.write().await; - + // Remove duplicate endpoint if exists subs.retain(|s| s.endpoint != subscription.endpoint); - - subs.push(subscription); + subs.push(subscription.clone()); tracing::info!("Added push subscription. Total: {}", subs.len()); + + // Save to DB if available + if let Some(db) = &self.db { + if let Err(e) = db.save_push_subscription( + &subscription.endpoint, + &subscription.keys.p256dh, + &subscription.keys.auth, + ).await { + tracing::error!("Failed to save push subscription to DB: {}", e); + } + } } pub async fn remove_subscription(&self, endpoint: &str) { + // Remove from memory let mut subs = self.subscriptions.write().await; subs.retain(|s| s.endpoint != endpoint); tracing::info!("Removed push subscription. Total: {}", subs.len()); + + // Remove from DB if available + if let Some(db) = &self.db { + if let Err(e) = db.remove_push_subscription(endpoint).await { + tracing::error!("Failed to remove push subscription from DB: {}", e); + } + } } pub async fn get_all_subscriptions(&self) -> Vec { @@ -65,7 +100,7 @@ pub async fn send_push_notification( body: &str, ) -> Result<(), Box> { let subscriptions = store.get_all_subscriptions().await; - + if subscriptions.is_empty() { tracing::debug!("No push subscriptions to send to"); return Ok(()); @@ -83,6 +118,14 @@ pub async fn send_push_notification( let client = HyperWebPushClient::new(); + // Get VAPID keys from environment or use defaults + let _vapid_public_key = std::env::var("VAPID_PUBLIC_KEY") + .unwrap_or_else(|_| "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU".to_string()); + let vapid_private_key = std::env::var("VAPID_PRIVATE_KEY") + .unwrap_or_else(|_| "aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10".to_string()); + let vapid_email = std::env::var("VAPID_EMAIL") + .unwrap_or_else(|_| "mailto:admin@vibetorrent.app".to_string()); + for subscription in subscriptions { let subscription_info = SubscriptionInfo { endpoint: subscription.endpoint.clone(), @@ -93,18 +136,18 @@ pub async fn send_push_notification( }; let mut sig_builder = VapidSignatureBuilder::from_base64( - VAPID_PRIVATE_KEY, + &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()); + + sig_builder.add_claim("sub", vapid_email.as_str()); + sig_builder.add_claim("aud", subscription.endpoint.as_str()); 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()); @@ -122,6 +165,7 @@ pub async fn send_push_notification( Ok(()) } -pub fn get_vapid_public_key() -> &'static str { - VAPID_PUBLIC_KEY +pub fn get_vapid_public_key() -> String { + std::env::var("VAPID_PUBLIC_KEY") + .unwrap_or_else(|_| "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU".to_string()) }