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