1use std::path::Path;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use axum::extract::{Path as UrlPath, State};
23use axum::response::Response;
24use axum::routing::get;
25use hmac::{Hmac, Mac};
26use jsonwebtoken::{Algorithm, EncodingKey, Header};
27use koan_core::config::PushConfig;
28use koan_core::db::pool::Pool;
29use parking_lot::Mutex;
30use serde_json::{Value, json};
31
32const TOKEN_LIFE: Duration = Duration::from_secs(50 * 60);
35
36const COVER_LIFE: u64 = 10 * 60;
40
41pub struct Pusher {
42 key: EncodingKey,
43 key_id: String,
44 team_id: String,
45 topic: String,
46 bearer: Mutex<Option<(String, SystemTime)>>,
47 http: std::sync::OnceLock<reqwest::blocking::Client>,
51 public_url: Option<String>,
54}
55
56#[derive(Debug, PartialEq, Eq)]
58pub enum Outcome {
59 Sent,
60 Gone,
63 Failed(String),
64}
65
66pub enum Push {
68 Wake,
70 Notify {
72 title: String,
73 body: String,
74 command: Value,
75 image: Option<String>,
77 },
78}
79
80impl Pusher {
81 pub fn from_config(cfg: &PushConfig) -> Option<Self> {
83 if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
84 return None;
85 }
86 let pem = match (&cfg.key, &cfg.key_path) {
87 (Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
88 (_, Some(path)) => read_key(path)?,
89 _ => return None,
90 };
91 let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
92 Ok(key) => key,
93 Err(e) => {
94 log::warn!("push: the APNs key is not an EC private key: {e}");
95 return None;
96 }
97 };
98 Some(Self {
99 key,
100 key_id: cfg.key_id.clone(),
101 team_id: cfg.team_id.clone(),
102 topic: cfg.topic.clone(),
103 bearer: Mutex::new(None),
104 http: std::sync::OnceLock::new(),
105 public_url: None,
106 })
107 }
108
109 pub fn cover_link(&self, track_id: i64) -> Option<String> {
111 let base = self.public_url.as_deref()?.trim_end_matches('/');
112 let expires = unix_now() + COVER_LIFE;
113 let sig = cover_sig(track_id, expires);
114 Some(format!("{base}/push/cover/{track_id}/{expires}/{sig}"))
115 }
116
117 fn bearer(&self) -> Result<String, String> {
118 let mut cached = self.bearer.lock();
119 if let Some((token, at)) = cached.as_ref()
120 && at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
121 {
122 return Ok(token.clone());
123 }
124 let mut header = Header::new(Algorithm::ES256);
125 header.kid = Some(self.key_id.clone());
126 let iat = SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map_err(|e| e.to_string())?
129 .as_secs();
130 let token = jsonwebtoken::encode(
131 &header,
132 &json!({ "iss": self.team_id, "iat": iat }),
133 &self.key,
134 )
135 .map_err(|e| e.to_string())?;
136 *cached = Some((token.clone(), SystemTime::now()));
137 Ok(token)
138 }
139
140 pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
143 let http = self.http.get_or_init(|| {
144 reqwest::blocking::Client::builder()
145 .connect_timeout(Duration::from_secs(10))
146 .timeout(Duration::from_secs(20))
147 .build()
148 .unwrap_or_default()
149 });
150 let bearer = match self.bearer() {
151 Ok(b) => b,
152 Err(e) => return Outcome::Failed(format!("signing: {e}")),
153 };
154 let host = if sandbox {
155 "api.sandbox.push.apple.com"
156 } else {
157 "api.push.apple.com"
158 };
159 let (kind, priority, expires_in) = match push {
160 Push::Wake => ("background", "5", 60 * 60),
162 Push::Notify { .. } => ("alert", "10", 10 * 60),
164 };
165 let expiration = unix_now() + expires_in;
166 let response = http
167 .post(format!("https://{host}/3/device/{token}"))
168 .bearer_auth(bearer)
169 .header("apns-topic", &self.topic)
170 .header("apns-push-type", kind)
171 .header("apns-priority", priority)
172 .header("apns-expiration", expiration.to_string())
173 .json(&payload(push))
174 .send();
175 match response {
176 Ok(r) if r.status().is_success() => Outcome::Sent,
177 Ok(r) => {
178 let status = r.status();
179 let reason = r
180 .json::<Value>()
181 .ok()
182 .and_then(|v| v["reason"].as_str().map(str::to_owned))
183 .unwrap_or_default();
184 if status == reqwest::StatusCode::GONE
185 || matches!(
186 reason.as_str(),
187 "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
188 )
189 {
190 Outcome::Gone
191 } else {
192 Outcome::Failed(format!("{status} {reason}"))
193 }
194 }
195 Err(e) => Outcome::Failed(e.to_string()),
196 }
197 }
198}
199
200pub fn payload(push: &Push) -> Value {
203 match push {
204 Push::Wake => json!({ "aps": { "content-available": 1 } }),
205 Push::Notify {
206 title,
207 body,
208 command,
209 image,
210 } => {
211 let mut body = json!({
212 "aps": {
213 "alert": { "title": title, "body": body },
214 "sound": "default",
215 "interruption-level": "time-sensitive",
217 },
218 "koan": command,
219 });
220 if let Some(image) = image {
221 body["aps"]["mutable-content"] = json!(1);
224 body["image"] = json!(image);
225 }
226 body
227 }
228 }
229}
230
231fn read_key(path: &Path) -> Option<String> {
232 match std::fs::read_to_string(path) {
233 Ok(pem) => Some(pem),
234 Err(e) => {
235 log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
236 None
237 }
238 }
239}
240
241pub fn pusher() -> Option<&'static Pusher> {
244 static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
245 let cfg = koan_core::config::Config::load().unwrap_or_default();
246 let pusher = Pusher::from_config(&cfg.push).map(|p| Pusher {
247 public_url: cfg.sharing.public_url.filter(|u| !u.trim().is_empty()),
248 ..p
249 });
250 if pusher.is_some() {
251 log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
252 }
253 pusher
254 });
255 PUSHER.as_ref()
256}
257
258fn unix_now() -> u64 {
259 SystemTime::now()
260 .duration_since(UNIX_EPOCH)
261 .map_or(0, |d| d.as_secs())
262}
263
264fn cover_mac() -> Hmac<sha2::Sha256> {
267 static KEY: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(|| {
268 let mut key = [0; 32];
269 getrandom::fill(&mut key).expect("system randomness");
270 key
271 });
272 let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&*KEY).expect("any key length");
273 mac.update(b"koan notification cover\0");
274 mac
275}
276
277fn cover_sig(track_id: i64, expires: u64) -> String {
278 use base64::Engine;
279 let mut mac = cover_mac();
280 mac.update(format!("{track_id}.{expires}").as_bytes());
281 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
282}
283
284fn cover_sig_valid(track_id: i64, expires: u64, sig: &str) -> bool {
285 use base64::Engine;
286 let Ok(sig) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(sig) else {
287 return false;
288 };
289 let mut mac = cover_mac();
290 mac.update(format!("{track_id}.{expires}").as_bytes());
291 expires >= unix_now() && mac.verify_slice(&sig).is_ok()
292}
293
294#[derive(Clone)]
295struct CoverState {
296 pool: std::sync::Arc<Pool>,
297 covers: std::sync::Arc<crate::covers::Covers>,
298}
299
300pub fn router(
303 pool: std::sync::Arc<Pool>,
304 covers: std::sync::Arc<crate::covers::Covers>,
305) -> axum::Router {
306 axum::Router::new()
307 .route("/push/cover/{track}/{expires}/{sig}", get(cover))
308 .with_state(CoverState { pool, covers })
309}
310
311async fn cover(
312 State(s): State<CoverState>,
313 UrlPath((track, expires, sig)): UrlPath<(i64, u64, String)>,
314) -> Response {
315 if !cover_sig_valid(track, expires, &sig) {
316 return crate::share::not_found();
317 }
318 let art = crate::share::blocking(move || {
319 let db = s.pool.get().ok()?;
320 let row = koan_core::db::queries::get_track_row(&db.conn, track).ok()??;
321 s.covers
322 .cover(std::slice::from_ref(&row), crate::covers::LARGE)
323 })
324 .await;
325 crate::share::jpeg(art, false)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
334MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
335rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
336AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
337-----END PRIVATE KEY-----";
338
339 fn config() -> PushConfig {
340 PushConfig {
341 key: Some(TEST_KEY.into()),
342 key_id: "ABC123DEFG".into(),
343 team_id: "TEAM123456".into(),
344 ..PushConfig::default()
345 }
346 }
347
348 #[test]
349 fn no_key_no_pusher() {
350 assert!(Pusher::from_config(&PushConfig::default()).is_none());
351 }
352
353 #[test]
354 fn the_token_is_es256_with_the_key_id_and_reused() {
355 let pusher = Pusher::from_config(&config()).expect("pusher");
356 let token = pusher.bearer().unwrap();
357 let header = jsonwebtoken::decode_header(&token).unwrap();
358 assert_eq!(header.alg, Algorithm::ES256);
359 assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
360 assert_eq!(pusher.bearer().unwrap(), token);
361 }
362
363 #[test]
364 fn a_notification_carries_its_command() {
365 let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
366 let body = payload(&Push::Notify {
367 title: "Play on this iPhone".into(),
368 body: "Golden Standard".into(),
369 command: command.clone(),
370 image: None,
371 });
372 assert_eq!(body["koan"], command);
373 assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
374 assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
375 assert!(body["aps"].get("mutable-content").is_none());
376 assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
377
378 let body = payload(&Push::Notify {
379 title: "Play on this iPhone".into(),
380 body: "Golden Standard".into(),
381 command,
382 image: Some("https://koan.example/push/cover/1/2/x".into()),
383 });
384 assert_eq!(body["aps"]["mutable-content"], 1);
385 assert_eq!(body["image"], "https://koan.example/push/cover/1/2/x");
386 }
387
388 #[test]
389 fn cover_links_open_one_cover_until_they_expire() {
390 let later = unix_now() + 60;
391 let sig = cover_sig(7, later);
392 assert!(cover_sig_valid(7, later, &sig));
393 assert!(!cover_sig_valid(8, later, &sig));
394 assert!(!cover_sig_valid(7, later + 1, &sig));
395 assert!(!cover_sig_valid(7, later, "not-a-signature"));
396 let past = unix_now() - 1;
397 assert!(!cover_sig_valid(7, past, &cover_sig(7, past)));
398 }
399}