OXYGENHUB

Coverage / crates/hub/src/push.rs 86.3%

63 of 73 lines run, 9 of 30 functions · run #537 on main · run missed

1
//! Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): each
2
//! notification an account is told also goes to its phones through Firebase
3
//! Cloud Messaging, sealed to each phone's key (`flow_core::alert`), so the
4
//! push service carries only ciphertext and a wake-up.
5
//!
6
//! The hub sends as the service account its VM runs as: the metadata
7
//! server's token, kept until a minute before it expires. Off while
8
//! `[push] fcm_project` is empty.
9
10
use crate::db::push as pdb;
11
use crate::Shared;
12
use std::sync::Mutex;
13
use std::time::{Duration, Instant};
14
15
/// Most phones one account's alert goes to.
16
const MOST_DEVICES: usize = 20;
17
18
static TOKEN: Mutex<Option<(String, Instant)>> = Mutex::new(None);
19
20
/// What a phone is told, before it is sealed: small, as a message's data
21
/// is at most 4 KB.
22
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
23
pub struct Alert {
24
    pub kind: String,
25
    pub title: String,
26
    pub url: String,
27
}
28
29
/// Whether alerts are on.
30112×
pub fn enabled(app: &Shared) -> bool {
31112×
    !app.config.push.fcm_project.is_empty()
32112×
}
33
34
/// Send `alert` to every phone of `account_id`, in the background.
35109×
pub fn send(app: &Shared, account_id: i64, alert: Alert) {
36109×
    if !enabled(app) {
37108×
        return;
381×
    }
391×
    let app = app.clone();
401×
    tokio::spawn(async move {
411×
        if let Err(e) = send_now(&app, account_id, &alert).await {
420×
            tracing::warn!("push: alert for account {account_id}: {e}");
431×
        }
441×
    });
45109×
}
46
472×
async fn access_token(app: &Shared, client: &reqwest::Client) -> Result<String, String> {
482×
    if let Some((t, until)) = TOKEN.lock().unwrap().clone() {
491×
        if Instant::now() < until {
501×
            return Ok(t);
510×
        }
521×
    }
53
    #[derive(serde::Deserialize)]
54
    struct Got {
55
        access_token: String,
56
        expires_in: u64,
57
    }
581×
    let got: Got = client
591×
        .get(&app.config.push.token_url)
601×
        .header("Metadata-Flavor", "Google")
611×
        .send()
621×
        .await
631×
        .and_then(|r| r.error_for_status())
641×
        .map_err(|e| format!("the metadata server's token: {e}"))?
651×
        .json()
661×
        .await
671×
        .map_err(|e| format!("the metadata server's token: {e}"))?;
681×
    let until = Instant::now() + Duration::from_secs(got.expires_in.saturating_sub(60));
691×
    *TOKEN.lock().unwrap() = Some((got.access_token.clone(), until));
701×
    Ok(got.access_token)
712×
}
72
73
/// Send `alert` to each of the account's phones now; a phone Cloud
74
/// Messaging no longer knows is forgotten.
752×
pub async fn send_now(app: &Shared, account_id: i64, alert: &Alert) -> Result<usize, String> {
762×
    let devices = pdb::of_account(&app.db, account_id).await.map_err(|e| e.to_string())?;
772×
    if devices.is_empty() {
780×
        return Ok(0);
792×
    }
802×
    let client = reqwest::Client::builder().timeout(Duration::from_secs(20)).build().map_err(|e| e.to_string())?;
812×
    let token = access_token(app, &client).await?;
822×
    let plain = serde_json::to_vec(alert).map_err(|e| e.to_string())?;
832×
    let url = format!("{}/v1/projects/{}/messages:send", app.config.push.fcm_url.trim_end_matches('/'), app.config.push.fcm_project);
842×
    let mut sent = 0;
853×
    for d in devices.iter().take(MOST_DEVICES) {
863×
        let Some(key) = hex::decode(&d.public_key).ok().and_then(|k| <[u8; 32]>::try_from(k).ok()) else { continue };
873×
        let Some(sealed) = flow_core::alert::seal(&key, &plain) else { continue };
88
        // Data only, high priority: the app wakes, opens it and shows it;
89
        // the push service has no title or body to show or read.
903×
        let body = serde_json::json!({ "message": {
913×
            "token": d.fcm_token,
923×
            "data": { "sealed": crate::oidc::b64url(&sealed) },
933×
            "android": { "priority": "high" },
94
        }});
953×
        let r = client.post(&url).bearer_auth(&token).json(&body).send().await.map_err(|e| e.to_string())?;
963×
        let status = r.status();
973×
        if status.is_success() {
982×
            sent += 1;
992×
        } else {
1001×
            let text = r.text().await.unwrap_or_default();
1011×
            if status == reqwest::StatusCode::NOT_FOUND || text.contains("UNREGISTERED") {
1021×
                pdb::forget_fcm(&app.db, &d.fcm_token).await.ok();
103
            } else {
1040×
                tracing::warn!("push: Cloud Messaging said {status}: {}", text.chars().take(200).collect::<String>());
105
            }
106
        }
107
    }
1082×
    Ok(sent)
1092×
}