feat(push): ring incoming calls on Apple PushKit devices (#2911)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-23 20:21:08 +02:00
1 parent c9754ac11a
commit b16989d567
25 files changed
+878 -83

No files matched your search

+35 -11
View File
@@ -28,6 +28,7 @@ const DEFAULT_FCM_BASE_URL: &str = "https://fcm.googleapis.com";
const DEFAULT_CLIENT_IP_HEADER_NAME: &str = "x-forwarded-for";
const APNS_PRODUCTION_BASE_URL: &str = "https://api.push.apple.com";
const APNS_DEVELOPMENT_BASE_URL: &str = "https://api.sandbox.push.apple.com";
const VOIP_TOPIC_SUFFIX: &str = ".voip";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
pub enum Mode {
@@ -72,6 +73,7 @@ impl ProviderEnvironment {
pub struct ProviderApp {
pub app_id: String,
pub topic: Option<String>,
pub voip_topic: Option<String>,
pub environment: Option<ProviderEnvironment>,
pub project_id: Option<String>,
}
@@ -107,16 +109,25 @@ pub struct ApnsConfig {
impl ApnsConfig {
pub fn topic_for(&self, app_id: &str, environment: ProviderEnvironment) -> Option<&str> {
let exact = self.apps.iter().find(|app| {
app.app_id == app_id && app.environment == Some(environment) && app.topic.is_some()
});
exact
.or_else(|| {
self.apps
.iter()
.find(|app| app.app_id == app_id && app.topic.is_some())
})
.and_then(|app| app.topic.as_deref())
self.topic_by(app_id, environment, |app| app.topic.as_deref())
}
pub fn voip_topic_for(&self, app_id: &str, environment: ProviderEnvironment) -> Option<&str> {
self.topic_by(app_id, environment, |app| app.voip_topic.as_deref())
}
fn topic_by(
&self,
app_id: &str,
environment: ProviderEnvironment,
topic: fn(&ProviderApp) -> Option<&str>,
) -> Option<&str> {
let listed = |app: &&ProviderApp| app.app_id == app_id && topic(app).is_some();
self.apps
.iter()
.find(|app| listed(app) && app.environment == Some(environment))
.or_else(|| self.apps.iter().find(listed))
.and_then(topic)
}
pub fn base_url(&self, environment: ProviderEnvironment) -> &str {
@@ -587,9 +598,22 @@ fn parse_apps(var_name: &str, raw: Option<&str>) -> anyhow::Result<Vec<ProviderA
Some(value) => Some(parse_environment(var_name, value)?),
None => None,
};
let topic = entry
.topic
.map(|topic| topic.trim().to_owned())
.filter(|topic| !topic.is_empty());
if let Some(topic) = topic.as_deref() {
anyhow::ensure!(
!topic.ends_with(VOIP_TOPIC_SUFFIX),
"{var_name} lists a topic that already ends in {VOIP_TOPIC_SUFFIX}: {topic}"
);
}
Ok(ProviderApp {
app_id,
topic: entry.topic.filter(|topic| !topic.trim().is_empty()),
voip_topic: topic
.as_deref()
.map(|topic| format!("{topic}{VOIP_TOPIC_SUFFIX}")),
topic,
environment,
project_id: entry
.project_id
+11 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::job::{ClearJob, MessageJob};
use crate::job::{ClearJob, MessageJob, RingJob};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
@@ -22,6 +22,16 @@ impl JobKey {
Self::digest(&["clear", &job.user_id, &job.channel_id, &job.message_id])
}
pub fn of_ring(job: &RingJob) -> Self {
Self::digest(&[
"ring",
&job.message_id,
&job.channel_id,
&job.user_id,
&job.started_at_ms.to_string(),
])
}
fn digest(parts: &[&str]) -> Self {
let mut hasher = Sha256::new();
for part in parts {
+130 -11
View File
@@ -2,7 +2,8 @@
use crate::dedupe::{Claim, DoneJobs, JobKey, Seen};
use crate::job::{
self, ClearJob, JobError, MessageJob, QUEUE_GROUP, SUBJECT_CLEAR, SUBJECT_MESSAGE,
self, ClearJob, JobError, MessageJob, QUEUE_GROUP, RingJob, SUBJECT_CLEAR, SUBJECT_MESSAGE,
SUBJECT_RING,
};
use crate::metrics::{JobKind, JobRejection, Provider, elapsed_ms};
use crate::payload;
@@ -10,7 +11,8 @@ use crate::providers::{self, SendOutcome};
use crate::retry::{self, RETRY_DEADLINE};
use crate::rpc::RpcError;
use crate::server::AppState;
use crate::subscription::Subscription;
use crate::subscription::{Platform, Subscription};
use crate::unix_millis;
use fluxer_svc::metrics::now_ms;
use fluxer_svc::transport::{Transport, TransportMessage, TransportSubscriber};
use futures::future::join_all;
@@ -23,6 +25,7 @@ use tokio::task::JoinSet;
use tracing::{error, info, warn};
const JOB_REPLY_DEADLINE: Duration = Duration::from_secs(90);
const RING_JOB_DEADLINE: Duration = Duration::from_secs(10);
const UNKNOWN_PROVIDER: &str = "unknown";
const OVERLOADED: &str = "overloaded";
@@ -34,6 +37,7 @@ const RUNNING: &str = "in_flight";
enum Job {
Message(Box<MessageJob>),
Clear(ClearJob),
Ring(RingJob),
}
impl Job {
@@ -41,6 +45,23 @@ impl Job {
match self {
Self::Message(_) => JobKind::Message,
Self::Clear(_) => JobKind::Clear,
Self::Ring(_) => JobKind::Ring,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Audience {
Standard,
Ring,
}
impl Audience {
fn admits(self, subscription: &Subscription) -> bool {
let voip = subscription.platform() == Some(Platform::IosApnsVoip);
match self {
Self::Standard => !voip,
Self::Ring => voip,
}
}
}
@@ -75,17 +96,29 @@ fn claim_recipients(done: &DoneJobs, job: Job) -> Claimed {
}
}
Job::Clear(job) => {
let (claims, recipients_running) = match done.claim(JobKey::of_clear(&job)) {
Seen::New(claim) => (vec![claim], false),
Seen::Done => (Vec::new(), false),
Seen::Running => (Vec::new(), true),
};
let (claims, recipients_running) = claim_one(done, JobKey::of_clear(&job));
Claimed {
job: Job::Clear(job),
claims,
recipients_running,
}
}
Job::Ring(job) => {
let (claims, recipients_running) = claim_one(done, JobKey::of_ring(&job));
Claimed {
job: Job::Ring(job),
claims,
recipients_running,
}
}
}
}
fn claim_one(done: &DoneJobs, key: JobKey) -> (Vec<Claim>, bool) {
match done.claim(key) {
Seen::New(claim) => (vec![claim], false),
Seen::Done => (Vec::new(), false),
Seen::Running => (Vec::new(), true),
}
}
@@ -127,7 +160,15 @@ pub async fn run_job_subscribers<T: Transport>(transport: T, state: Arc<AppState
done.clone(),
JobKind::Message,
),
run_subject(transport, state, admission, sends, done, JobKind::Clear),
run_subject(
transport.clone(),
Arc::clone(&state),
Arc::clone(&admission),
Arc::clone(&sends),
done.clone(),
JobKind::Clear,
),
run_subject(transport, state, admission, sends, done, JobKind::Ring),
);
}
@@ -270,6 +311,7 @@ async fn run_job(state: &AppState, sends: &Semaphore, job: Job) -> Answer {
match job {
Job::Message(job) => run_message_job(state, sends, *job).await,
Job::Clear(job) => run_clear_job(state, sends, job).await,
Job::Ring(job) => run_ring_job(state, sends, job).await,
}
};
match tokio::time::timeout(JOB_REPLY_DEADLINE, work).await {
@@ -325,7 +367,15 @@ async fn run_message_job(
)
})
.collect();
let summary = deliver(state, sends, &subscriptions, envelopes, deadline).await;
let summary = deliver(
state,
sends,
&subscriptions,
envelopes,
deadline,
Audience::Standard,
)
.await;
let duration_ms = elapsed_ms(started_ms);
info!(
@@ -362,7 +412,15 @@ async fn run_clear_job(state: &AppState, sends: &Semaphore, job: ClearJob) -> an
let envelope = payload::web_push_clear(&job, badge_of(&badges, &job.user_id));
let envelopes = vec![(job.user_id.as_str(), envelope)];
let summary = deliver(state, sends, &subscriptions, envelopes, deadline).await;
let summary = deliver(
state,
sends,
&subscriptions,
envelopes,
deadline,
Audience::Standard,
)
.await;
let duration_ms = elapsed_ms(started_ms);
info!(
@@ -384,6 +442,61 @@ async fn run_clear_job(state: &AppState, sends: &Semaphore, job: ClearJob) -> an
Ok(())
}
async fn run_ring_job(state: &AppState, sends: &Semaphore, job: RingJob) -> anyhow::Result<()> {
let started_ms = now_ms();
if job.expires_at_ms <= unix_millis() {
state.metrics.record_ring_suppressed();
info!(
kind = JobKind::Ring.label(),
user_id = %job.user_id,
channel_id = %job.channel_id,
message_id = %job.message_id,
config_version = job.config_version,
expires_at_ms = job.expires_at_ms,
"push ring dropped past its ring window"
);
state
.metrics
.record_job_completed(JobKind::Ring, elapsed_ms(started_ms));
return Ok(());
}
let deadline = Instant::now() + RING_JOB_DEADLINE;
let user_ids = std::slice::from_ref(&job.user_id);
let subscriptions = lookup(deadline, || state.rpc.push_subscriptions(user_ids)).await?;
state.metrics.record_recipients(1);
let envelopes = vec![(job.user_id.as_str(), payload::web_push_call_ring(&job))];
let summary = deliver(
state,
sends,
&subscriptions,
envelopes,
deadline,
Audience::Ring,
)
.await;
let duration_ms = elapsed_ms(started_ms);
info!(
kind = JobKind::Ring.label(),
user_id = %job.user_id,
channel_id = %job.channel_id,
message_id = %job.message_id,
config_version = job.config_version,
recipients = 1,
subscriptions = summary.subscriptions,
accepted = summary.accepted,
deleted = summary.deleted,
duration_ms,
"push job"
);
state
.metrics
.record_job_completed(JobKind::Ring, duration_ms);
Ok(())
}
async fn lookup<T, F, Fut>(deadline: Instant, mut call: F) -> Result<T, RpcError>
where
F: FnMut() -> Fut,
@@ -411,6 +524,7 @@ async fn deliver(
subscriptions: &HashMap<String, Vec<Subscription>>,
envelopes: Vec<(&str, Value)>,
deadline: Instant,
audience: Audience,
) -> Summary {
let mut pending = Vec::new();
for (user_id, envelope) in envelopes {
@@ -418,7 +532,10 @@ async fn deliver(
continue;
};
let envelope = Arc::new(envelope);
for subscription in subscriptions {
for subscription in subscriptions
.iter()
.filter(|subscription| audience.admits(subscription))
{
pending.push(send_one(
state,
sends,
@@ -522,6 +639,7 @@ fn decode(kind: JobKind, payload: &[u8]) -> Result<Job, JobError> {
match kind {
JobKind::Message => job::decode_message(payload).map(Box::new).map(Job::Message),
JobKind::Clear => job::decode_clear(payload).map(Job::Clear),
JobKind::Ring => job::decode_ring(payload).map(Job::Ring),
}
}
@@ -529,6 +647,7 @@ fn subject_of(kind: JobKind) -> &'static str {
match kind {
JobKind::Message => SUBJECT_MESSAGE,
JobKind::Clear => SUBJECT_CLEAR,
JobKind::Ring => SUBJECT_RING,
}
}
+18
View File
@@ -5,6 +5,7 @@ use thiserror::Error;
pub const SUBJECT_MESSAGE: &str = "push.job.message";
pub const SUBJECT_CLEAR: &str = "push.job.clear";
pub const SUBJECT_RING: &str = "push.job.ring";
pub const QUEUE_GROUP: &str = "fluxer-push";
const SUPPORTED_VERSION: u8 = 1;
@@ -43,6 +44,17 @@ pub struct ClearJob {
pub message_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct RingJob {
pub v: u8,
pub config_version: u64,
pub user_id: String,
pub channel_id: String,
pub message_id: String,
pub started_at_ms: i64,
pub expires_at_ms: i64,
}
#[derive(Debug, Error)]
pub enum JobError {
#[error("push job version {0} is not supported")]
@@ -69,6 +81,12 @@ pub fn decode_clear(bytes: &[u8]) -> Result<ClearJob, JobError> {
Ok(job)
}
pub fn decode_ring(bytes: &[u8]) -> Result<RingJob, JobError> {
let job: RingJob = serde_json::from_slice(bytes)?;
supported(job.v)?;
Ok(job)
}
fn supported(version: u8) -> Result<(), JobError> {
if version == SUPPORTED_VERSION {
return Ok(());
+6
View File
@@ -28,3 +28,9 @@ fn unix_seconds() -> i64 {
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
fn unix_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_millis() as i64)
}
+26 -3
View File
@@ -76,15 +76,17 @@ impl Default for Histogram {
pub enum JobKind {
Message,
Clear,
Ring,
}
impl JobKind {
pub const ALL: [Self; 2] = [Self::Message, Self::Clear];
pub const ALL: [Self; 3] = [Self::Message, Self::Clear, Self::Ring];
pub fn label(self) -> &'static str {
match self {
Self::Message => "message",
Self::Clear => "clear",
Self::Ring => "ring",
}
}
}
@@ -114,10 +116,17 @@ pub enum Provider {
UnifiedPush,
Fcm,
Apns,
ApnsVoip,
}
impl Provider {
pub const ALL: [Self; 4] = [Self::WebPush, Self::UnifiedPush, Self::Fcm, Self::Apns];
pub const ALL: [Self; 5] = [
Self::WebPush,
Self::UnifiedPush,
Self::Fcm,
Self::Apns,
Self::ApnsVoip,
];
pub fn label(self) -> &'static str {
match self {
@@ -125,6 +134,7 @@ impl Provider {
Self::UnifiedPush => "unified_push",
Self::Fcm => "fcm",
Self::Apns => "apns",
Self::ApnsVoip => "apns_voip",
}
}
}
@@ -161,15 +171,17 @@ impl SendResult {
pub enum RelayLeg {
Apns,
Fcm,
ApnsVoip,
}
impl RelayLeg {
pub const ALL: [Self; 2] = [Self::Apns, Self::Fcm];
pub const ALL: [Self; 3] = [Self::Apns, Self::Fcm, Self::ApnsVoip];
pub fn label(self) -> &'static str {
match self {
Self::Apns => "apns",
Self::Fcm => "fcm",
Self::ApnsVoip => "apns_voip",
}
}
}
@@ -350,6 +362,7 @@ pub struct Metrics {
relay_vendor_requests: [[AtomicU64; RELAY_RESULT_COUNT]; RELAY_LEG_COUNT],
relay_rejected: [AtomicU64; REASON_COUNT],
relay_bucket_drops: [AtomicU64; BUCKET_KEY_COUNT],
rings_suppressed: AtomicU64,
job_duration: [Histogram; JOB_KIND_COUNT],
rpc_duration: [Histogram; RPC_METHOD_COUNT],
send_duration: [Histogram; PROVIDER_COUNT],
@@ -383,6 +396,7 @@ impl Metrics {
RELAY_LEG_COUNT],
relay_rejected: [const { AtomicU64::new(0) }; REASON_COUNT],
relay_bucket_drops: [const { AtomicU64::new(0) }; BUCKET_KEY_COUNT],
rings_suppressed: AtomicU64::new(0),
job_duration: [const { Histogram::new() }; JOB_KIND_COUNT],
rpc_duration: [const { Histogram::new() }; RPC_METHOD_COUNT],
send_duration: [const { Histogram::new() }; PROVIDER_COUNT],
@@ -470,6 +484,10 @@ impl Metrics {
self.relay_bucket_drops[key as usize].fetch_add(1, ORDERING);
}
pub fn record_ring_suppressed(&self) {
self.rings_suppressed.fetch_add(1, ORDERING);
}
pub fn set_queue_depth(&self, depth: u64) {
self.queue_depth.store(depth, ORDERING);
}
@@ -607,6 +625,11 @@ impl Metrics {
BucketKey::ALL.map(BucketKey::label),
&self.relay_bucket_drops,
)?;
render_counter(
out,
"fluxer_push_rings_suppressed_total",
&self.rings_suppressed,
)?;
render_gauge(out, "fluxer_push_queue_depth", &self.queue_depth)?;
render_gauge(out, "fluxer_push_rollout_enabled", &self.rollout_enabled)?;
render_gauge(
+38 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::job::{ClearJob, MessageJob};
use crate::job::{ClearJob, MessageJob, RingJob};
use crate::metrics::PayloadShrink;
use crate::unix_seconds;
use serde_json::{Map, Value, json};
@@ -8,6 +8,7 @@ use serde_json::{Map, Value, json};
const WEB_PUSH_MARKER: u64 = 8030;
const CLEAR_TYPE: &str = "notification_clear";
const CLEAR_ACTION: &str = "clear_channel";
const RING_TYPE: &str = "call_ring";
const FALLBACK_TAG: &str = "fluxer-message";
const FALLBACK_TITLE: &str = "Fluxer";
const APNS_CATEGORY: &str = "FLUXER_MESSAGE";
@@ -104,6 +105,21 @@ pub fn web_push_clear(job: &ClearJob, badge_count: u32) -> Value {
})
}
pub fn web_push_call_ring(job: &RingJob) -> Value {
let data = json!({
"type": RING_TYPE,
"channel_id": job.channel_id,
"message_id": job.message_id,
"target_user_id": job.user_id,
"started_at_ms": job.started_at_ms,
});
json!({
"web_push": WEB_PUSH_MARKER,
"type": RING_TYPE,
"data": data,
})
}
pub fn fcm_message(device_token: &str, envelope: &Value) -> Value {
if is_clear(envelope) {
return fcm_clear_message(device_token, envelope);
@@ -352,9 +368,28 @@ fn badge_number(value: Option<&Value>) -> Option<u64> {
}
}
pub fn is_clear(envelope: &Value) -> bool {
envelope.get("type").and_then(Value::as_str) == Some(CLEAR_TYPE)
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RecordKind {
Message,
Clear,
Ring,
}
pub fn record_kind(envelope: &Value) -> RecordKind {
let kind = envelope.get("type").and_then(Value::as_str);
if kind == Some(RING_TYPE) {
return RecordKind::Ring;
}
if kind == Some(CLEAR_TYPE)
|| envelope.get("action").and_then(Value::as_str) == Some(CLEAR_ACTION)
{
return RecordKind::Clear;
}
RecordKind::Message
}
pub fn is_clear(envelope: &Value) -> bool {
matches!(record_kind(envelope), RecordKind::Clear)
}
pub fn fit(envelope: &Value, budget: usize) -> (Vec<u8>, Option<PayloadShrink>) {
+3
View File
@@ -75,8 +75,10 @@ pub fn route_of(sub: &Subscription) -> Option<Route> {
Platform::WebPush | Platform::AndroidUnifiedPush => Some(Route::WebPush),
Platform::IosApns if sub.is_web_push_registration() => Some(Route::WebPush),
Platform::AndroidFcm if sub.is_web_push_registration() => Some(Route::WebPush),
Platform::IosApnsVoip if sub.is_web_push_registration() => Some(Route::WebPush),
Platform::IosApns => Some(Route::LegacyApns),
Platform::AndroidFcm => Some(Route::LegacyFcm),
Platform::IosApnsVoip => None,
}
}
@@ -115,6 +117,7 @@ pub fn provider_of(platform: Platform) -> Provider {
Platform::AndroidUnifiedPush => Provider::UnifiedPush,
Platform::AndroidFcm => Provider::Fcm,
Platform::IosApns => Provider::Apns,
Platform::IosApnsVoip => Provider::ApnsVoip,
}
}
+13 -14
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::crypto;
use crate::payload;
use crate::payload::{self, RecordKind};
use crate::providers::SendOutcome;
use crate::resolver;
use crate::server::AppState;
@@ -27,6 +27,7 @@ const BASE_RETRY_DELAY_MS: u64 = 200;
const MAX_RETRY_DELAY_MS: u64 = 2_000;
const ALERT_TTL_SECONDS: &str = "86400";
const CLEAR_TTL_SECONDS: &str = "3600";
const RING_TTL_SECONDS: &str = "0";
const TTL_HEADER: &str = "TTL";
const URGENCY_HEADER: &str = "Urgency";
const ALERT_URGENCY: &str = "high";
@@ -71,25 +72,15 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
Ok(body) => body,
Err(error) => return SendOutcome::permanent(format!("encrypt: {error}")),
};
let clear = payload::is_clear(envelope);
let (ttl_seconds, urgency) = delivery_headers(envelope);
let mut attempt: u32 = 0;
loop {
let response = state
.web_push_http
.post(&sub.endpoint)
.header(
TTL_HEADER,
if clear {
CLEAR_TTL_SECONDS
} else {
ALERT_TTL_SECONDS
},
)
.header(
URGENCY_HEADER,
if clear { CLEAR_URGENCY } else { ALERT_URGENCY },
)
.header(TTL_HEADER, ttl_seconds)
.header(URGENCY_HEADER, urgency)
.header(CONTENT_TYPE, OCTET_STREAM)
.header(CONTENT_ENCODING, AES128GCM)
.header(AUTHORIZATION, &authorization)
@@ -117,6 +108,14 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
}
}
fn delivery_headers(envelope: &Value) -> (&'static str, &'static str) {
match payload::record_kind(envelope) {
RecordKind::Message => (ALERT_TTL_SECONDS, ALERT_URGENCY),
RecordKind::Clear => (CLEAR_TTL_SECONDS, CLEAR_URGENCY),
RecordKind::Ring => (RING_TTL_SECONDS, ALERT_URGENCY),
}
}
fn classify(status: u16) -> SendOutcome {
match status {
200..=299 => SendOutcome::Accepted,
+23
View File
@@ -16,6 +16,9 @@ const APNS_PRIORITY_HEADER: &str = "apns-priority";
const APNS_EXPIRATION_HEADER: &str = "apns-expiration";
const CONTENT_TYPE_HEADER: &str = "content-type";
const JSON_CONTENT_TYPE: &str = "application/json";
const VOIP_PUSH_TYPE: &str = "voip";
const VOIP_PRIORITY: &str = "10";
const VOIP_EXPIRATION: &str = "0";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Urgency {
@@ -63,6 +66,26 @@ pub fn apns_body(payload: &str, urgency: Urgency) -> Result<Vec<u8>, Rejection>
Ok(body)
}
pub fn apns_voip_body(payload: &str) -> Result<Vec<u8>, Rejection> {
let body = serialize(&json!({"aps": {}, "v": FORMAT_VERSION, "p": payload}));
if body.len() > APNS_BODY_MAX_BYTES {
return Err(Rejection::new(Reason::PayloadTooLarge));
}
Ok(body)
}
pub fn apns_voip_headers() -> Vec<(String, String)> {
vec![
(APNS_PUSH_TYPE_HEADER.to_owned(), VOIP_PUSH_TYPE.to_owned()),
(APNS_PRIORITY_HEADER.to_owned(), VOIP_PRIORITY.to_owned()),
(
APNS_EXPIRATION_HEADER.to_owned(),
VOIP_EXPIRATION.to_owned(),
),
(CONTENT_TYPE_HEADER.to_owned(), JSON_CONTENT_TYPE.to_owned()),
]
}
pub fn apns_headers(urgency: Urgency, now_unix: i64, ttl_seconds: i64) -> Vec<(String, String)> {
let (push_type, priority) = match urgency {
Urgency::Alert => ("alert", "10"),
+47 -13
View File
@@ -29,6 +29,7 @@ use std::time::{Duration, Instant};
use tracing::{info, warn};
pub const APNS_ROUTE: &str = "/relay/v1/apns/{app_id}/{environment}/{device_token}";
pub const APNS_VOIP_ROUTE: &str = "/relay/v1/apns-voip/{app_id}/{environment}/{device_token}";
pub const FCM_ROUTE: &str = "/relay/v1/fcm/{app_id}/{device_token}";
const AES128GCM: &str = "aes128gcm";
@@ -103,6 +104,7 @@ pub fn router(state: Arc<AppState>) -> Router {
let sidecar = Arc::clone(&state.sidecar);
Router::new()
.route(APNS_ROUTE, post(apns_route))
.route(APNS_VOIP_ROUTE, post(apns_voip_route))
.route(FCM_ROUTE, post(fcm_route))
.fallback(unmatched_route)
.with_state(state)
@@ -118,17 +120,37 @@ async fn unmatched_route(State(state): State<Arc<AppState>>) -> Response {
async fn apns_route(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((app_id, environment, device_token)): Path<(String, String, String)>,
Path(path): Path<(String, String, String)>,
headers: HeaderMap,
body: Body,
) -> Response {
apns_leg(&state, RelayLeg::Apns, peer, path, headers, body).await
}
async fn apns_voip_route(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(path): Path<(String, String, String)>,
headers: HeaderMap,
body: Body,
) -> Response {
apns_leg(&state, RelayLeg::ApnsVoip, peer, path, headers, body).await
}
async fn apns_leg(
state: &AppState,
leg: RelayLeg,
peer: SocketAddr,
(app_id, environment, device_token): (String, String, String),
headers: HeaderMap,
body: Body,
) -> Response {
let environment = ProviderEnvironment::from_label(&environment);
relay(
&state,
state,
Incoming {
leg: RelayLeg::Apns,
leg,
app_id,
environment,
environment: ProviderEnvironment::from_label(&environment),
device_token,
peer,
},
@@ -271,14 +293,16 @@ async fn forward(
fn resolve<'a>(state: &'a AppState, incoming: &Incoming) -> Result<Target<'a>, Rejection> {
let unknown = Rejection::new(Reason::AppUnknown);
match incoming.leg {
RelayLeg::Apns => {
RelayLeg::Apns | RelayLeg::ApnsVoip => {
let cfg = state.cfg.apns.as_ref().ok_or(unknown)?;
let environment = incoming.environment.ok_or(unknown)?;
let topic = match incoming.leg {
RelayLeg::ApnsVoip => cfg.voip_topic_for(&incoming.app_id, environment),
_ => cfg.topic_for(&incoming.app_id, environment),
};
Ok(Target::Apns {
environment,
topic: cfg
.topic_for(&incoming.app_id, environment)
.ok_or(unknown)?,
topic: topic.ok_or(unknown)?,
})
}
RelayLeg::Fcm => {
@@ -303,12 +327,22 @@ async fn send_apns(
.apns
.as_ref()
.ok_or(Rejection::new(Reason::AppUnknown))?;
let (headers, body) = match incoming.leg {
RelayLeg::ApnsVoip => (
envelope::apns_voip_headers(),
envelope::apns_voip_body(payload)?,
),
_ => (
envelope::apns_headers(delivery.urgency, unix_seconds(), delivery.ttl_seconds),
envelope::apns_body(payload, delivery.urgency)?,
),
};
let request = ApnsRequest {
environment,
topic,
device_token: &incoming.device_token,
headers: &envelope::apns_headers(delivery.urgency, unix_seconds(), delivery.ttl_seconds),
body: envelope::apns_body(payload, delivery.urgency)?,
headers: &headers,
body,
};
let outcome = vendor::send_apns(
&state.apns_http,
@@ -318,7 +352,7 @@ async fn send_apns(
request,
)
.await;
finish(state, RelayLeg::Apns, outcome)
finish(state, incoming.leg, outcome)
}
async fn send_fcm(
@@ -390,7 +424,7 @@ fn refusal_reason(refusal: &Refusal) -> Reason {
fn device_token_is_shaped(leg: RelayLeg, device_token: &str) -> bool {
match leg {
RelayLeg::Apns => {
RelayLeg::Apns | RelayLeg::ApnsVoip => {
device_token.len() == APNS_DEVICE_TOKEN_LEN
&& device_token.bytes().all(|byte| byte.is_ascii_hexdigit())
}
+2
View File
@@ -11,6 +11,7 @@ pub enum Platform {
AndroidUnifiedPush,
AndroidFcm,
IosApns,
IosApnsVoip,
}
#[derive(Clone, Debug, Deserialize)]
@@ -31,6 +32,7 @@ impl Subscription {
"android_unified_push" => Some(Platform::AndroidUnifiedPush),
"android_fcm" => Some(Platform::AndroidFcm),
"ios_apns" => Some(Platform::IosApns),
"ios_apns_voip" => Some(Platform::IosApnsVoip),
_ => None,
}
}