use std::{
collections::{BTreeMap, BTreeSet},
sync::Mutex,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use reqwest::{
StatusCode,
header::{AUTHORIZATION, HeaderMap, HeaderValue},
};
use serde::{Deserialize, Serialize};
use crate::{
error::{Error, Result},
message::Message,
};
const TOKEN_REFRESH_AFTER: u64 = 45 * 60;
const DEFAULT_TEAM_ID: &str = "5U8LBRXG3A";
const DEFAULT_AUTH_KEY_ID: &str = "LH4T9V5U4R";
const DEFAULT_TOPIC: &str = "me.fin.bark";
const DEFAULT_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg4vtC3g5L5HgKGJ2+\n\
T1eA0tOivREvEAY2g+juRXJkYL2gCgYIKoZIzj0DAQehRANCAASmOs3JkSyoGEWZ\n\
sUGxFs/4pw1rIlSV2IC19M8u3G5kq36upOwyFWj9Gi3Ejc9d3sC7+SHRqXrEAJow\n\
8/7tRpV+\n\
-----END PRIVATE KEY-----\n";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment {
Production,
Sandbox,
}
impl Environment {
pub const fn host(self) -> &'static str {
match self {
Self::Production => "api.push.apple.com",
Self::Sandbox => "api.sandbox.push.apple.com",
}
}
}
#[derive(Clone)]
struct Credentials {
team_id: String,
auth_key_id: String,
topic: String,
encoding_key: EncodingKey,
}
impl Credentials {
fn new<T, K, O, P>(team_id: T, auth_key_id: K, topic: O, private_key_pem: P) -> Result<Self>
where
T: Into<String>,
K: Into<String>,
O: Into<String>,
P: Into<String>,
{
let private_key_pem = private_key_pem.into();
let encoding_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes())?;
Ok(Self {
team_id: team_id.into(),
auth_key_id: auth_key_id.into(),
topic: topic.into(),
encoding_key,
})
}
fn team_id(&self) -> &str {
&self.team_id
}
fn auth_key_id(&self) -> &str {
&self.auth_key_id
}
fn topic(&self) -> &str {
&self.topic
}
fn token(&self, issued_at: u64) -> Result<String> {
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(self.auth_key_id.clone());
let claims = ApnsClaims {
iss: &self.team_id,
iat: issued_at,
};
Ok(encode(&header, &claims, &self.encoding_key)?)
}
}
pub struct Bark {
credentials: Credentials,
environment: Environment,
async_http: reqwest::Client,
blocking_http: reqwest::blocking::Client,
token: Mutex<Option<CachedToken>>,
}
impl Bark {
pub fn new() -> Result<Self> {
Self::with_credentials(
DEFAULT_TEAM_ID,
DEFAULT_AUTH_KEY_ID,
DEFAULT_TOPIC,
DEFAULT_PRIVATE_KEY,
)
}
pub fn with_credentials<T, K, O, P>(
team_id: T,
auth_key_id: K,
topic: O,
private_key_pem: P,
) -> Result<Self>
where
T: Into<String>,
K: Into<String>,
O: Into<String>,
P: Into<String>,
{
let credentials = Credentials::new(team_id, auth_key_id, topic, private_key_pem)?;
Ok(Self {
credentials,
environment: Environment::Production,
async_http: reqwest::ClientBuilder::new()
.http2_adaptive_window(true)
.build()
.map_err(Error::HttpClient)?,
blocking_http: reqwest::blocking::ClientBuilder::new()
.http2_adaptive_window(true)
.build()
.map_err(Error::HttpClient)?,
token: Mutex::new(None),
})
}
pub fn production(mut self) -> Self {
self.environment = Environment::Production;
self
}
pub fn sandbox(mut self) -> Self {
self.environment = Environment::Sandbox;
self
}
pub fn team_id(&self) -> &str {
self.credentials.team_id()
}
pub fn auth_key_id(&self) -> &str {
self.credentials.auth_key_id()
}
pub fn topic(&self) -> &str {
self.credentials.topic()
}
pub fn send<I, D>(&self, message: &Message, devices: I) -> Result<()>
where
I: IntoIterator<Item = D>,
D: Into<String>,
{
let body = message.payload_bytes()?;
let headers = self.headers(message)?;
let devices = collect_devices(devices);
let mut failures = BTreeMap::new();
for device in devices {
let response = self
.blocking_http
.post(self.device_url(&device))
.headers(headers.clone())
.body(body.clone())
.send();
match response {
Ok(response) => {
if !response.status().is_success() {
let status = response.status();
let reason = blocking_apns_reason(response);
failures.insert(device, format!("{status}: {reason}"));
}
}
Err(error) => {
failures.insert(device, error.to_string());
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(Error::ApnsFailures(format_apns_failures(&failures)))
}
}
pub async fn send_async<I, D>(&self, message: &Message, devices: I) -> Result<()>
where
I: IntoIterator<Item = D>,
D: Into<String>,
{
let body = message.payload_bytes()?;
let headers = self.headers(message)?;
let devices = collect_devices(devices);
let mut failures = BTreeMap::new();
for device in devices {
let response = self
.async_http
.post(self.device_url(&device))
.headers(headers.clone())
.body(body.clone())
.send()
.await;
match response {
Ok(response) => {
if !response.status().is_success() {
let status = response.status();
let reason = async_apns_reason(response).await;
failures.insert(device, format!("{status}: {reason}"));
}
}
Err(error) => {
failures.insert(device, error.to_string());
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(Error::ApnsFailures(format_apns_failures(&failures)))
}
}
fn headers(&self, message: &Message) -> Result<HeaderMap> {
message.validate_headers()?;
let mut headers = HeaderMap::new();
let authorization = format!("bearer {}", self.apns_token()?);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&authorization).map_err(|source| Error::InvalidHeaderValue {
name: "authorization",
source,
})?,
);
headers.insert(
"apns-topic",
HeaderValue::from_str(self.credentials.topic()).map_err(|source| {
Error::InvalidHeaderValue {
name: "apns-topic",
source,
}
})?,
);
headers.insert(
"apns-push-type",
HeaderValue::from_static(if message.is_delete() {
"background"
} else {
"alert"
}),
);
headers.insert(
"apns-priority",
HeaderValue::from_static(if message.is_delete() { "5" } else { "10" }),
);
if let Some(id) = message.id_value() {
headers.insert(
"apns-collapse-id",
HeaderValue::from_str(id).map_err(|source| Error::InvalidHeaderValue {
name: "apns-collapse-id",
source,
})?,
);
}
Ok(headers)
}
fn apns_token(&self) -> Result<String> {
let now = unix_timestamp();
let mut cached = self.token.lock().expect("APNs token cache poisoned");
if let Some(cached) = cached.as_ref()
&& cached.issued_at + TOKEN_REFRESH_AFTER > now
{
return Ok(cached.value.clone());
}
let value = self.credentials.token(now)?;
*cached = Some(CachedToken {
issued_at: now,
value: value.clone(),
});
Ok(value)
}
fn device_url(&self, device: &str) -> String {
format!("https://{}/3/device/{device}", self.environment.host())
}
}
#[derive(Clone, Debug)]
struct CachedToken {
issued_at: u64,
value: String,
}
#[derive(Debug, Serialize)]
struct ApnsClaims<'a> {
iss: &'a str,
iat: u64,
}
#[derive(Debug, Deserialize)]
struct ApnsErrorBody {
reason: Option<String>,
}
fn collect_devices<I, D>(devices: I) -> Vec<String>
where
I: IntoIterator<Item = D>,
D: Into<String>,
{
devices
.into_iter()
.filter_map(|device| {
let normalized = normalize_device_token(device.into());
(!normalized.is_empty()).then_some(normalized)
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn normalize_device_token(device: String) -> String {
device
.trim()
.trim_start_matches('<')
.trim_end_matches('>')
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect()
}
async fn async_apns_reason(response: reqwest::Response) -> String {
let status = response.status();
match response.text().await {
Ok(text) => apns_reason_from_text(status, &text),
Err(error) => error.to_string(),
}
}
fn blocking_apns_reason(response: reqwest::blocking::Response) -> String {
let status = response.status();
match response.text() {
Ok(text) => apns_reason_from_text(status, &text),
Err(error) => error.to_string(),
}
}
fn apns_reason_from_text(status: StatusCode, text: &str) -> String {
if text.trim().is_empty() {
return status.to_string();
}
serde_json::from_str::<ApnsErrorBody>(text)
.ok()
.and_then(|body| body.reason)
.unwrap_or_else(|| text.to_owned())
}
fn format_apns_failures(failures: &BTreeMap<String, String>) -> String {
failures
.iter()
.map(|(device, reason)| format!("{device}: {reason}"))
.collect::<Vec<_>>()
.join("; ")
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credentials_accept_str_and_string() {
let credentials = Credentials::new(
String::from(DEFAULT_TEAM_ID),
DEFAULT_AUTH_KEY_ID,
DEFAULT_TOPIC,
DEFAULT_PRIVATE_KEY,
)
.unwrap();
assert_eq!(credentials.team_id(), DEFAULT_TEAM_ID);
assert_eq!(credentials.auth_key_id(), DEFAULT_AUTH_KEY_ID);
assert_eq!(credentials.topic(), DEFAULT_TOPIC);
}
#[test]
fn device_tokens_are_normalized_and_deduped() {
let devices = collect_devices(["<aa bb>", "aabb", " ", "cc"]);
assert_eq!(devices, vec!["aabb", "cc"]);
}
}