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 Activity(ActivityState),
80}
81
82impl Pusher {
83 pub fn from_config(cfg: &PushConfig) -> Option<Self> {
85 if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
86 return None;
87 }
88 let pem = match (&cfg.key, &cfg.key_path) {
89 (Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
90 (_, Some(path)) => read_key(path)?,
91 _ => return None,
92 };
93 let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
94 Ok(key) => key,
95 Err(e) => {
96 log::warn!("push: the APNs key is not an EC private key: {e}");
97 return None;
98 }
99 };
100 Some(Self {
101 key,
102 key_id: cfg.key_id.clone(),
103 team_id: cfg.team_id.clone(),
104 topic: cfg.topic.clone(),
105 bearer: Mutex::new(None),
106 http: std::sync::OnceLock::new(),
107 public_url: None,
108 })
109 }
110
111 pub fn cover_link(&self, track_id: i64) -> Option<String> {
113 let base = self.public_url.as_deref()?.trim_end_matches('/');
114 let expires = unix_now() + COVER_LIFE;
115 let sig = cover_sig(track_id, expires);
116 Some(format!("{base}/push/cover/{track_id}/{expires}/{sig}"))
117 }
118
119 fn bearer(&self) -> Result<String, String> {
120 let mut cached = self.bearer.lock();
121 if let Some((token, at)) = cached.as_ref()
122 && at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
123 {
124 return Ok(token.clone());
125 }
126 let mut header = Header::new(Algorithm::ES256);
127 header.kid = Some(self.key_id.clone());
128 let iat = SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .map_err(|e| e.to_string())?
131 .as_secs();
132 let token = jsonwebtoken::encode(
133 &header,
134 &json!({ "iss": self.team_id, "iat": iat }),
135 &self.key,
136 )
137 .map_err(|e| e.to_string())?;
138 *cached = Some((token.clone(), SystemTime::now()));
139 Ok(token)
140 }
141
142 pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
145 let http = self.http.get_or_init(|| {
146 reqwest::blocking::Client::builder()
147 .connect_timeout(Duration::from_secs(10))
148 .timeout(Duration::from_secs(20))
149 .build()
150 .unwrap_or_default()
151 });
152 let bearer = match self.bearer() {
153 Ok(b) => b,
154 Err(e) => return Outcome::Failed(format!("signing: {e}")),
155 };
156 let host = if sandbox {
157 "api.sandbox.push.apple.com"
158 } else {
159 "api.push.apple.com"
160 };
161 let (kind, priority, expires_in) = match push {
162 Push::Wake => ("background", "5", 60 * 60),
164 Push::Notify { .. } => ("alert", "10", 10 * 60),
166 Push::Activity(_) => ("liveactivity", "10", 60),
168 };
169 let topic = match push {
171 Push::Activity(_) => format!("{}.push-type.liveactivity", self.topic),
172 _ => self.topic.clone(),
173 };
174 let expiration = unix_now() + expires_in;
175 let response = http
176 .post(format!("https://{host}/3/device/{token}"))
177 .bearer_auth(bearer)
178 .header("apns-topic", topic)
179 .header("apns-push-type", kind)
180 .header("apns-priority", priority)
181 .header("apns-expiration", expiration.to_string())
182 .json(&payload(push))
183 .send();
184 match response {
185 Ok(r) if r.status().is_success() => Outcome::Sent,
186 Ok(r) => {
187 let status = r.status();
188 let reason = r
189 .json::<Value>()
190 .ok()
191 .and_then(|v| v["reason"].as_str().map(str::to_owned))
192 .unwrap_or_default();
193 if status == reqwest::StatusCode::GONE
194 || matches!(
195 reason.as_str(),
196 "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
197 )
198 {
199 Outcome::Gone
200 } else {
201 Outcome::Failed(format!("{status} {reason}"))
202 }
203 }
204 Err(e) => Outcome::Failed(e.to_string()),
205 }
206 }
207}
208
209pub fn payload(push: &Push) -> Value {
212 match push {
213 Push::Activity(state) => json!({
214 "aps": {
215 "timestamp": state.at as u64,
216 "event": "update",
217 "content-state": state,
218 "stale-date": state.at as u64 + ACTIVITY_STALE_SECS,
220 },
221 }),
222 Push::Wake => json!({ "aps": { "content-available": 1 } }),
223 Push::Notify {
224 title,
225 body,
226 command,
227 image,
228 } => {
229 let mut body = json!({
230 "aps": {
231 "alert": { "title": title, "body": body },
232 "sound": "default",
233 "interruption-level": "time-sensitive",
235 },
236 "koan": command,
237 });
238 if let Some(image) = image {
239 body["aps"]["mutable-content"] = json!(1);
242 body["image"] = json!(image);
243 }
244 body
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, serde::Serialize)]
252#[serde(rename_all = "camelCase")]
253pub struct ActivityState {
254 pub device: String,
255 pub linked: bool,
256 pub title: Option<String>,
257 pub artist: Option<String>,
258 pub album: Option<String>,
259 pub playing: bool,
260 pub position_ms: u64,
261 pub duration_ms: u64,
262 pub at: f64,
264 #[serde(skip_serializing_if = "Option::is_none")]
267 pub art: Option<String>,
268}
269
270const ACTIVITY_STALE_SECS: u64 = 8 * 60 * 60;
272
273impl ActivityState {
274 pub fn of(info: &crate::clients::ClientInfo) -> Self {
275 Self {
276 device: info.name.clone(),
277 linked: true,
278 title: info.state.title.clone(),
279 artist: info.state.artist.clone(),
280 album: info.state.album.clone(),
281 playing: info.state.playing,
282 position_ms: info.position_ms(),
283 duration_ms: info.state.duration_ms,
284 at: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
285 art: info
286 .state
287 .queue
288 .iter()
289 .find(|e| e.current)
290 .and_then(|e| e.track_id.as_deref())
291 .and_then(activity_art),
292 }
293 }
294
295 pub fn differs(&self, sent: &Self) -> bool {
298 let strip = |s: &Self| Self {
299 position_ms: 0,
300 at: 0.0,
301 ..s.clone()
302 };
303 if strip(self) != strip(sent) {
304 return true;
305 }
306 let expected = if sent.playing {
307 sent.position_ms + ((self.at - sent.at).max(0.0) * 1000.0) as u64
308 } else {
309 sent.position_ms
310 };
311 self.position_ms.abs_diff(expected) > 3000
312 }
313}
314
315const ART_BUDGET: usize = 2600;
318
319fn activity_art(track: &str) -> Option<String> {
323 use base64::Engine as _;
324 static LAST: parking_lot::Mutex<Option<(String, Option<String>)>> =
325 parking_lot::Mutex::new(None);
326 static COVERS: std::sync::LazyLock<crate::covers::Covers> =
327 std::sync::LazyLock::new(crate::covers::Covers::in_config_dir);
328 if cfg!(test) {
329 return None;
330 }
331 if let Some((held, art)) = LAST.lock().as_ref()
332 && held == track
333 {
334 return art.clone();
335 }
336 let art = (|| {
337 let db = koan_core::db::pool::shared().get().ok()?;
338 let id = koan_core::db::queries::resolve_id(
339 &db.conn,
340 koan_core::db::queries::UidKind::Track,
341 track,
342 )
343 .ok()??;
344 let row = koan_core::db::queries::get_track_row(&db.conn, id).ok()??;
345 let cover = COVERS.cover(std::slice::from_ref(&row), crate::covers::SIZES[0])?;
346 let img = image::load_from_memory(&cover).ok()?;
347 [(72, 60), (60, 50), (48, 40)]
349 .into_iter()
350 .find_map(|(side, quality)| {
351 let mut out = Vec::new();
352 let small = img.thumbnail(side, side).to_rgb8();
353 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality)
354 .encode_image(&small)
355 .ok()?;
356 let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
357 (b64.len() <= ART_BUDGET).then_some(b64)
358 })
359 })();
360 *LAST.lock() = Some((track.to_string(), art.clone()));
361 art
362}
363
364fn read_key(path: &Path) -> Option<String> {
365 match std::fs::read_to_string(path) {
366 Ok(pem) => Some(pem),
367 Err(e) => {
368 log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
369 None
370 }
371 }
372}
373
374pub fn pusher() -> Option<&'static Pusher> {
377 static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
378 let cfg = koan_core::config::Config::load().unwrap_or_default();
379 let pusher = Pusher::from_config(&cfg.push).map(|p| Pusher {
380 public_url: cfg.sharing.public_url.filter(|u| !u.trim().is_empty()),
381 ..p
382 });
383 if pusher.is_some() {
384 log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
385 }
386 pusher
387 });
388 PUSHER.as_ref()
389}
390
391fn unix_now() -> u64 {
392 SystemTime::now()
393 .duration_since(UNIX_EPOCH)
394 .map_or(0, |d| d.as_secs())
395}
396
397fn cover_mac() -> Hmac<sha2::Sha256> {
400 static KEY: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(|| {
401 let mut key = [0; 32];
402 getrandom::fill(&mut key).expect("system randomness");
403 key
404 });
405 let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&*KEY).expect("any key length");
406 mac.update(b"koan notification cover\0");
407 mac
408}
409
410fn cover_sig(track_id: i64, expires: u64) -> String {
411 use base64::Engine;
412 let mut mac = cover_mac();
413 mac.update(format!("{track_id}.{expires}").as_bytes());
414 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
415}
416
417fn cover_sig_valid(track_id: i64, expires: u64, sig: &str) -> bool {
418 use base64::Engine;
419 let Ok(sig) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(sig) else {
420 return false;
421 };
422 let mut mac = cover_mac();
423 mac.update(format!("{track_id}.{expires}").as_bytes());
424 expires >= unix_now() && mac.verify_slice(&sig).is_ok()
425}
426
427#[derive(Clone)]
428struct CoverState {
429 pool: std::sync::Arc<Pool>,
430 covers: std::sync::Arc<crate::covers::Covers>,
431}
432
433pub fn router(
436 pool: std::sync::Arc<Pool>,
437 covers: std::sync::Arc<crate::covers::Covers>,
438) -> axum::Router {
439 axum::Router::new()
440 .route("/push/cover/{track}/{expires}/{sig}", get(cover))
441 .with_state(CoverState { pool, covers })
442}
443
444async fn cover(
445 State(s): State<CoverState>,
446 UrlPath((track, expires, sig)): UrlPath<(i64, u64, String)>,
447) -> Response {
448 if !cover_sig_valid(track, expires, &sig) {
449 return crate::share::not_found();
450 }
451 let art = crate::share::blocking(move || {
452 let db = s.pool.get().ok()?;
453 let row = koan_core::db::queries::get_track_row(&db.conn, track).ok()??;
454 s.covers
455 .cover(std::slice::from_ref(&row), crate::covers::LARGE)
456 })
457 .await;
458 crate::share::jpeg(art, false)
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
467MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
468rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
469AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
470-----END PRIVATE KEY-----";
471
472 fn config() -> PushConfig {
473 PushConfig {
474 key: Some(TEST_KEY.into()),
475 key_id: "ABC123DEFG".into(),
476 team_id: "TEAM123456".into(),
477 ..PushConfig::default()
478 }
479 }
480
481 #[test]
482 fn no_key_no_pusher() {
483 assert!(Pusher::from_config(&PushConfig::default()).is_none());
484 }
485
486 #[test]
487 fn the_token_is_es256_with_the_key_id_and_reused() {
488 let pusher = Pusher::from_config(&config()).expect("pusher");
489 let token = pusher.bearer().unwrap();
490 let header = jsonwebtoken::decode_header(&token).unwrap();
491 assert_eq!(header.alg, Algorithm::ES256);
492 assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
493 assert_eq!(pusher.bearer().unwrap(), token);
494 }
495
496 #[test]
497 fn a_notification_carries_its_command() {
498 let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
499 let body = payload(&Push::Notify {
500 title: "Play on this iPhone".into(),
501 body: "Golden Standard".into(),
502 command: command.clone(),
503 image: None,
504 });
505 assert_eq!(body["koan"], command);
506 assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
507 assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
508 assert!(body["aps"].get("mutable-content").is_none());
509 assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
510
511 let body = payload(&Push::Notify {
512 title: "Play on this iPhone".into(),
513 body: "Golden Standard".into(),
514 command,
515 image: Some("https://koan.example/push/cover/1/2/x".into()),
516 });
517 assert_eq!(body["aps"]["mutable-content"], 1);
518 assert_eq!(body["image"], "https://koan.example/push/cover/1/2/x");
519 }
520
521 #[test]
522 fn cover_links_open_one_cover_until_they_expire() {
523 let later = unix_now() + 60;
524 let sig = cover_sig(7, later);
525 assert!(cover_sig_valid(7, later, &sig));
526 assert!(!cover_sig_valid(8, later, &sig));
527 assert!(!cover_sig_valid(7, later + 1, &sig));
528 assert!(!cover_sig_valid(7, later, "not-a-signature"));
529 let past = unix_now() - 1;
530 assert!(!cover_sig_valid(7, past, &cover_sig(7, past)));
531 }
532}