use std::{fmt, future::Future, num::NonZeroU32, pin::Pin};
use chrono::{DateTime, Utc};
use thiserror::Error;
use uuid::Uuid;
use crate::PushOutcome;
pub const MAX_DEVICE_TOKEN_LENGTH: usize = 512;
pub const MAX_DEVICE_TIME_ZONE_LENGTH: usize = 64;
pub const DEFAULT_DEVICES_PER_OWNER: NonZeroU32 = NonZeroU32::new(10).expect("ten is not zero");
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum PushValidationError {
#[error("device token is invalid")]
DeviceToken,
#[error("device time zone is invalid")]
DeviceTimeZone,
#[error("delivery kind is invalid")]
DeliveryKind,
#[error("push ticket id is invalid")]
TicketId,
}
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DeviceToken(String);
impl DeviceToken {
pub fn new(token: impl Into<String>) -> Result<Self, PushValidationError> {
let token = token.into();
let valid = !token.is_empty()
&& token.len() <= MAX_DEVICE_TOKEN_LENGTH
&& token.bytes().all(|byte| byte.is_ascii_graphic());
if valid {
Ok(Self(token))
} else {
Err(PushValidationError::DeviceToken)
}
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for DeviceToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("DeviceToken(<redacted>)")
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct DeviceTimeZone(String);
impl DeviceTimeZone {
pub fn new(name: impl Into<String>) -> Result<Self, PushValidationError> {
let name = name.into();
let valid = !name.is_empty()
&& name.len() <= MAX_DEVICE_TIME_ZONE_LENGTH
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"/_+-".contains(&byte));
if valid {
Ok(Self(name))
} else {
Err(PushValidationError::DeviceTimeZone)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DevicePlatform {
Ios,
Android,
}
impl DevicePlatform {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ios => "ios",
Self::Android => "android",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"ios" => Some(Self::Ios),
"android" => Some(Self::Android),
_ => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DeviceRegistration {
pub owner_id: Uuid,
pub token: DeviceToken,
pub platform: DevicePlatform,
pub time_zone: Option<DeviceTimeZone>,
pub registered_at: DateTime<Utc>,
}
impl DeviceRegistration {
#[must_use]
pub const fn new(
owner_id: Uuid,
token: DeviceToken,
platform: DevicePlatform,
registered_at: DateTime<Utc>,
) -> Self {
Self {
owner_id,
token,
platform,
time_zone: None,
registered_at,
}
}
#[must_use]
pub fn with_time_zone(mut self, time_zone: DeviceTimeZone) -> Self {
self.time_zone = Some(time_zone);
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RegisteredDevice {
pub owner_id: Uuid,
pub token: DeviceToken,
pub platform: DevicePlatform,
pub time_zone: Option<DeviceTimeZone>,
pub created_at: DateTime<Utc>,
pub last_registered_at: DateTime<Utc>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RegistrationOutcome {
pub evicted: u64,
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum PushStoreError {
#[error("push store failed")]
Internal(String),
}
impl PushStoreError {
pub fn internal(error: impl fmt::Display) -> Self {
Self::Internal(error.to_string())
}
}
pub type PushStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait DeviceRegistry: Send + Sync {
fn register(
&self,
registration: DeviceRegistration,
) -> PushStoreFuture<'_, Result<RegistrationOutcome, PushStoreError>>;
fn rotate(
&self,
previous: DeviceToken,
registration: DeviceRegistration,
) -> PushStoreFuture<'_, Result<RegistrationOutcome, PushStoreError>>;
fn unregister(
&self,
owner_id: Uuid,
token: DeviceToken,
) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
fn list_for_owner(
&self,
owner_id: Uuid,
) -> PushStoreFuture<'_, Result<Vec<RegisteredDevice>, PushStoreError>>;
fn invalidate(
&self,
tokens: Vec<DeviceToken>,
sent_at: DateTime<Utc>,
) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
fn erase_owner(&self, owner_id: Uuid) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
fn invalidate_dead_tokens(
&self,
outcomes: &[PushOutcome],
sent_at: DateTime<Utc>,
) -> PushStoreFuture<'_, Result<u64, PushStoreError>> {
let tokens = dead_tokens(outcomes);
if tokens.is_empty() {
return Box::pin(std::future::ready(Ok(0)));
}
self.invalidate(tokens, sent_at)
}
}
#[must_use]
pub fn dead_tokens(outcomes: &[PushOutcome]) -> Vec<DeviceToken> {
let mut tokens = outcomes
.iter()
.filter(|outcome| outcome.is_token_dead())
.map(|outcome| outcome.token.clone())
.collect::<Vec<_>>();
tokens.sort();
tokens.dedup();
tokens
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PushDeliveryStatus, PushRejection, PushTicketId};
const EXPO_TOKEN: &str = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]";
#[test]
fn a_token_accepts_visible_ascii_up_to_the_limit() {
assert!(DeviceToken::new(EXPO_TOKEN).is_ok());
assert!(DeviceToken::new("a".repeat(MAX_DEVICE_TOKEN_LENGTH)).is_ok());
for invalid in [
String::new(),
"a".repeat(MAX_DEVICE_TOKEN_LENGTH + 1),
" leading".to_owned(),
"inner space".to_owned(),
"tab\t".to_owned(),
"umlaut-ΓΌ".to_owned(),
] {
assert_eq!(
DeviceToken::new(invalid),
Err(PushValidationError::DeviceToken)
);
}
}
#[test]
fn a_token_never_appears_in_debug_output_or_errors() {
let token = DeviceToken::new(EXPO_TOKEN).expect("valid token");
let registration = DeviceRegistration::new(
Uuid::nil(),
token.clone(),
DevicePlatform::Ios,
DateTime::UNIX_EPOCH,
);
assert!(!format!("{token:?}").contains("Exponent"));
assert!(!format!("{registration:?}").contains("Exponent"));
assert_eq!(token.expose(), EXPO_TOKEN);
let error = DeviceToken::new(format!("{EXPO_TOKEN} ")).expect_err("space is invalid");
assert!(!format!("{error} {error:?}").contains("Exponent"));
}
#[test]
fn a_time_zone_accepts_iana_shaped_names_only() {
for valid in [
"UTC",
"Europe/Berlin",
"America/Argentina/Buenos_Aires",
"Etc/GMT+5",
] {
assert_eq!(
DeviceTimeZone::new(valid).map(|zone| zone.as_str().to_owned()),
Ok(valid.to_owned())
);
}
for invalid in [
String::new(),
"Europe/Berlin ".to_owned(),
"x".repeat(MAX_DEVICE_TIME_ZONE_LENGTH + 1),
"Europe;Berlin".to_owned(),
] {
assert_eq!(
DeviceTimeZone::new(invalid),
Err(PushValidationError::DeviceTimeZone)
);
}
}
#[test]
fn platforms_round_trip_through_their_stored_form() {
for platform in [DevicePlatform::Ios, DevicePlatform::Android] {
assert_eq!(DevicePlatform::parse(platform.as_str()), Some(platform));
}
assert_eq!(DevicePlatform::parse("web"), None);
}
#[test]
fn only_distinct_dead_tokens_are_invalidated() {
let outcome = |token: &str, status| PushOutcome {
token: DeviceToken::new(token).expect("valid test token"),
status,
};
let dead = || PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered);
let tokens = dead_tokens(&[
outcome("b", dead()),
outcome("a", dead()),
outcome("b", dead()),
outcome("c", PushDeliveryStatus::Delivered),
outcome(
"d",
PushDeliveryStatus::Accepted(PushTicketId::new("ticket").expect("valid ticket")),
),
outcome(
"e",
PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
),
]);
assert_eq!(
tokens.iter().map(DeviceToken::expose).collect::<Vec<_>>(),
["a", "b"]
);
}
}