1use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11use base64::Engine as _;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
13use p256::ecdsa::signature::Signer;
14use p256::ecdsa::{Signature, SigningKey};
15use p256::pkcs8::DecodePrivateKey;
16use serde_json::{Value, json};
17use tracing::{debug, warn};
18
19use crate::app::Ctx;
20use crate::config::Config;
21
22const TOKEN_LIFE: Duration = Duration::from_mins(50);
24
25pub struct Apns {
27 key: SigningKey,
28 key_id: String,
29 team_id: String,
30 topic: String,
31 http: reqwest::Client,
32 token: Mutex<Option<(String, Instant)>>,
33}
34
35impl Apns {
36 pub fn from_config(cfg: &Config) -> Result<Option<Arc<Apns>>> {
38 let (Some(key), Some(key_id), Some(team_id)) = (&cfg.apns_key, &cfg.apns_key_id, &cfg.apns_team_id)
39 else {
40 return Ok(None);
41 };
42 let pem = if key.contains("BEGIN PRIVATE KEY") {
43 key.clone()
44 } else {
45 std::fs::read_to_string(key).with_context(|| format!("reading CHESS_APNS_KEY {key}"))?
46 };
47 let key = SigningKey::from_pkcs8_pem(&pem).map_err(|e| anyhow::anyhow!("CHESS_APNS_KEY: {e}"))?;
48 let http = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
49 Ok(Some(Arc::new(Apns {
50 key,
51 key_id: key_id.clone(),
52 team_id: team_id.clone(),
53 topic: cfg.apns_topic.clone(),
54 http,
55 token: Mutex::new(None),
56 })))
57 }
58
59 fn token(&self) -> String {
60 let mut t = self.token.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
61 if let Some((tok, at)) = t.as_ref()
62 && at.elapsed() < TOKEN_LIFE
63 {
64 return tok.clone();
65 }
66 let tok = provider_token(&self.key, &self.key_id, &self.team_id, chrono::Utc::now().timestamp());
67 *t = Some((tok.clone(), Instant::now()));
68 tok
69 }
70
71 async fn send(
73 &self,
74 device: &str,
75 sandbox: bool,
76 payload: &Value,
77 collapse: Option<&str>,
78 ) -> Result<bool> {
79 let host = if sandbox { "api.sandbox.push.apple.com" } else { "api.push.apple.com" };
80 let mut req = self
81 .http
82 .post(format!("https://{host}/3/device/{device}"))
83 .bearer_auth(self.token())
84 .header("apns-topic", &self.topic)
85 .header("apns-push-type", "alert")
86 .header("apns-priority", "10")
87 .json(payload);
88 if let Some(c) = collapse {
89 req = req.header("apns-collapse-id", c);
90 }
91 let r = req.send().await?;
92 let status = r.status();
93 if status.is_success() {
94 return Ok(true);
95 }
96 let body: Value = r.json().await.unwrap_or(Value::Null);
97 let reason = body["reason"].as_str().unwrap_or_default();
98 if status.as_u16() == 410
99 || matches!(reason, "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic")
100 {
101 return Ok(false);
102 }
103 anyhow::bail!("APNs answered {status} {reason}")
104 }
105}
106
107fn provider_token(key: &SigningKey, key_id: &str, team_id: &str, now: i64) -> String {
109 let header = B64.encode(json!({"alg": "ES256", "kid": key_id}).to_string());
110 let claims = B64.encode(json!({"iss": team_id, "iat": now}).to_string());
111 let input = format!("{header}.{claims}");
112 let sig: Signature = key.sign(input.as_bytes());
113 format!("{input}.{}", B64.encode(sig.to_bytes()))
114}
115
116pub fn notify(
119 ctx: &Ctx,
120 player: i64,
121 title: String,
122 body: String,
123 game: Option<String>,
124 collapse: Option<String>,
125) {
126 let Some(apns) = ctx.apns.clone() else { return };
127 let ctx = ctx.clone();
128 tokio::spawn(async move {
129 if let Err(e) =
130 deliver(&ctx, &apns, player, &title, &body, game.as_deref(), collapse.as_deref()).await
131 {
132 warn!(player, "push: {e:#}");
133 }
134 });
135}
136
137async fn deliver(
138 ctx: &Ctx,
139 apns: &Apns,
140 player: i64,
141 title: &str,
142 body: &str,
143 game: Option<&str>,
144 collapse: Option<&str>,
145) -> Result<()> {
146 let devices: Vec<(String, bool)> = {
147 let c = ctx.db.conn().await?;
148 c.query(&ctx.db.sql("SELECT token, sandbox FROM chess.push_devices WHERE player_id = $1"), &[&player])
149 .await?
150 .iter()
151 .map(|r| (r.get(0), r.get(1)))
152 .collect()
153 };
154 let payload = json!({
155 "aps": {"alert": {"title": title, "body": body}, "sound": "default", "thread-id": game.unwrap_or("open-chess")},
156 "gameId": game,
157 });
158 for (token, sandbox) in devices {
159 match apns.send(&token, sandbox, &payload, collapse).await {
160 Ok(true) => debug!(player, "push sent"),
161 Ok(false) => {
162 let c = ctx.db.conn().await?;
163 c.execute(&ctx.db.sql("DELETE FROM chess.push_devices WHERE token = $1"), &[&token]).await?;
164 }
165 Err(e) => warn!(player, "push: {e:#}"),
166 }
167 }
168 Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use p256::ecdsa::VerifyingKey;
175 use p256::ecdsa::signature::Verifier;
176
177 #[test]
178 fn signs_a_verifiable_provider_token() {
179 let key = SigningKey::from_slice(&[7u8; 32]).unwrap();
180 let t = provider_token(&key, "ABC123DEFG", "TEAM456789", 1_700_000_000);
181 let parts: Vec<&str> = t.split('.').collect();
182 assert_eq!(parts.len(), 3);
183 let header: Value = serde_json::from_slice(&B64.decode(parts[0]).unwrap()).unwrap();
184 assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("ES256"), Some("ABC123DEFG")));
185 let claims: Value = serde_json::from_slice(&B64.decode(parts[1]).unwrap()).unwrap();
186 assert_eq!(
187 (claims["iss"].as_str(), claims["iat"].as_i64()),
188 (Some("TEAM456789"), Some(1_700_000_000))
189 );
190 let sig = Signature::from_slice(&B64.decode(parts[2]).unwrap()).unwrap();
191 let input = format!("{}.{}", parts[0], parts[1]);
192 VerifyingKey::from(&key).verify(input.as_bytes(), &sig).unwrap();
193 }
194}