From c51f37e6eaf47c47341c533e78948f199aaa8b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Sat, 3 Aug 2024 20:02:33 -0700 Subject: [PATCH] Push notification preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements basic push notification preferences as well as the interface to change them. Furthermore, the API interface was reworked to follow better REST API conventions. Testing -------- PASS Device: iPhone 15 simulators iOS: 17.5 Damus: 4ea6c360e6e33747cb09ecf085049948ec1dadd1 (A commit from GH issue #2360) notepush: This commit Steps: 1. Disable all types of notifications, except for DMs. 2. Send a like to this user's post. Push notification should not appear. PASS 3. Send a DM to this user's post. Push notification should appear. PASS Signed-off-by: Daniel D’Aquino --- Cargo.lock | 4 +- src/api_request_handler.rs | 345 ++++++++++++++---- .../notification_manager.rs | 110 +++++- 3 files changed, 391 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca5e842..d971bf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.5" +version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", diff --git a/src/api_request_handler.rs b/src/api_request_handler.rs index 292bb1d..b55f646 100644 --- a/src/api_request_handler.rs +++ b/src/api_request_handler.rs @@ -1,4 +1,5 @@ use crate::nip98_auth; +use crate::notification_manager::notification_manager::UserNotificationSettings; use crate::relay_connection::RelayConnection; use http_body_util::Full; use hyper::body::Buf; @@ -9,50 +10,21 @@ use hyper_tungstenite; use http_body_util::BodyExt; use nostr; +use serde_json::from_value; use crate::notification_manager::NotificationManager; use hyper::Method; use log; use serde_json::{json, Value}; +use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; -struct ParsedRequest { - uri: String, - method: Method, - body_bytes: Option>, - authorized_pubkey: nostr::PublicKey, -} - -impl ParsedRequest { - fn body_json(&self) -> Result> { - if let Some(body_bytes) = &self.body_bytes { - Ok(serde_json::from_slice(body_bytes)?) - } else { - Ok(json!({})) - } - } -} - -struct APIResponse { - status: StatusCode, - body: Value, -} - pub struct APIHandler { notification_manager: Arc, base_url: String, } -impl Clone for APIHandler { - fn clone(&self) -> Self { - APIHandler { - notification_manager: self.notification_manager.clone(), - base_url: self.base_url.clone(), - } - } -} - impl APIHandler { pub fn new(notification_manager: Arc, base_url: String) -> Self { APIHandler { @@ -60,6 +32,8 @@ impl APIHandler { base_url, } } + + // MARK: - HTTP handling pub async fn handle_http_request( &self, @@ -185,22 +159,37 @@ impl APIHandler { authorized_pubkey, }) } + + // MARK: - Router async fn handle_parsed_http_request( &self, parsed_request: &ParsedRequest, ) -> Result> { - match (&parsed_request.method, parsed_request.uri.as_str()) { - (&Method::POST, "/user-info") => self.handle_user_info(parsed_request).await, - (&Method::POST, "/user-info/remove") => { - self.handle_user_info_remove(parsed_request).await - } - _ => Ok(APIResponse { - status: StatusCode::NOT_FOUND, - body: json!({ "error": "Not found" }), - }), + + if let Some(url_params) = route_match(&Method::PUT, "/user-info/:pubkey/:deviceToken", &parsed_request) { + return self.handle_user_info(parsed_request, &url_params).await; } + + if let Some(url_params) = route_match(&Method::DELETE, "/user-info/:pubkey/:deviceToken", &parsed_request) { + return self.handle_user_info_remove(parsed_request, &url_params).await; + } + + if let Some(url_params) = route_match(&Method::GET, "/user-info/:pubkey/:deviceToken/preferences", &parsed_request) { + return self.get_user_settings(parsed_request, &url_params).await; + } + + if let Some(url_params) = route_match(&Method::PUT, "/user-info/:pubkey/:deviceToken/preferences", &parsed_request) { + return self.set_user_settings(parsed_request, &url_params).await; + } + + Ok(APIResponse { + status: StatusCode::NOT_FOUND, + body: json!({ "error": "Not found" }), + }) } + + // MARK: - Authentication async fn authenticate( &self, @@ -220,51 +209,283 @@ impl APIHandler { ) .await) } + + // MARK: - Endpoint handlers async fn handle_user_info( &self, req: &ParsedRequest, + url_params: &HashMap<&str, String>, ) -> Result> { - let body = req.body_json()?; - - if let Some(device_token) = body["deviceToken"].as_str() { - self.notification_manager.save_user_device_info(req.authorized_pubkey, device_token).await?; - return Ok(APIResponse { - status: StatusCode::OK, - body: json!({ "message": "User info saved successfully" }), - }); - } else { - return Ok(APIResponse { + // Early return if `deviceToken` is missing + let device_token = match url_params.get("deviceToken") { + Some(token) => token, + None => return Ok(APIResponse { status: StatusCode::BAD_REQUEST, - body: json!({ "error": "deviceToken is required" }), + body: json!({ "error": "deviceToken is required on the URL" }), + }), + }; + + // Early return if `pubkey` is missing + let pubkey = match url_params.get("pubkey") { + Some(key) => key, + None => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "pubkey is required on the URL" }), + }), + }; + + // Validate the `pubkey` and prepare it for use + let pubkey = match nostr::PublicKey::from_hex(pubkey) { + Ok(key) => key, + Err(_) => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "Invalid pubkey" }), + }), + }; + + // Early return if `pubkey` does not match `req.authorized_pubkey` + if pubkey != req.authorized_pubkey { + return Ok(APIResponse { + status: StatusCode::FORBIDDEN, + body: json!({ "error": "Forbidden" }), }); } + + // Proceed with the main logic after passing all checks + self.notification_manager.save_user_device_info(pubkey, device_token).await?; + Ok(APIResponse { + status: StatusCode::OK, + body: json!({ "message": "User info saved successfully" }), + }) } async fn handle_user_info_remove( &self, req: &ParsedRequest, + url_params: &HashMap<&str, String>, ) -> Result> { - let body: Value = req.body_json()?; - - if let Some(device_token) = body["deviceToken"].as_str() { - self.notification_manager.remove_user_device_info(req.authorized_pubkey, device_token).await?; - return Ok(APIResponse { - status: StatusCode::OK, - body: json!({ "message": "User info removed successfully" }), - }); - } else { - return Ok(APIResponse { + // Early return if `deviceToken` is missing + let device_token = match url_params.get("deviceToken") { + Some(token) => token, + None => return Ok(APIResponse { status: StatusCode::BAD_REQUEST, - body: json!({ "error": "deviceToken is required" }), + body: json!({ "error": "deviceToken is required on the URL" }), + }), + }; + + // Early return if `pubkey` is missing + let pubkey = match url_params.get("pubkey") { + Some(key) => key, + None => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "pubkey is required on the URL" }), + }), + }; + + // Validate the `pubkey` and prepare it for use + let pubkey = match nostr::PublicKey::from_hex(pubkey) { + Ok(key) => key, + Err(_) => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "Invalid pubkey" }), + }), + }; + + // Early return if `pubkey` does not match `req.authorized_pubkey` + if pubkey != req.authorized_pubkey { + return Ok(APIResponse { + status: StatusCode::FORBIDDEN, + body: json!({ "error": "Forbidden" }), }); } + + // Proceed with the main logic after passing all checks + self.notification_manager.remove_user_device_info(pubkey, device_token).await?; + + Ok(APIResponse { + status: StatusCode::OK, + body: json!({ "message": "User info removed successfully" }), + }) + } + + async fn set_user_settings( + &self, + req: &ParsedRequest, + url_params: &HashMap<&str, String>, + ) -> Result> { + // Early return if `deviceToken` is missing + let device_token = match url_params.get("deviceToken") { + Some(token) => token, + None => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "deviceToken is required on the URL" }), + }), + }; + + // Early return if `pubkey` is missing + let pubkey = match url_params.get("pubkey") { + Some(key) => key, + None => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "pubkey is required on the URL" }), + }), + }; + + // Validate the `pubkey` and prepare it for use + let pubkey = match nostr::PublicKey::from_hex(pubkey) { + Ok(key) => key, + Err(_) => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "Invalid pubkey" }), + }), + }; + + // Early return if `pubkey` does not match `req.authorized_pubkey` + if pubkey != req.authorized_pubkey { + return Ok(APIResponse { + status: StatusCode::FORBIDDEN, + body: json!({ "error": "Forbidden" }), + }); + } + + // Proceed with the main logic after passing all checks + let body = req.body_json()?; + + let settings: UserNotificationSettings = match from_value(body.clone()) { + Ok(settings) => settings, + Err(_) => { + return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "Invalid settings" }), + }); + } + }; + + self.notification_manager.save_user_notification_settings(&req.authorized_pubkey, device_token.to_string(), settings).await?; + return Ok(APIResponse { + status: StatusCode::OK, + body: json!({ "message": "User settings saved successfully" }), + }); + } + + async fn get_user_settings( + &self, + req: &ParsedRequest, + url_params: &HashMap<&str, String>, + ) -> Result> { + // Early return if `deviceToken` is missing + let device_token = match url_params.get("deviceToken") { + Some(token) => token, + None => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "deviceToken is required on the URL" }), + }), + }; + + // Early return if `pubkey` is missing + let pubkey = match url_params.get("pubkey") { + Some(key) => key, + None => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "pubkey is required on the URL" }), + }), + }; + + // Validate the `pubkey` and prepare it for use + let pubkey = match nostr::PublicKey::from_hex(pubkey) { + Ok(key) => key, + Err(_) => return Ok(APIResponse { + status: StatusCode::BAD_REQUEST, + body: json!({ "error": "Invalid pubkey" }), + }), + }; + + // Early return if `pubkey` does not match `req.authorized_pubkey` + if pubkey != req.authorized_pubkey { + return Ok(APIResponse { + status: StatusCode::FORBIDDEN, + body: json!({ "error": "Forbidden" }), + }); + } + + // Proceed with the main logic after passing all checks + let settings = self.notification_manager.get_user_notification_settings(&req.authorized_pubkey, device_token.to_string()).await?; + + Ok(APIResponse { + status: StatusCode::OK, + body: json!(settings), + }) + } +} + +// MARK: - Extensions + +impl Clone for APIHandler { + fn clone(&self) -> Self { + APIHandler { + notification_manager: self.notification_manager.clone(), + base_url: self.base_url.clone(), + } } } +// MARK: - Helper types + // Define enum error types including authentication error #[derive(Debug, Error)] enum APIError { #[error("Authentication error: {0}")] AuthenticationError(String), } + +struct ParsedRequest { + uri: String, + method: Method, + body_bytes: Option>, + authorized_pubkey: nostr::PublicKey, +} + +impl ParsedRequest { + fn body_json(&self) -> Result> { + if let Some(body_bytes) = &self.body_bytes { + Ok(serde_json::from_slice(body_bytes)?) + } else { + Ok(json!({})) + } + } +} + +struct APIResponse { + status: StatusCode, + body: Value, +} + +// MARK: - Helper functions + +/// Matches the request to a specified route, returning a hashmap of the route parameters +/// e.g. GET /user/:id/info route against request GET /user/123/info matches to { "id": "123" } +fn route_match<'a>(method: &Method, path: &'a str, req: &ParsedRequest) -> Option> { + if method != req.method { + return None; + } + let mut params = HashMap::new(); + let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let req_segments: Vec<&str> = req.uri.split('/').filter(|s| !s.is_empty()).collect(); + + if path_segments.len() != req_segments.len() { + return None; + } + + for (i, segment) in path_segments.iter().enumerate() { + if segment.starts_with(':') { + let key = &segment[1..]; + let value = req_segments[i].to_string(); + params.insert(key, value); + } else if segment != &req_segments[i] { + return None; + } + } + + Some(params) +} diff --git a/src/notification_manager/notification_manager.rs b/src/notification_manager/notification_manager.rs index 8583de7..4babe9b 100644 --- a/src/notification_manager/notification_manager.rs +++ b/src/notification_manager/notification_manager.rs @@ -4,8 +4,11 @@ use nostr::event::EventId; use nostr::key::PublicKey; use nostr::types::Timestamp; use nostr_sdk::JsonUtil; +use nostr_sdk::Kind; use rusqlite; use rusqlite::params; +use serde::Deserialize; +use serde::Serialize; use tokio::sync::Mutex; use std::collections::HashSet; use tokio; @@ -65,6 +68,8 @@ impl NotificationManager { // MARK: - Database setup operations pub fn setup_database(db: &rusqlite::Connection) -> Result<(), rusqlite::Error> { + // Initial schema setup + db.execute( "CREATE TABLE IF NOT EXISTS notifications ( id TEXT PRIMARY KEY, @@ -94,8 +99,17 @@ impl NotificationManager { [], )?; - Self::add_column_if_not_exists(&db, "notifications", "sent_at", "INTEGER")?; - Self::add_column_if_not_exists(&db, "user_info", "added_at", "INTEGER")?; + Self::add_column_if_not_exists(&db, "notifications", "sent_at", "INTEGER", None)?; + Self::add_column_if_not_exists(&db, "user_info", "added_at", "INTEGER", None)?; + + // Notification settings migration (https://github.com/damus-io/damus/issues/2360) + + Self::add_column_if_not_exists(&db, "user_info", "zap_notifications_enabled", "BOOLEAN", Some("true"))?; + Self::add_column_if_not_exists(&db, "user_info", "mention_notifications_enabled", "BOOLEAN", Some("true"))?; + Self::add_column_if_not_exists(&db, "user_info", "repost_notifications_enabled", "BOOLEAN", Some("true"))?; + Self::add_column_if_not_exists(&db, "user_info", "reaction_notifications_enabled", "BOOLEAN", Some("true"))?; + Self::add_column_if_not_exists(&db, "user_info", "dm_notifications_enabled", "BOOLEAN", Some("true"))?; + Self::add_column_if_not_exists(&db, "user_info", "only_notifications_from_following_enabled", "BOOLEAN", Some("false"))?; Ok(()) } @@ -105,6 +119,7 @@ impl NotificationManager { table_name: &str, column_name: &str, column_type: &str, + default_value: Option<&str>, ) -> Result<(), rusqlite::Error> { let query = format!("PRAGMA table_info({})", table_name); let mut stmt = db.prepare(&query)?; @@ -115,8 +130,11 @@ impl NotificationManager { if !column_names.contains(&column_name.to_string()) { let query = format!( - "ALTER TABLE {} ADD COLUMN {} {}", - table_name, column_name, column_type + "ALTER TABLE {} ADD COLUMN {} {} {}", + table_name, column_name, column_type, match default_value { + Some(value) => format!("DEFAULT {}", value), + None => "".to_string(), + }, ); db.execute(&query, [])?; } @@ -251,11 +269,34 @@ impl NotificationManager { ) -> Result<(), Box> { let user_device_tokens = self.get_user_device_tokens(pubkey).await?; for device_token in user_device_tokens { + if !self.user_wants_notification(pubkey, device_token.clone(), event).await? { + continue; + } self.send_event_notification_to_device_token(event, &device_token) .await?; } Ok(()) } + + async fn user_wants_notification( + &self, + pubkey: &PublicKey, + device_token: String, + event: &Event, + ) -> Result> { + let notification_preferences = self.get_user_notification_settings(pubkey, device_token).await?; + match event.kind { + Kind::TextNote => Ok(notification_preferences.mention_notifications_enabled), // TODO: Not 100% accurate + Kind::EncryptedDirectMessage => Ok(notification_preferences.dm_notifications_enabled), + Kind::Repost => Ok(notification_preferences.repost_notifications_enabled), + Kind::GenericRepost => Ok(notification_preferences.repost_notifications_enabled), + Kind::Reaction => Ok(notification_preferences.reaction_notifications_enabled), + Kind::ZapPrivateMessage => Ok(notification_preferences.zap_notifications_enabled), + Kind::ZapRequest => Ok(notification_preferences.zap_notifications_enabled), + Kind::ZapReceipt => Ok(notification_preferences.zap_notifications_enabled), + _ => Ok(false), + } + } async fn get_user_device_tokens( &self, @@ -343,6 +384,8 @@ impl NotificationManager { }; (title, "".to_string(), body) } + + // MARK: - User device info and settings pub async fn save_user_device_info( &self, @@ -375,6 +418,65 @@ impl NotificationManager { )?; Ok(()) } + + pub async fn get_user_notification_settings( + &self, + pubkey: &PublicKey, + device_token: String, + ) -> Result> { + let db_mutex_guard = self.db.lock().await; + let connection = db_mutex_guard.get()?; + let mut stmt = connection.prepare( + "SELECT zap_notifications_enabled, mention_notifications_enabled, repost_notifications_enabled, reaction_notifications_enabled, dm_notifications_enabled, only_notifications_from_following_enabled FROM user_info WHERE pubkey = ? AND device_token = ?", + )?; + let settings = stmt + .query_row([pubkey.to_sql_string(), device_token], |row| { + Ok(UserNotificationSettings { + zap_notifications_enabled: row.get(0)?, + mention_notifications_enabled: row.get(1)?, + repost_notifications_enabled: row.get(2)?, + reaction_notifications_enabled: row.get(3)?, + dm_notifications_enabled: row.get(4)?, + only_notifications_from_following_enabled: row.get(5)?, + }) + })?; + + Ok(settings) + } + + pub async fn save_user_notification_settings( + &self, + pubkey: &PublicKey, + device_token: String, + settings: UserNotificationSettings, + ) -> Result<(), Box> { + let db_mutex_guard = self.db.lock().await; + let connection = db_mutex_guard.get()?; + connection.execute( + "UPDATE user_info SET zap_notifications_enabled = ?, mention_notifications_enabled = ?, repost_notifications_enabled = ?, reaction_notifications_enabled = ?, dm_notifications_enabled = ?, only_notifications_from_following_enabled = ? WHERE pubkey = ? AND device_token = ?", + params![ + settings.zap_notifications_enabled, + settings.mention_notifications_enabled, + settings.repost_notifications_enabled, + settings.reaction_notifications_enabled, + settings.dm_notifications_enabled, + settings.only_notifications_from_following_enabled, + pubkey.to_sql_string(), + device_token, + ], + )?; + Ok(()) + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UserNotificationSettings { + zap_notifications_enabled: bool, + mention_notifications_enabled: bool, + repost_notifications_enabled: bool, + reaction_notifications_enabled: bool, + dm_notifications_enabled: bool, + only_notifications_from_following_enabled: bool } struct NotificationStatus {