mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(push): ring incoming calls on Apple PushKit devices (#2911)
This commit is contained in:
1 parent
c9754ac11a
commit
b16989d567
25 files changed
+878
-83
No files matched your search
+35
-11
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user