//! Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): each //! notification an account is told also goes to its phones through Firebase //! Cloud Messaging, sealed to each phone's key (`flow_core::alert`), so the //! push service carries only ciphertext and a wake-up. //! //! The hub sends as the service account its VM runs as: the metadata //! server's token, kept until a minute before it expires. Off while //! `[push] fcm_project` is empty. use crate::db::push as pdb; use crate::Shared; use std::sync::Mutex; use std::time::{Duration, Instant}; /// Most phones one account's alert goes to. const MOST_DEVICES: usize = 20; static TOKEN: Mutex> = Mutex::new(None); /// What a phone is told, before it is sealed: small, as a message's data /// is at most 4 KB. #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] pub struct Alert { pub kind: String, pub title: String, pub url: String, } /// Whether alerts are on. pub fn enabled(app: &Shared) -> bool { !app.config.push.fcm_project.is_empty() } /// Send `alert` to every phone of `account_id`, in the background. pub fn send(app: &Shared, account_id: i64, alert: Alert) { if !enabled(app) { return; } let app = app.clone(); tokio::spawn(async move { if let Err(e) = send_now(&app, account_id, &alert).await { tracing::warn!("push: alert for account {account_id}: {e}"); } }); } async fn access_token(app: &Shared, client: &reqwest::Client) -> Result { if let Some((t, until)) = TOKEN.lock().unwrap().clone() { if Instant::now() < until { return Ok(t); } } #[derive(serde::Deserialize)] struct Got { access_token: String, expires_in: u64, } let got: Got = client .get(&app.config.push.token_url) .header("Metadata-Flavor", "Google") .send() .await .and_then(|r| r.error_for_status()) .map_err(|e| format!("the metadata server's token: {e}"))? .json() .await .map_err(|e| format!("the metadata server's token: {e}"))?; let until = Instant::now() + Duration::from_secs(got.expires_in.saturating_sub(60)); *TOKEN.lock().unwrap() = Some((got.access_token.clone(), until)); Ok(got.access_token) } /// Send `alert` to each of the account's phones now; a phone Cloud /// Messaging no longer knows is forgotten. pub async fn send_now(app: &Shared, account_id: i64, alert: &Alert) -> Result { let devices = pdb::of_account(&app.db, account_id).await.map_err(|e| e.to_string())?; if devices.is_empty() { return Ok(0); } let client = reqwest::Client::builder().timeout(Duration::from_secs(20)).build().map_err(|e| e.to_string())?; let token = access_token(app, &client).await?; let plain = serde_json::to_vec(alert).map_err(|e| e.to_string())?; let url = format!("{}/v1/projects/{}/messages:send", app.config.push.fcm_url.trim_end_matches('/'), app.config.push.fcm_project); let mut sent = 0; for d in devices.iter().take(MOST_DEVICES) { let Some(key) = hex::decode(&d.public_key).ok().and_then(|k| <[u8; 32]>::try_from(k).ok()) else { continue }; let Some(sealed) = flow_core::alert::seal(&key, &plain) else { continue }; // Data only, high priority: the app wakes, opens it and shows it; // the push service has no title or body to show or read. let body = serde_json::json!({ "message": { "token": d.fcm_token, "data": { "sealed": crate::oidc::b64url(&sealed) }, "android": { "priority": "high" }, }}); let r = client.post(&url).bearer_auth(&token).json(&body).send().await.map_err(|e| e.to_string())?; let status = r.status(); if status.is_success() { sent += 1; } else { let text = r.text().await.unwrap_or_default(); if status == reqwest::StatusCode::NOT_FOUND || text.contains("UNREGISTERED") { pdb::forget_fcm(&app.db, &d.fcm_token).await.ok(); } else { tracing::warn!("push: Cloud Messaging said {status}: {}", text.chars().take(200).collect::()); } } } Ok(sent) }