mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -279,14 +279,11 @@ async fn run_subject<T: Transport>(
|
|||||||
let job_transport = transport.clone();
|
let job_transport = transport.clone();
|
||||||
running.spawn(async move {
|
running.spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let mut result = run_job(&job_state, &job_sends, job).await;
|
let result = run_job(&job_state, &job_sends, job).await;
|
||||||
if matches!(result, Answer::Done) {
|
if matches!(result, Answer::Done) {
|
||||||
for claim in claims {
|
for claim in claims {
|
||||||
claim.done();
|
claim.done();
|
||||||
}
|
}
|
||||||
if recipients_running {
|
|
||||||
result = Answer::NotDone(RUNNING);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
answer(&job_transport, reply_to, result).await;
|
answer(&job_transport, reply_to, result).await;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const CLEAR_ACTION: &str = "clear_channel";
|
|||||||
const RING_TYPE: &str = "call_ring";
|
const RING_TYPE: &str = "call_ring";
|
||||||
const FALLBACK_TAG: &str = "fluxer-message";
|
const FALLBACK_TAG: &str = "fluxer-message";
|
||||||
const FALLBACK_TITLE: &str = "Fluxer";
|
const FALLBACK_TITLE: &str = "Fluxer";
|
||||||
const APNS_CATEGORY: &str = "FLUXER_MESSAGE";
|
const APNS_CATEGORY: &str = "fluxer_message";
|
||||||
|
const FCM_CLICK_ACTION: &str = "FLUXER_MESSAGE";
|
||||||
const APNS_SOUND: &str = "default";
|
const APNS_SOUND: &str = "default";
|
||||||
const APNS_ALERT_EXPIRATION_SECONDS: i64 = 86_400;
|
const APNS_ALERT_EXPIRATION_SECONDS: i64 = 86_400;
|
||||||
const APNS_BACKGROUND_EXPIRATION_SECONDS: i64 = 86_400;
|
const APNS_BACKGROUND_EXPIRATION_SECONDS: i64 = 86_400;
|
||||||
@@ -25,7 +26,9 @@ const RING_CALLER_NAME_KEY: &str = "caller_name";
|
|||||||
const RING_CALLER_AVATAR_KEY: &str = "caller_avatar_url";
|
const RING_CALLER_AVATAR_KEY: &str = "caller_avatar_url";
|
||||||
const RING_AVATAR_KEYS: [&str; 1] = [RING_CALLER_AVATAR_KEY];
|
const RING_AVATAR_KEYS: [&str; 1] = [RING_CALLER_AVATAR_KEY];
|
||||||
const RING_IDENTITY_KEYS: [&str; 2] = [RING_CALLER_ID_KEY, RING_CALLER_NAME_KEY];
|
const RING_IDENTITY_KEYS: [&str; 2] = [RING_CALLER_ID_KEY, RING_CALLER_NAME_KEY];
|
||||||
const MINIMAL_DATA_KEYS: [&str; 7] = [
|
const MINIMAL_DATA_KEYS: [&str; 9] = [
|
||||||
|
"type",
|
||||||
|
"action",
|
||||||
"channel_id",
|
"channel_id",
|
||||||
"message_id",
|
"message_id",
|
||||||
"guild_id",
|
"guild_id",
|
||||||
@@ -201,7 +204,7 @@ fn fcm_notification_message(device_token: &str, envelope: &Value) -> Value {
|
|||||||
let mut android_notification = json!({
|
let mut android_notification = json!({
|
||||||
"channel_id": "fluxer_default_push",
|
"channel_id": "fluxer_default_push",
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
"click_action": APNS_CATEGORY,
|
"click_action": FCM_CLICK_ACTION,
|
||||||
});
|
});
|
||||||
put_image(&mut android_notification, image_url);
|
put_image(&mut android_notification, image_url);
|
||||||
json!({
|
json!({
|
||||||
@@ -262,9 +265,7 @@ pub fn apns_payload(envelope: &Value) -> Value {
|
|||||||
if let Some(badge) = badge_number(data.and_then(|data| data.get("badge_count"))) {
|
if let Some(badge) = badge_number(data.and_then(|data| data.get("badge_count"))) {
|
||||||
aps.insert("badge".to_owned(), badge.into());
|
aps.insert("badge".to_owned(), badge.into());
|
||||||
}
|
}
|
||||||
if image_url.is_some() {
|
aps.insert("mutable-content".to_owned(), 1.into());
|
||||||
aps.insert("mutable-content".to_owned(), 1.into());
|
|
||||||
}
|
|
||||||
payload.insert("title".to_owned(), title.into());
|
payload.insert("title".to_owned(), title.into());
|
||||||
payload.insert("body".to_owned(), body.into());
|
payload.insert("body".to_owned(), body.into());
|
||||||
payload.remove("url");
|
payload.remove("url");
|
||||||
@@ -591,3 +592,95 @@ fn non_empty(value: &Value) -> Option<&str> {
|
|||||||
fn serialize(value: &Value) -> Vec<u8> {
|
fn serialize(value: &Value) -> Vec<u8> {
|
||||||
serde_json::to_vec(value).expect("a json value serialises")
|
serde_json::to_vec(value).expect("a json value serialises")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::job::{ClearJob, MessageJob, NotificationFields};
|
||||||
|
|
||||||
|
const CHANNEL_ID: &str = "9876543210987654321";
|
||||||
|
const MESSAGE_ID: &str = "1122334455667788990";
|
||||||
|
const USER_ID: &str = "1234567890123456789";
|
||||||
|
|
||||||
|
fn message_job(image_url: Option<&str>) -> MessageJob {
|
||||||
|
MessageJob {
|
||||||
|
v: 1,
|
||||||
|
config_version: 7,
|
||||||
|
guild_id: "0".to_owned(),
|
||||||
|
channel_id: CHANNEL_ID.to_owned(),
|
||||||
|
message_id: MESSAGE_ID.to_owned(),
|
||||||
|
notification: NotificationFields {
|
||||||
|
title: "Elias".to_owned(),
|
||||||
|
body: "see you tomorrow".to_owned(),
|
||||||
|
icon: "https://media.fluxer.app/avatars/1/a.webp".to_owned(),
|
||||||
|
badge: "https://media.fluxer.app/badge.png".to_owned(),
|
||||||
|
tag: format!("channel:{CHANNEL_ID}:{MESSAGE_ID}"),
|
||||||
|
notification_tag: format!("channel:{CHANNEL_ID}"),
|
||||||
|
url: format!("/channels/@me/{CHANNEL_ID}"),
|
||||||
|
image_url: image_url.map(str::to_owned),
|
||||||
|
},
|
||||||
|
user_ids: vec![USER_ID.to_owned()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_job() -> ClearJob {
|
||||||
|
ClearJob {
|
||||||
|
v: 1,
|
||||||
|
config_version: 7,
|
||||||
|
user_id: USER_ID.to_owned(),
|
||||||
|
channel_id: CHANNEL_ID.to_owned(),
|
||||||
|
message_id: MESSAGE_ID.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apns_for(image_url: Option<&str>) -> Value {
|
||||||
|
apns_payload(&web_push_message(&message_job(image_url), USER_ID, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_without_an_image_still_runs_the_notification_service_extension() {
|
||||||
|
assert_eq!(apns_for(None)["aps"]["mutable-content"], json!(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_with_an_image_still_runs_the_notification_service_extension() {
|
||||||
|
assert_eq!(
|
||||||
|
apns_for(Some("https://media.fluxer.app/proxy/attachment.webp"))["aps"]["mutable-content"],
|
||||||
|
json!(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_background_clear_never_runs_the_notification_service_extension() {
|
||||||
|
let payload = apns_payload(&web_push_clear(&clear_job(), 3));
|
||||||
|
assert_eq!(payload["aps"], json!({"content-available": 1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_apns_category_is_the_identifier_the_app_registers() {
|
||||||
|
assert_eq!(apns_for(None)["aps"]["category"], json!("fluxer_message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_android_click_action_keeps_its_own_identifier() {
|
||||||
|
let message = fcm_message(
|
||||||
|
"device-token",
|
||||||
|
&web_push_message(&message_job(None), USER_ID, 3),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
message["message"]["android"]["notification"]["click_action"],
|
||||||
|
json!("FLUXER_MESSAGE")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shrunk_clear_still_tells_the_client_it_is_a_clear() {
|
||||||
|
let envelope = web_push_clear(&clear_job(), 3);
|
||||||
|
let budget = serialize(&envelope).len() - 1;
|
||||||
|
let (bytes, step) = fit(&envelope, budget);
|
||||||
|
assert_eq!(step, Some(PayloadShrink::Minimal));
|
||||||
|
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
|
||||||
|
assert_eq!(shrunk["data"]["type"], json!(CLEAR_TYPE));
|
||||||
|
assert_eq!(shrunk["data"]["action"], json!(CLEAR_ACTION));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,15 +91,12 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
|||||||
return SendOutcome::permanent("unsupported_platform");
|
return SendOutcome::permanent("unsupported_platform");
|
||||||
};
|
};
|
||||||
let started_ms = now_ms();
|
let started_ms = now_ms();
|
||||||
let direct = own_relay::parse(&sub.endpoint, &state.cfg.own_relay_hosts);
|
let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts);
|
||||||
let outcome = match (route, direct) {
|
let outcome = match (route, direct) {
|
||||||
(Route::WebPush, Some(hop)) => {
|
(Route::WebPush, Some(hop)) => {
|
||||||
let hopped = hop.as_subscription(sub);
|
let hopped = hop.as_subscription(sub);
|
||||||
state.metrics.record_own_relay_shortcut();
|
state.metrics.record_own_relay_shortcut();
|
||||||
match hop.leg {
|
apns::send(state, &hopped, envelope).await
|
||||||
own_relay::Leg::Fcm => fcm::send(state, &hopped, envelope).await,
|
|
||||||
_ => apns::send(state, &hopped, envelope).await,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(Route::WebPush, None) => web_push::send(state, sub, envelope).await,
|
(Route::WebPush, None) => web_push::send(state, sub, envelope).await,
|
||||||
(Route::LegacyApns, _) => apns::send(state, sub, envelope).await,
|
(Route::LegacyApns, _) => apns::send(state, sub, envelope).await,
|
||||||
@@ -116,6 +113,10 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
|||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn in_process_hop(endpoint: &str, hosts: &[String]) -> Option<own_relay::Hop> {
|
||||||
|
own_relay::parse(endpoint, hosts).filter(|hop| matches!(hop.leg, own_relay::Leg::Apns))
|
||||||
|
}
|
||||||
|
|
||||||
fn route_label(route: Route) -> DeliveryRoute {
|
fn route_label(route: Route) -> DeliveryRoute {
|
||||||
match route {
|
match route {
|
||||||
Route::WebPush => DeliveryRoute::WebPush,
|
Route::WebPush => DeliveryRoute::WebPush,
|
||||||
@@ -142,3 +143,32 @@ fn result_of(outcome: &SendOutcome) -> SendResult {
|
|||||||
SendOutcome::Transient { .. } => SendResult::Transient,
|
SendOutcome::Transient { .. } => SendResult::Transient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod hop_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TOKEN: &str = "3dbc5a5ef1a1c1666afc26f466e1b3ebaaf4c66d92dddeb0fd1b69c49641d4cd";
|
||||||
|
|
||||||
|
fn ours() -> Vec<String> {
|
||||||
|
vec!["push.fluxer.com".to_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_plain_apns_leg_is_delivered_in_process() {
|
||||||
|
let apns = format!("https://push.fluxer.com/relay/v1/apns/canary/production/{TOKEN}");
|
||||||
|
assert!(in_process_hop(&apns, &ours()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_fcm_relay_endpoint_keeps_its_encrypted_network_hop() {
|
||||||
|
let fcm = "https://push.fluxer.com/relay/v1/fcm/canary/tok%3AAPA91bExample";
|
||||||
|
assert!(in_process_hop(fcm, &ours()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pushkit_relay_endpoint_keeps_its_voip_topic() {
|
||||||
|
let voip = format!("https://push.fluxer.com/relay/v1/apns-voip/canary/production/{TOKEN}");
|
||||||
|
assert!(in_process_hop(&voip, &ours()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,27 @@ mod tests {
|
|||||||
assert_eq!(hop.device_token, "dYC_x9gXTjyyrG8_Aw3nUM:APA91bExample");
|
assert_eq!(hop.device_token, "dYC_x9gXTjyyrG8_Aw3nUM:APA91bExample");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_voip_endpoint_on_our_own_relay_keeps_its_own_leg() {
|
||||||
|
let hop = parse(
|
||||||
|
&format!("https://push.fluxer.com/relay/v1/apns-voip/canary/production/{TOKEN}"),
|
||||||
|
&ours(),
|
||||||
|
)
|
||||||
|
.expect("a voip endpoint parses");
|
||||||
|
assert_eq!(hop.leg, Leg::ApnsVoip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_apns_alert_leg_is_taken_in_process() {
|
||||||
|
let shortcut = |path: &str| {
|
||||||
|
parse(&format!("https://push.fluxer.com/relay/v1/{path}"), &ours())
|
||||||
|
.filter(|hop| matches!(hop.leg, Leg::Apns))
|
||||||
|
};
|
||||||
|
assert!(shortcut(&format!("apns/canary/production/{TOKEN}")).is_some());
|
||||||
|
assert!(shortcut(&format!("apns-voip/canary/production/{TOKEN}")).is_none());
|
||||||
|
assert!(shortcut("fcm/canary/dYC_x9gXTjyyrG8_Aw3nUM%3AAPA91bExample").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_relay_we_do_not_operate_is_left_on_the_network_path() {
|
fn a_relay_we_do_not_operate_is_left_on_the_network_path() {
|
||||||
let endpoint = format!("https://push.example.org/relay/v1/apns/canary/production/{TOKEN}");
|
let endpoint = format!("https://push.example.org/relay/v1/apns/canary/production/{TOKEN}");
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const OCTET_STREAM: &str = "application/octet-stream";
|
|||||||
const AES128GCM: &str = "aes128gcm";
|
const AES128GCM: &str = "aes128gcm";
|
||||||
const NOT_FOUND: u16 = 404;
|
const NOT_FOUND: u16 = 404;
|
||||||
const GONE: u16 = 410;
|
const GONE: u16 = 410;
|
||||||
|
const INSUFFICIENT_STORAGE: u16 = 507;
|
||||||
const MAX_HOSTNAME_BYTES: usize = 253;
|
const MAX_HOSTNAME_BYTES: usize = 253;
|
||||||
const MAX_LABEL_BYTES: usize = 63;
|
const MAX_LABEL_BYTES: usize = 63;
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if is_transient_status(status) && attempt < MAX_TRANSIENT_RETRIES {
|
if should_retry(status, attempt) {
|
||||||
tokio::time::sleep(retry_delay(attempt)).await;
|
tokio::time::sleep(retry_delay(attempt)).await;
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -127,6 +128,10 @@ fn delivery_headers(envelope: &Value) -> (&'static str, &'static str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_retry(status: u16, attempt: u32) -> bool {
|
||||||
|
is_transient_status(status) && status != INSUFFICIENT_STORAGE && attempt < MAX_TRANSIENT_RETRIES
|
||||||
|
}
|
||||||
|
|
||||||
fn classify(status: u16) -> SendOutcome {
|
fn classify(status: u16) -> SendOutcome {
|
||||||
match status {
|
match status {
|
||||||
200..=299 => SendOutcome::Accepted,
|
200..=299 => SendOutcome::Accepted,
|
||||||
@@ -134,6 +139,7 @@ fn classify(status: u16) -> SendOutcome {
|
|||||||
NOT_FOUND => SendOutcome::TokenInvalid {
|
NOT_FOUND => SendOutcome::TokenInvalid {
|
||||||
reason: "not_found",
|
reason: "not_found",
|
||||||
},
|
},
|
||||||
|
INSUFFICIENT_STORAGE => SendOutcome::permanent("http_507"),
|
||||||
_ if is_transient_status(status) => SendOutcome::transient(format!("http_{status}")),
|
_ if is_transient_status(status) => SendOutcome::transient(format!("http_{status}")),
|
||||||
_ => SendOutcome::permanent(format!("http_{status}")),
|
_ => SendOutcome::permanent(format!("http_{status}")),
|
||||||
}
|
}
|
||||||
@@ -208,3 +214,43 @@ fn retry_delay(attempt: u32) -> Duration {
|
|||||||
let jitter = rand::rng().random_range(1..=(base / 4).max(1));
|
let jitter = rand::rng().random_range(1..=(base / 4).max(1));
|
||||||
Duration::from_millis(MAX_RETRY_DELAY_MS.min(base + jitter - 1))
|
Duration::from_millis(MAX_RETRY_DELAY_MS.min(base + jitter - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_unified_push_topic_with_no_listener_is_permanent() {
|
||||||
|
assert_eq!(
|
||||||
|
classify(INSUFFICIENT_STORAGE),
|
||||||
|
SendOutcome::permanent("http_507")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unavailable_push_service_stays_retryable() {
|
||||||
|
assert_eq!(classify(503), SendOutcome::transient("http_503"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod retry_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_topic_with_no_listener_is_not_retried() {
|
||||||
|
assert!(!should_retry(INSUFFICIENT_STORAGE, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unavailable_push_service_is_retried_until_the_budget_runs_out() {
|
||||||
|
assert!(should_retry(503, 0));
|
||||||
|
assert!(!should_retry(503, MAX_TRANSIENT_RETRIES));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_permanent_status_is_never_retried() {
|
||||||
|
assert!(!should_retry(400, 0));
|
||||||
|
assert!(!should_retry(410, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ fn apns_dead_token(status: u16, reason: &str) -> Option<DeadToken> {
|
|||||||
(_, "Unregistered") => Some(DeadToken::Gone("unregistered")),
|
(_, "Unregistered") => Some(DeadToken::Gone("unregistered")),
|
||||||
(410, _) => Some(DeadToken::Gone("gone")),
|
(410, _) => Some(DeadToken::Gone("gone")),
|
||||||
(400, "BadDeviceToken") => Some(DeadToken::Invalid("bad_device_token")),
|
(400, "BadDeviceToken") => Some(DeadToken::Invalid("bad_device_token")),
|
||||||
(400, "DeviceTokenNotForTopic") => Some(DeadToken::Invalid("device_token_not_for_topic")),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +307,19 @@ mod tests {
|
|||||||
assert!(!error.without_url().to_string().contains(TOKEN));
|
assert!(!error.without_url().to_string().contains(TOKEN));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wrong_topic_is_not_a_dead_token() {
|
||||||
|
assert_eq!(apns_dead_token(400, "DeviceTokenNotForTopic"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_device_token_is_still_a_dead_token() {
|
||||||
|
assert_eq!(
|
||||||
|
apns_dead_token(400, "BadDeviceToken"),
|
||||||
|
Some(DeadToken::Invalid("bad_device_token"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_refused_connection_stays_retryable() {
|
async fn a_refused_connection_stays_retryable() {
|
||||||
let error = error_for("http://127.0.0.1:1/").await;
|
let error = error_for("http://127.0.0.1:1/").await;
|
||||||
|
|||||||
Reference in New Issue
Block a user