diff --git a/.gitignore b/.gitignore index b3982b7..754c4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -config.private.yaml \ No newline at end of file +config.private.yaml +storage diff --git a/Cargo.lock b/Cargo.lock index 0749853..1a2126d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,7 +121,7 @@ dependencies = [ "regex", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", "sha-1", "slab", "time 0.2.25", @@ -306,7 +306,7 @@ dependencies = [ "regex", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", "socket2", "time 0.2.25", "tinyvec", @@ -441,6 +441,36 @@ dependencies = [ "syn 1.0.60", ] +[[package]] +name = "attohttpc" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe174d1b67f7b2bafed829c09db039301eb5841f66e43be2cf60b326e7f8e2cc" +dependencies = [ + "flate2", + "http", + "log", + "url", +] + +[[package]] +name = "attohttpc" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb8867f378f33f78a811a8eb9bf108ad99430d7aad43315dd9319c827ef6247" +dependencies = [ + "flate2", + "http", + "log", + "rustls", + "serde", + "serde_urlencoded 0.6.1", + "url", + "webpki", + "webpki-roots", + "wildmatch", +] + [[package]] name = "autocfg" version = "0.1.7" @@ -474,7 +504,7 @@ dependencies = [ "rand 0.7.3", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", ] [[package]] @@ -768,6 +798,7 @@ dependencies = [ "dashmap", "encoding_rs", "futures", + "gouth", "image", "kamadak-exif", "lazy_static", @@ -1013,6 +1044,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dtoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" + [[package]] name = "either" version = "1.6.1" @@ -1235,6 +1272,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "gcemeta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b740806c16b381ca8d78cb3869fb47ce5b490db28c5f19bc0336a9b9aaca6e" +dependencies = [ + "attohttpc 0.15.0", + "lazy_static", + "serde_json", +] + [[package]] name = "generic-array" version = "0.12.3" @@ -1298,6 +1346,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +[[package]] +name = "gouth" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f60d0f7f508e9354fb3d9f734655e47ac525177e60dbdcb120ed482f58f385c" +dependencies = [ + "attohttpc 0.16.3", + "gcemeta", + "jsonwebtoken", + "serde", + "serde_json", + "url", +] + [[package]] name = "h2" version = "0.2.7" @@ -1590,6 +1652,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afabcc15e437a6484fc4f12d0fd63068fe457bf93f1c148d3d9649c60b103f32" +dependencies = [ + "base64 0.12.3", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kamadak-exif" version = "0.5.3" @@ -2712,7 +2788,7 @@ dependencies = [ "pin-project-lite 0.2.4", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", "tokio 1.2.0", "tokio-native-tls", "url", @@ -2732,6 +2808,21 @@ dependencies = [ "quick-error", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi 0.3.9", +] + [[package]] name = "rust_decimal" version = "1.10.3" @@ -2758,6 +2849,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1126dcf58e93cee7d098dbda643b5f92ed724f1f6a63007c1116eed6700c81" +dependencies = [ + "base64 0.12.3", + "log", + "ring", + "sct", + "webpki", +] + [[package]] name = "ryu" version = "1.0.5" @@ -2786,6 +2890,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.0.0" @@ -2855,6 +2969,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec5d77e2d4c73717816afac02670d5c4f534ea95ed430442cad02e7a6e32c97" +dependencies = [ + "dtoa", + "itoa", + "serde", + "url", +] + [[package]] name = "serde_urlencoded" version = "0.7.0" @@ -2920,6 +3046,17 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692ca13de57ce0613a363c8c2f1de925adebc81b04c923ac60c5488bb44abe4b" +dependencies = [ + "chrono", + "num-bigint 0.2.6", + "num-traits", +] + [[package]] name = "siphasher" version = "0.2.3" @@ -2970,6 +3107,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "standback" version = "0.2.15" @@ -3474,6 +3617,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.2.1" @@ -3659,6 +3808,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eff4b7516a57307f9349c64bf34caa34b940b66fed4b2fb3136cb7386e5739" +dependencies = [ + "webpki", +] + [[package]] name = "webrtc-sdp" version = "0.3.8" @@ -3681,6 +3849,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c" +[[package]] +name = "wildmatch" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f44b95f62d34113cf558c93511ac93027e03e9c29a60dd0fd70e6e025c7270a" + [[package]] name = "winapi" version = "0.2.8" diff --git a/Cargo.toml b/Cargo.toml index 1ca16b2..10669bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,4 +38,5 @@ bcrypt = "0.9.0" mp3-metadata = "0.3.3" mp4 = "0.8.1" zip = "0.5.10" -webpage = "1.2.0" \ No newline at end of file +webpage = "1.2.0" +gouth = "0.2.0" \ No newline at end of file diff --git a/src/constants.rs b/src/constants.rs index 421cc56..bd103fe 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -222,4 +222,7 @@ pub mod conversations { /// Conversation logo size pub const MAX_CONV_LOGO_WIDTH: u32 = 200; pub const MAX_CONV_LOGO_HEIGHT: u32 = 200; -} \ No newline at end of file +} + +/// Url where Firebase push notifications can be sent +pub const FIREBASE_PUSH_MESSAGE_URL: &str = "https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send"; \ No newline at end of file diff --git a/src/data/api_client.rs b/src/data/api_client.rs index 11f2124..1ea69c0 100644 --- a/src/data/api_client.rs +++ b/src/data/api_client.rs @@ -8,11 +8,13 @@ pub struct APIClient { pub domain: Option, pub comment: Option, pub default_expiration_time: u64, + pub firebase_project_name: Option, pub firebase_service_account_file: Option, } impl APIClient { pub fn is_firebase_available(&self) -> bool { - self.firebase_service_account_file.is_some() + self.firebase_project_name.is_some() && + self.firebase_service_account_file.is_some() } } \ No newline at end of file diff --git a/src/helpers/api_helper.rs b/src/helpers/api_helper.rs index bebdd52..8fb56af 100644 --- a/src/helpers/api_helper.rs +++ b/src/helpers/api_helper.rs @@ -23,6 +23,13 @@ pub fn get_by_origin(name: &str) -> ResultBoxError { .query_row(db_to_client) } +/// Get information about a client, based on its ID +pub fn get_by_id(id: u64) -> Res { + QueryInfo::new(CLIENTS_TABLE) + .cond_u64("id", id) + .query_row(db_to_client) +} + fn db_to_client(res: &database::RowResult) -> Res { Ok(APIClient { id: res.get_u64("id")?, @@ -30,6 +37,7 @@ fn db_to_client(res: &database::RowResult) -> Res { domain: res.get_optional_str("domain")?, comment: res.get_optional_str("comment")?, default_expiration_time: res.get_u64("default_expiration_time")?, + firebase_project_name: res.get_optional_str("firebase_project_name")?, firebase_service_account_file: res.get_optional_str("firebase_service_account_file")?, }) } \ No newline at end of file diff --git a/src/helpers/firebase_notifications_helper.rs b/src/helpers/firebase_notifications_helper.rs new file mode 100644 index 0000000..c21da91 --- /dev/null +++ b/src/helpers/firebase_notifications_helper.rs @@ -0,0 +1,145 @@ +//! # Firebase notifications helper +//! +//! Helper used to send notifications through the Firebase Cloud Messaging services +//! +//! @author Pierre Hubert + +use std::collections::HashMap; + +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::Serialize; + +use crate::constants::FIREBASE_PUSH_MESSAGE_URL; +use crate::data::api_client::APIClient; +use crate::data::error::{ExecError, Res}; +use crate::data::push_notification::PushNotification; +use crate::data::user_token::{PushNotificationToken, UserAccessToken}; +use crate::helpers::api_helper; + +struct FirebaseClientIdentifier { + project_name: String, + authorization_token: String, +} + +#[derive(Serialize)] +struct FirebaseNotification { + title: String, + body: String, + image: Option, +} + + +#[derive(Serialize)] +struct FirebaseAndroidNotification { + tag: String, +} + +#[derive(Serialize)] +struct FirebaseAndroid { + notification: FirebaseAndroidNotification, + ttl: Option, +} + +#[derive(Serialize)] +struct FirebaseMessage { + token: String, + notification: FirebaseNotification, + android: FirebaseAndroid, +} + +#[derive(Serialize)] +struct FirebaseNotificationRequest { + validate_only: bool, + message: FirebaseMessage, +} + +/// Get short-lived authorization token for a specific client +fn get_credentials(client: &APIClient) -> Res { + let service_account_file = match &client.firebase_service_account_file { + Some(file) => file, + None => { + return Err(ExecError::boxed_string(format!("No service account file for client {}!", client.name))); + } + }; + + let token = gouth::Builder::new() + .scopes(&["https://www.googleapis.com/auth/firebase.messaging"]) + .json(service_account_file) + .build()? + .header_value()? + .to_string(); + + Ok(token) +} + +/// Send a single notification through Firebase service +fn send_notification(n: &PushNotification, client_token: &str, access: &FirebaseClientIdentifier) -> Res { + let notif = FirebaseNotificationRequest { + validate_only: false, + message: FirebaseMessage { + token: client_token.to_string(), + notification: FirebaseNotification { + title: n.title.to_string(), + body: n.body.to_string(), + image: n.image.clone(), + }, + android: FirebaseAndroid { + notification: FirebaseAndroidNotification { + tag: n.id.to_string(), + }, + ttl: n.timeout.map(|t| format!("{}s", t)), + }, + }, + }; + + let mut headers = HeaderMap::new(); + headers.insert("Authorization", HeaderValue::from_str(&access.authorization_token)?); + let client = Client::builder().default_headers(headers).build()?; + + client + .post(&FIREBASE_PUSH_MESSAGE_URL.replace("{PROJECT_ID}", &access.project_name)) + .json(¬if) + .send()?; + + Ok(()) +} + +/// Send a notification +pub fn push_notifications(n: &PushNotification, targets: Vec) -> Res { + let mut tokens_cache: HashMap = HashMap::new(); + + for target in targets { + // Get an access token if required + if !tokens_cache.contains_key(&target.client_id) { + let client = api_helper::get_by_id(target.client_id)?; + tokens_cache.insert(target.client_id, FirebaseClientIdentifier { + authorization_token: get_credentials(&client)?, + project_name: client.firebase_project_name + .ok_or(ExecError::boxed_new("Missing firebase project name!"))?, + }); + } + + + let authorization_token = match tokens_cache.get(&target.client_id) { + None => { + return Err(ExecError::boxed_new("Should have a Firebase token now!!!")); + } + Some(token) => token + }; + + let client_token = match &target.push_notifications_token { + PushNotificationToken::FIREBASE(token) => token, + _ => { + return Err(ExecError::boxed_new("Invalid token!")); + } + }; + + + if let Err(e) = send_notification(n, client_token, authorization_token) { + eprintln!("Failed to send a push notification to a device! Error: {}", e); + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 0728c06..2357b5b 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -19,4 +19,5 @@ pub mod requests_limit_helper; pub mod events_helper; pub mod calls_helper; pub mod push_notifications_helper; -pub mod independent_push_notifications_service_helper; \ No newline at end of file +pub mod independent_push_notifications_service_helper; +pub mod firebase_notifications_helper; \ No newline at end of file diff --git a/src/helpers/push_notifications_helper.rs b/src/helpers/push_notifications_helper.rs index e2911cf..6921a7e 100644 --- a/src/helpers/push_notifications_helper.rs +++ b/src/helpers/push_notifications_helper.rs @@ -9,7 +9,7 @@ use crate::data::error::Res; use crate::data::push_notification::PushNotification; use crate::data::user::UserID; use crate::data::user_token::{PushNotificationToken, UserAccessToken}; -use crate::helpers::{account_helper, conversations_helper, independent_push_notifications_service_helper, user_helper}; +use crate::helpers::{account_helper, conversations_helper, independent_push_notifications_service_helper, user_helper, firebase_notifications_helper}; use crate::helpers::events_helper::Event; /// Un-register for previous push notifications service @@ -26,36 +26,47 @@ pub fn un_register_from_previous_service(client: &UserAccessToken) -> Res { } /// Split tokens in categories : (Independent, Firebase) -fn split_tokens(targets: Vec) -> (Vec) { +fn split_tokens(targets: Vec) -> (Vec, Vec) { let mut independent = vec![]; + let mut firebase = vec![]; for target in targets { match target.push_notifications_token { PushNotificationToken::INDEPENDENT(token) => { independent.push(token) } + + PushNotificationToken::FIREBASE(_) => { + firebase.push(target); + } + _ => {} } } - (independent) + (independent, firebase) } /// Push a notification to specific tokens fn push_notification(n: &PushNotification, targets: Vec) -> Res { - let independents = split_tokens(targets); + let (independents, firebase) = split_tokens(targets); // Push independent notifications if !independents.is_empty() { independent_push_notifications_service_helper::push_notifications(n, independents)?; } + // Push Firebase notifications + if !firebase.is_empty() { + firebase_notifications_helper::push_notifications(n, firebase)?; + } + Ok(()) } /// Cancel a notification for specific tokens (optional) fn cancel_notification(id: String, targets: Vec) -> Res { - let independents = split_tokens(targets); + let (independents, _) = split_tokens(targets); if !independents.is_empty() { independent_push_notifications_service_helper::cancel_notifications(id, independents)?;