1use std::{fmt, future::Future, num::NonZeroU32, pin::Pin};
4
5use chrono::{DateTime, Utc};
6use thiserror::Error;
7use uuid::Uuid;
8
9use crate::PushOutcome;
10
11pub const MAX_DEVICE_TOKEN_LENGTH: usize = 512;
13
14pub const MAX_DEVICE_TIME_ZONE_LENGTH: usize = 64;
16
17pub const DEFAULT_DEVICES_PER_OWNER: NonZeroU32 = NonZeroU32::new(10).expect("ten is not zero");
19
20#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
25pub enum PushValidationError {
26 #[error("device token is invalid")]
28 DeviceToken,
29 #[error("device time zone is invalid")]
31 DeviceTimeZone,
32 #[error("delivery kind is invalid")]
34 DeliveryKind,
35 #[error("push ticket id is invalid")]
37 TicketId,
38}
39
40#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct DeviceToken(String);
48
49impl DeviceToken {
50 pub fn new(token: impl Into<String>) -> Result<Self, PushValidationError> {
59 let token = token.into();
60 let valid = !token.is_empty()
61 && token.len() <= MAX_DEVICE_TOKEN_LENGTH
62 && token.bytes().all(|byte| byte.is_ascii_graphic());
63 if valid {
64 Ok(Self(token))
65 } else {
66 Err(PushValidationError::DeviceToken)
67 }
68 }
69
70 #[must_use]
72 pub fn expose(&self) -> &str {
73 &self.0
74 }
75}
76
77impl fmt::Debug for DeviceToken {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 formatter.write_str("DeviceToken(<redacted>)")
80 }
81}
82
83#[derive(Clone, Debug, Eq, Hash, PartialEq)]
88pub struct DeviceTimeZone(String);
89
90impl DeviceTimeZone {
91 pub fn new(name: impl Into<String>) -> Result<Self, PushValidationError> {
99 let name = name.into();
100 let valid = !name.is_empty()
101 && name.len() <= MAX_DEVICE_TIME_ZONE_LENGTH
102 && name
103 .bytes()
104 .all(|byte| byte.is_ascii_alphanumeric() || b"/_+-".contains(&byte));
105 if valid {
106 Ok(Self(name))
107 } else {
108 Err(PushValidationError::DeviceTimeZone)
109 }
110 }
111
112 #[must_use]
114 pub fn as_str(&self) -> &str {
115 &self.0
116 }
117}
118
119#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
121pub enum DevicePlatform {
122 Ios,
124 Android,
126}
127
128impl DevicePlatform {
129 #[must_use]
131 pub const fn as_str(self) -> &'static str {
132 match self {
133 Self::Ios => "ios",
134 Self::Android => "android",
135 }
136 }
137
138 #[must_use]
140 pub fn parse(value: &str) -> Option<Self> {
141 match value {
142 "ios" => Some(Self::Ios),
143 "android" => Some(Self::Android),
144 _ => None,
145 }
146 }
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct DeviceRegistration {
152 pub owner_id: Uuid,
154 pub token: DeviceToken,
156 pub platform: DevicePlatform,
158 pub time_zone: Option<DeviceTimeZone>,
160 pub registered_at: DateTime<Utc>,
162}
163
164impl DeviceRegistration {
165 #[must_use]
167 pub const fn new(
168 owner_id: Uuid,
169 token: DeviceToken,
170 platform: DevicePlatform,
171 registered_at: DateTime<Utc>,
172 ) -> Self {
173 Self {
174 owner_id,
175 token,
176 platform,
177 time_zone: None,
178 registered_at,
179 }
180 }
181
182 #[must_use]
184 pub fn with_time_zone(mut self, time_zone: DeviceTimeZone) -> Self {
185 self.time_zone = Some(time_zone);
186 self
187 }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct RegisteredDevice {
193 pub owner_id: Uuid,
195 pub token: DeviceToken,
197 pub platform: DevicePlatform,
199 pub time_zone: Option<DeviceTimeZone>,
201 pub created_at: DateTime<Utc>,
203 pub last_registered_at: DateTime<Utc>,
205}
206
207#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
209pub struct RegistrationOutcome {
210 pub evicted: u64,
212}
213
214#[derive(Clone, Debug, Eq, Error, PartialEq)]
220pub enum PushStoreError {
221 #[error("push store failed")]
223 Internal(String),
224}
225
226impl PushStoreError {
227 pub fn internal(error: impl fmt::Display) -> Self {
229 Self::Internal(error.to_string())
230 }
231}
232
233pub type PushStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
235
236pub trait DeviceRegistry: Send + Sync {
245 fn register(
247 &self,
248 registration: DeviceRegistration,
249 ) -> PushStoreFuture<'_, Result<RegistrationOutcome, PushStoreError>>;
250
251 fn rotate(
257 &self,
258 previous: DeviceToken,
259 registration: DeviceRegistration,
260 ) -> PushStoreFuture<'_, Result<RegistrationOutcome, PushStoreError>>;
261
262 fn unregister(
264 &self,
265 owner_id: Uuid,
266 token: DeviceToken,
267 ) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
268
269 fn list_for_owner(
271 &self,
272 owner_id: Uuid,
273 ) -> PushStoreFuture<'_, Result<Vec<RegisteredDevice>, PushStoreError>>;
274
275 fn invalidate(
281 &self,
282 tokens: Vec<DeviceToken>,
283 sent_at: DateTime<Utc>,
284 ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
285
286 fn erase_owner(&self, owner_id: Uuid) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
288
289 fn invalidate_dead_tokens(
294 &self,
295 outcomes: &[PushOutcome],
296 sent_at: DateTime<Utc>,
297 ) -> PushStoreFuture<'_, Result<u64, PushStoreError>> {
298 let tokens = dead_tokens(outcomes);
299 if tokens.is_empty() {
300 return Box::pin(std::future::ready(Ok(0)));
301 }
302 self.invalidate(tokens, sent_at)
303 }
304}
305
306#[must_use]
308pub fn dead_tokens(outcomes: &[PushOutcome]) -> Vec<DeviceToken> {
309 let mut tokens = outcomes
310 .iter()
311 .filter(|outcome| outcome.is_token_dead())
312 .map(|outcome| outcome.token.clone())
313 .collect::<Vec<_>>();
314 tokens.sort();
315 tokens.dedup();
316 tokens
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::{PushDeliveryStatus, PushRejection, PushTicketId};
323
324 const EXPO_TOKEN: &str = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]";
325
326 #[test]
327 fn a_token_accepts_visible_ascii_up_to_the_limit() {
328 assert!(DeviceToken::new(EXPO_TOKEN).is_ok());
329 assert!(DeviceToken::new("a".repeat(MAX_DEVICE_TOKEN_LENGTH)).is_ok());
330 for invalid in [
331 String::new(),
332 "a".repeat(MAX_DEVICE_TOKEN_LENGTH + 1),
333 " leading".to_owned(),
334 "inner space".to_owned(),
335 "tab\t".to_owned(),
336 "umlaut-ΓΌ".to_owned(),
337 ] {
338 assert_eq!(
339 DeviceToken::new(invalid),
340 Err(PushValidationError::DeviceToken)
341 );
342 }
343 }
344
345 #[test]
346 fn a_token_never_appears_in_debug_output_or_errors() {
347 let token = DeviceToken::new(EXPO_TOKEN).expect("valid token");
348 let registration = DeviceRegistration::new(
349 Uuid::nil(),
350 token.clone(),
351 DevicePlatform::Ios,
352 DateTime::UNIX_EPOCH,
353 );
354 assert!(!format!("{token:?}").contains("Exponent"));
355 assert!(!format!("{registration:?}").contains("Exponent"));
356 assert_eq!(token.expose(), EXPO_TOKEN);
357
358 let error = DeviceToken::new(format!("{EXPO_TOKEN} ")).expect_err("space is invalid");
359 assert!(!format!("{error} {error:?}").contains("Exponent"));
360 }
361
362 #[test]
363 fn a_time_zone_accepts_iana_shaped_names_only() {
364 for valid in [
365 "UTC",
366 "Europe/Berlin",
367 "America/Argentina/Buenos_Aires",
368 "Etc/GMT+5",
369 ] {
370 assert_eq!(
371 DeviceTimeZone::new(valid).map(|zone| zone.as_str().to_owned()),
372 Ok(valid.to_owned())
373 );
374 }
375 for invalid in [
376 String::new(),
377 "Europe/Berlin ".to_owned(),
378 "x".repeat(MAX_DEVICE_TIME_ZONE_LENGTH + 1),
379 "Europe;Berlin".to_owned(),
380 ] {
381 assert_eq!(
382 DeviceTimeZone::new(invalid),
383 Err(PushValidationError::DeviceTimeZone)
384 );
385 }
386 }
387
388 #[test]
389 fn platforms_round_trip_through_their_stored_form() {
390 for platform in [DevicePlatform::Ios, DevicePlatform::Android] {
391 assert_eq!(DevicePlatform::parse(platform.as_str()), Some(platform));
392 }
393 assert_eq!(DevicePlatform::parse("web"), None);
394 }
395
396 #[test]
397 fn only_distinct_dead_tokens_are_invalidated() {
398 let outcome = |token: &str, status| PushOutcome {
399 token: DeviceToken::new(token).expect("valid test token"),
400 status,
401 };
402 let dead = || PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered);
403 let tokens = dead_tokens(&[
404 outcome("b", dead()),
405 outcome("a", dead()),
406 outcome("b", dead()),
407 outcome("c", PushDeliveryStatus::Delivered),
408 outcome(
409 "d",
410 PushDeliveryStatus::Accepted(PushTicketId::new("ticket").expect("valid ticket")),
411 ),
412 outcome(
413 "e",
414 PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
415 ),
416 ]);
417 assert_eq!(
418 tokens.iter().map(DeviceToken::expose).collect::<Vec<_>>(),
419 ["a", "b"]
420 );
421 }
422}