1use std::{
2 collections::{BTreeMap, BTreeSet},
3 sync::Mutex,
4 time::{Duration, SystemTime, UNIX_EPOCH},
5};
6
7use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
8use reqwest::{
9 StatusCode,
10 header::{AUTHORIZATION, HeaderMap, HeaderValue},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 error::{Error, Result},
16 message::Message,
17};
18
19const TOKEN_REFRESH_AFTER: u64 = 45 * 60;
20const DEFAULT_TEAM_ID: &str = "5U8LBRXG3A";
21const DEFAULT_AUTH_KEY_ID: &str = "LH4T9V5U4R";
22const DEFAULT_TOPIC: &str = "me.fin.bark";
23const DEFAULT_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
24MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg4vtC3g5L5HgKGJ2+\n\
25T1eA0tOivREvEAY2g+juRXJkYL2gCgYIKoZIzj0DAQehRANCAASmOs3JkSyoGEWZ\n\
26sUGxFs/4pw1rIlSV2IC19M8u3G5kq36upOwyFWj9Gi3Ejc9d3sC7+SHRqXrEAJow\n\
278/7tRpV+\n\
28-----END PRIVATE KEY-----\n";
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31enum Environment {
32 Production,
33 Sandbox,
34}
35
36impl Environment {
37 pub const fn host(self) -> &'static str {
38 match self {
39 Self::Production => "api.push.apple.com",
40 Self::Sandbox => "api.sandbox.push.apple.com",
41 }
42 }
43}
44
45#[derive(Clone)]
46struct Credentials {
47 team_id: String,
48 auth_key_id: String,
49 topic: String,
50 encoding_key: EncodingKey,
51}
52
53impl Credentials {
54 fn new<T, K, O, P>(team_id: T, auth_key_id: K, topic: O, private_key_pem: P) -> Result<Self>
55 where
56 T: Into<String>,
57 K: Into<String>,
58 O: Into<String>,
59 P: Into<String>,
60 {
61 let private_key_pem = private_key_pem.into();
62 let encoding_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes())?;
63
64 Ok(Self {
65 team_id: team_id.into(),
66 auth_key_id: auth_key_id.into(),
67 topic: topic.into(),
68 encoding_key,
69 })
70 }
71
72 fn team_id(&self) -> &str {
73 &self.team_id
74 }
75
76 fn auth_key_id(&self) -> &str {
77 &self.auth_key_id
78 }
79
80 fn topic(&self) -> &str {
81 &self.topic
82 }
83
84 fn token(&self, issued_at: u64) -> Result<String> {
85 let mut header = Header::new(Algorithm::ES256);
86 header.kid = Some(self.auth_key_id.clone());
87
88 let claims = ApnsClaims {
89 iss: &self.team_id,
90 iat: issued_at,
91 };
92
93 Ok(encode(&header, &claims, &self.encoding_key)?)
94 }
95}
96
97pub struct Bark {
108 credentials: Credentials,
109 environment: Environment,
110 async_http: reqwest::Client,
111 blocking_http: reqwest::blocking::Client,
112 token: Mutex<Option<CachedToken>>,
113}
114
115impl Bark {
116 pub fn new() -> Result<Self> {
126 Self::with_credentials(
127 DEFAULT_TEAM_ID,
128 DEFAULT_AUTH_KEY_ID,
129 DEFAULT_TOPIC,
130 DEFAULT_PRIVATE_KEY,
131 )
132 }
133
134 pub fn with_credentials<T, K, O, P>(
144 team_id: T,
145 auth_key_id: K,
146 topic: O,
147 private_key_pem: P,
148 ) -> Result<Self>
149 where
150 T: Into<String>,
151 K: Into<String>,
152 O: Into<String>,
153 P: Into<String>,
154 {
155 let credentials = Credentials::new(team_id, auth_key_id, topic, private_key_pem)?;
156 Ok(Self {
157 credentials,
158 environment: Environment::Production,
159 async_http: reqwest::ClientBuilder::new()
160 .http2_adaptive_window(true)
161 .build()
162 .map_err(Error::HttpClient)?,
163 blocking_http: reqwest::blocking::ClientBuilder::new()
164 .http2_adaptive_window(true)
165 .build()
166 .map_err(Error::HttpClient)?,
167 token: Mutex::new(None),
168 })
169 }
170
171 pub fn production(mut self) -> Self {
175 self.environment = Environment::Production;
176 self
177 }
178
179 pub fn sandbox(mut self) -> Self {
184 self.environment = Environment::Sandbox;
185 self
186 }
187
188 pub fn team_id(&self) -> &str {
190 self.credentials.team_id()
191 }
192
193 pub fn auth_key_id(&self) -> &str {
195 self.credentials.auth_key_id()
196 }
197
198 pub fn topic(&self) -> &str {
200 self.credentials.topic()
201 }
202
203 pub fn send<I, D>(&self, message: &Message, devices: I) -> Result<()>
218 where
219 I: IntoIterator<Item = D>,
220 D: Into<String>,
221 {
222 let body = message.payload_bytes()?;
223 let headers = self.headers(message)?;
224 let devices = collect_devices(devices);
225 let mut failures = BTreeMap::new();
226
227 for device in devices {
228 let response = self
229 .blocking_http
230 .post(self.device_url(&device))
231 .headers(headers.clone())
232 .body(body.clone())
233 .send();
234
235 match response {
236 Ok(response) => {
237 if !response.status().is_success() {
238 let status = response.status();
239 let reason = blocking_apns_reason(response);
240 failures.insert(device, format!("{status}: {reason}"));
241 }
242 }
243 Err(error) => {
244 failures.insert(device, error.to_string());
245 }
246 }
247 }
248
249 if failures.is_empty() {
250 Ok(())
251 } else {
252 Err(Error::ApnsFailures(format_apns_failures(&failures)))
253 }
254 }
255
256 pub async fn send_async<I, D>(&self, message: &Message, devices: I) -> Result<()>
270 where
271 I: IntoIterator<Item = D>,
272 D: Into<String>,
273 {
274 let body = message.payload_bytes()?;
275 let headers = self.headers(message)?;
276 let devices = collect_devices(devices);
277 let mut failures = BTreeMap::new();
278
279 for device in devices {
280 let response = self
281 .async_http
282 .post(self.device_url(&device))
283 .headers(headers.clone())
284 .body(body.clone())
285 .send()
286 .await;
287
288 match response {
289 Ok(response) => {
290 if !response.status().is_success() {
291 let status = response.status();
292 let reason = async_apns_reason(response).await;
293 failures.insert(device, format!("{status}: {reason}"));
294 }
295 }
296 Err(error) => {
297 failures.insert(device, error.to_string());
298 }
299 }
300 }
301
302 if failures.is_empty() {
303 Ok(())
304 } else {
305 Err(Error::ApnsFailures(format_apns_failures(&failures)))
306 }
307 }
308
309 fn headers(&self, message: &Message) -> Result<HeaderMap> {
310 message.validate_headers()?;
311
312 let mut headers = HeaderMap::new();
313 let authorization = format!("bearer {}", self.apns_token()?);
314 headers.insert(
315 AUTHORIZATION,
316 HeaderValue::from_str(&authorization).map_err(|source| Error::InvalidHeaderValue {
317 name: "authorization",
318 source,
319 })?,
320 );
321 headers.insert(
322 "apns-topic",
323 HeaderValue::from_str(self.credentials.topic()).map_err(|source| {
324 Error::InvalidHeaderValue {
325 name: "apns-topic",
326 source,
327 }
328 })?,
329 );
330 headers.insert(
331 "apns-push-type",
332 HeaderValue::from_static(if message.is_delete() {
333 "background"
334 } else {
335 "alert"
336 }),
337 );
338 headers.insert(
339 "apns-priority",
340 HeaderValue::from_static(if message.is_delete() { "5" } else { "10" }),
341 );
342
343 if let Some(id) = message.id_value() {
344 headers.insert(
345 "apns-collapse-id",
346 HeaderValue::from_str(id).map_err(|source| Error::InvalidHeaderValue {
347 name: "apns-collapse-id",
348 source,
349 })?,
350 );
351 }
352
353 Ok(headers)
354 }
355
356 fn apns_token(&self) -> Result<String> {
357 let now = unix_timestamp();
358 let mut cached = self.token.lock().expect("APNs token cache poisoned");
359
360 if let Some(cached) = cached.as_ref()
361 && cached.issued_at + TOKEN_REFRESH_AFTER > now
362 {
363 return Ok(cached.value.clone());
364 }
365
366 let value = self.credentials.token(now)?;
367 *cached = Some(CachedToken {
368 issued_at: now,
369 value: value.clone(),
370 });
371 Ok(value)
372 }
373
374 fn device_url(&self, device: &str) -> String {
375 format!("https://{}/3/device/{device}", self.environment.host())
376 }
377}
378
379#[derive(Clone, Debug)]
380struct CachedToken {
381 issued_at: u64,
382 value: String,
383}
384
385#[derive(Debug, Serialize)]
386struct ApnsClaims<'a> {
387 iss: &'a str,
388 iat: u64,
389}
390
391#[derive(Debug, Deserialize)]
392struct ApnsErrorBody {
393 reason: Option<String>,
394}
395
396fn collect_devices<I, D>(devices: I) -> Vec<String>
397where
398 I: IntoIterator<Item = D>,
399 D: Into<String>,
400{
401 devices
402 .into_iter()
403 .filter_map(|device| {
404 let normalized = normalize_device_token(device.into());
405 (!normalized.is_empty()).then_some(normalized)
406 })
407 .collect::<BTreeSet<_>>()
408 .into_iter()
409 .collect()
410}
411
412fn normalize_device_token(device: String) -> String {
413 device
414 .trim()
415 .trim_start_matches('<')
416 .trim_end_matches('>')
417 .chars()
418 .filter(|ch| !ch.is_ascii_whitespace())
419 .collect()
420}
421
422async fn async_apns_reason(response: reqwest::Response) -> String {
423 let status = response.status();
424 match response.text().await {
425 Ok(text) => apns_reason_from_text(status, &text),
426 Err(error) => error.to_string(),
427 }
428}
429
430fn blocking_apns_reason(response: reqwest::blocking::Response) -> String {
431 let status = response.status();
432 match response.text() {
433 Ok(text) => apns_reason_from_text(status, &text),
434 Err(error) => error.to_string(),
435 }
436}
437
438fn apns_reason_from_text(status: StatusCode, text: &str) -> String {
439 if text.trim().is_empty() {
440 return status.to_string();
441 }
442
443 serde_json::from_str::<ApnsErrorBody>(text)
444 .ok()
445 .and_then(|body| body.reason)
446 .unwrap_or_else(|| text.to_owned())
447}
448
449fn format_apns_failures(failures: &BTreeMap<String, String>) -> String {
450 failures
451 .iter()
452 .map(|(device, reason)| format!("{device}: {reason}"))
453 .collect::<Vec<_>>()
454 .join("; ")
455}
456
457fn unix_timestamp() -> u64 {
458 SystemTime::now()
459 .duration_since(UNIX_EPOCH)
460 .unwrap_or_else(|_| Duration::from_secs(0))
461 .as_secs()
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn credentials_accept_str_and_string() {
470 let credentials = Credentials::new(
471 String::from(DEFAULT_TEAM_ID),
472 DEFAULT_AUTH_KEY_ID,
473 DEFAULT_TOPIC,
474 DEFAULT_PRIVATE_KEY,
475 )
476 .unwrap();
477
478 assert_eq!(credentials.team_id(), DEFAULT_TEAM_ID);
479 assert_eq!(credentials.auth_key_id(), DEFAULT_AUTH_KEY_ID);
480 assert_eq!(credentials.topic(), DEFAULT_TOPIC);
481 }
482
483 #[test]
484 fn device_tokens_are_normalized_and_deduped() {
485 let devices = collect_devices(["<aa bb>", "aabb", " ", "cc"]);
486
487 assert_eq!(devices, vec!["aabb", "cc"]);
488 }
489}