mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(push): ring incoming calls on Apple PushKit devices (#2911)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CLIENT_IP_HEADER_NAME = 'x-real-ip';
|
||||
|
||||
function createProductionApp(): Hono<HonoEnv> {
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
configureMiddleware(routes, {
|
||||
logger: new NoopLogger(),
|
||||
nodeEnv: 'production',
|
||||
corsOrigins: ['https://web.fluxer.app'],
|
||||
trustClientIpHeader: true,
|
||||
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
|
||||
maxInflightRequests: 100,
|
||||
torExitBlockingEnabled: false,
|
||||
});
|
||||
routes.onError(AppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
routes.post('/internal/rpc', (ctx) => ctx.json({ok: true}));
|
||||
routes.get('/connections/bluesky/jwks.json', (ctx) => ctx.json({keys: []}));
|
||||
routes.get('/users/@me', (ctx) => ctx.json({ok: true}));
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.route('/v1', routes);
|
||||
app.route('/', routes);
|
||||
app.onError(AppErrorHandler);
|
||||
app.notFound(AppNotFoundHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('client ip requirements across the production middleware pipeline', () => {
|
||||
let previousTestModeEnabled: boolean;
|
||||
let previousTrustClientIpHeader: boolean;
|
||||
let previousClientIpHeader: string;
|
||||
|
||||
beforeAll(() => {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
previousTrustClientIpHeader = Config.proxy.trust_client_ip_header;
|
||||
previousClientIpHeader = Config.proxy.client_ip_header;
|
||||
Config.dev.testModeEnabled = false;
|
||||
Config.proxy.trust_client_ip_header = true;
|
||||
Config.proxy.client_ip_header = CLIENT_IP_HEADER_NAME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
Config.proxy.trust_client_ip_header = previousTrustClientIpHeader;
|
||||
Config.proxy.client_ip_header = previousClientIpHeader;
|
||||
});
|
||||
|
||||
it('serves the internal rpc route without a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/internal/rpc', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: '{}',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves the internal rpc route with a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/internal/rpc', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json', [CLIENT_IP_HEADER_NAME]: '203.0.113.10'},
|
||||
body: '{}',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves an exempt public route without a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/connections/bluesky/jwks.json');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('still rejects a non exempt route without a client ip header', async () => {
|
||||
const app = createProductionApp();
|
||||
const response = await app.request('http://api:8080/users/@me');
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,12 @@ import type {GuildFolderIcon, MentionReplyPreference} from '@fluxer/constants/sr
|
||||
import type {types} from 'cassandra-driver';
|
||||
|
||||
type Nullish<T> = T | null;
|
||||
export type PushSubscriptionPlatform = 'web_push' | 'android_fcm' | 'ios_apns' | 'android_unified_push';
|
||||
export type PushSubscriptionPlatform =
|
||||
| 'web_push'
|
||||
| 'android_fcm'
|
||||
| 'ios_apns'
|
||||
| 'ios_apns_voip'
|
||||
| 'android_unified_push';
|
||||
|
||||
export interface UserRow {
|
||||
user_id: UserID;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import {hashAuthToken, recordAbuseSignal} from '@app/api/middleware/AbusiveIpAutoBanner';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {requireRequestClientIp} from '@app/api/utils/RequestClientIp';
|
||||
import {getRequestClientIp} from '@app/api/utils/RequestClientIp';
|
||||
import {stripApiPrefix} from '@app/api/utils/RequestPathUtils';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
@@ -60,7 +60,7 @@ function setUserInContext(ctx: Context<HonoEnv>, user: User, trackActivity: bool
|
||||
ctx.set('user', user);
|
||||
if (trackActivity) {
|
||||
const now = new Date();
|
||||
const ip = requireRequestClientIp(ctx);
|
||||
const ip = getRequestClientIp(ctx);
|
||||
const kvActivityTracker = ctx.get('kvActivityTracker');
|
||||
const userActivityBuffer = ctx.get('userActivityBuffer');
|
||||
userActivityBuffer.recordActivity(user.id, now, ip);
|
||||
@@ -77,7 +77,7 @@ export const UserMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
}
|
||||
const rawAuthHeader = ctx.req.header('Authorization');
|
||||
const parsed = parseAuthHeader(rawAuthHeader);
|
||||
const resolvedClientIp = requireRequestClientIp(ctx);
|
||||
const resolvedClientIp = getRequestClientIp(ctx);
|
||||
ctx.set('oauthBearerToken', undefined);
|
||||
ctx.set('oauthBearerApplicationId', undefined);
|
||||
ctx.set('oauthBearerAllowed', false);
|
||||
|
||||
@@ -22735,13 +22735,14 @@
|
||||
"properties": {
|
||||
"platform": {
|
||||
"description": "The mobile push notification platform",
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "IOS_APNS_VOIP", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumDescriptions": [
|
||||
"Firebase Cloud Messaging (Android)",
|
||||
"Apple Push Notification Service (iOS)",
|
||||
"Apple PushKit VoIP push, used only to ring an incoming call (iOS)",
|
||||
"UnifiedPush (Android without Google services)"
|
||||
],
|
||||
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
|
||||
"enum": ["android_fcm", "ios_apns", "ios_apns_voip", "android_unified_push"],
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
@@ -22802,13 +22803,14 @@
|
||||
"properties": {
|
||||
"platform": {
|
||||
"description": "The mobile push notification platform",
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumNames": ["ANDROID_FCM", "IOS_APNS", "IOS_APNS_VOIP", "ANDROID_UNIFIED_PUSH"],
|
||||
"x-enumDescriptions": [
|
||||
"Firebase Cloud Messaging (Android)",
|
||||
"Apple Push Notification Service (iOS)",
|
||||
"Apple PushKit VoIP push, used only to ring an incoming call (iOS)",
|
||||
"UnifiedPush (Android without Google services)"
|
||||
],
|
||||
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
|
||||
"enum": ["android_fcm", "ios_apns", "ios_apns_voip", "android_unified_push"],
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
|
||||
@@ -119,6 +119,12 @@ function resolveMobileWebPushKeys(device: RegisterMobileDeviceRequest): {p256dh:
|
||||
'Web Push registrations require encryption_key and auth_secret',
|
||||
);
|
||||
}
|
||||
if (device.platform === 'android_unified_push' || device.platform === 'ios_apns_voip') {
|
||||
throw InputValidationError.create(
|
||||
'encryption_key',
|
||||
'Web Push registrations require encryption_key and auth_secret',
|
||||
);
|
||||
}
|
||||
if (isPushEndpointUrl(device.token)) {
|
||||
throw InputValidationError.create('token', 'Endpoint URL registrations require encryption_key and auth_secret');
|
||||
}
|
||||
@@ -135,7 +141,7 @@ function normalizeProviderEnvironment(
|
||||
environment: RegisterMobileDeviceRequest['provider_environment'],
|
||||
): string | null {
|
||||
if (environment) return environment;
|
||||
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
return platform === 'ios_apns' || platform === 'ios_apns_voip' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
}
|
||||
|
||||
const isUnreachableEntityError = (error: unknown): boolean =>
|
||||
|
||||
@@ -292,6 +292,136 @@ describe('Push Subscription Lifecycle', () => {
|
||||
const mobileDevices = await listMobileDevices(harness, account.token);
|
||||
expect(mobileDevices.devices).toHaveLength(0);
|
||||
});
|
||||
test('VoIP registration stores the PushKit endpoint and encryption keys', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const endpoint = 'https://relay.example.com/apns-voip/device-1';
|
||||
const registered = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: endpoint,
|
||||
encryption_key: 'voip-p256dh-key',
|
||||
auth_secret: 'voip-auth-secret',
|
||||
app_id: 'stable',
|
||||
});
|
||||
const subscription = await findStoredSubscription(account.userId, registered.device_id);
|
||||
expect(subscription.platform).toBe('ios_apns_voip');
|
||||
expect(subscription.endpoint).toBe(endpoint);
|
||||
expect(subscription.p256dhKey).toBe('voip-p256dh-key');
|
||||
expect(subscription.authKey).toBe('voip-auth-secret');
|
||||
});
|
||||
test('VoIP registration defaults to the production provider environment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const registered = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: 'https://relay.example.com/apns-voip/default-environment',
|
||||
encryption_key: 'voip-default-environment-p256dh-key',
|
||||
auth_secret: 'voip-default-environment-auth-secret',
|
||||
});
|
||||
const subscription = await findStoredSubscription(account.userId, registered.device_id);
|
||||
expect(subscription.providerEnvironment).toBe('production');
|
||||
});
|
||||
test('VoIP registration without encryption keys is rejected', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mobile-devices')
|
||||
.body({
|
||||
platform: 'ios_apns_voip',
|
||||
token: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('VoIP registration with only one encryption key is rejected', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mobile-devices')
|
||||
.body({
|
||||
platform: 'ios_apns_voip',
|
||||
token: 'https://relay.example.com/apns-voip/half-keys',
|
||||
encryption_key: 'voip-half-p256dh-key',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('VoIP and standard APNs registrations coexist as separate devices', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const standard = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns',
|
||||
token: 'https://relay.example.com/apns/paired-device',
|
||||
encryption_key: 'paired-apns-p256dh-key',
|
||||
auth_secret: 'paired-apns-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
const voip = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: 'https://relay.example.com/apns-voip/paired-device',
|
||||
encryption_key: 'paired-voip-p256dh-key',
|
||||
auth_secret: 'paired-voip-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
expect(voip.device_id).not.toBe(standard.device_id);
|
||||
const mobileDevices = await listMobileDevices(harness, account.token);
|
||||
const platforms = mobileDevices.devices.map((device) => device.platform).sort();
|
||||
expect(platforms).toEqual(['ios_apns', 'ios_apns_voip']);
|
||||
});
|
||||
test('platform alone separates device ids for one registration token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const endpoint = 'https://relay.example.com/apns/shared-token';
|
||||
const standard = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns',
|
||||
token: endpoint,
|
||||
encryption_key: 'shared-p256dh-key',
|
||||
auth_secret: 'shared-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
const voip = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: endpoint,
|
||||
encryption_key: 'shared-p256dh-key',
|
||||
auth_secret: 'shared-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
expect(voip.device_id).not.toBe(standard.device_id);
|
||||
});
|
||||
test('unregister removes only the named VoIP registration', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const voipEndpoint = 'https://relay.example.com/apns-voip/removed-device';
|
||||
const standard = await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns',
|
||||
token: 'https://relay.example.com/apns/kept-device',
|
||||
encryption_key: 'kept-p256dh-key',
|
||||
auth_secret: 'kept-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
await registerMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: voipEndpoint,
|
||||
encryption_key: 'removed-p256dh-key',
|
||||
auth_secret: 'removed-auth-secret',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
await unregisterMobileDevice(harness, account.token, {
|
||||
platform: 'ios_apns_voip',
|
||||
token: voipEndpoint,
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
});
|
||||
const mobileDevices = await listMobileDevices(harness, account.token);
|
||||
expect(mobileDevices.devices).toEqual([
|
||||
{
|
||||
device_id: standard.device_id,
|
||||
platform: 'ios_apns',
|
||||
app_id: 'stable',
|
||||
provider_environment: 'production',
|
||||
user_agent: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('mobile Web Push registrations stay out of the web push subscription list', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await registerMobileDevice(harness, account.token, {
|
||||
|
||||
@@ -344,7 +344,7 @@ export async function registerMobileDevice(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
body: {
|
||||
platform: 'android_fcm' | 'ios_apns' | 'android_unified_push';
|
||||
platform: 'android_fcm' | 'ios_apns' | 'ios_apns_voip' | 'android_unified_push';
|
||||
token: string;
|
||||
user_agent?: string;
|
||||
app_id?: string;
|
||||
@@ -371,7 +371,7 @@ export async function unregisterMobileDevice(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
body: {
|
||||
platform: 'android_fcm' | 'ios_apns' | 'android_unified_push';
|
||||
platform: 'android_fcm' | 'ios_apns' | 'ios_apns_voip' | 'android_unified_push';
|
||||
token: string;
|
||||
app_id?: string;
|
||||
provider_environment?: 'production' | 'development';
|
||||
|
||||
@@ -27,9 +27,12 @@ Fluxer reads the shape from the body rather than from `platform`. A `token` that
|
||||
| --- | --- |
|
||||
| `android_fcm` | Firebase Cloud Messaging |
|
||||
| `ios_apns` | Apple Push Notification service |
|
||||
| `ios_apns_voip` | Apple PushKit, the separate call registration of an iOS device |
|
||||
| `android_unified_push` | UnifiedPush, on an Android build without Google services |
|
||||
|
||||
`android_unified_push` is always a Web Push registration. Sending it with no keys is refused.
|
||||
`android_unified_push` and `ios_apns_voip` are always Web Push registrations. Sending either with no keys is refused.
|
||||
|
||||
Apple issues a PushKit device token separate from the alert token. An iOS device that answers calls registers both: the alert token as `ios_apns`, and the PushKit token as `ios_apns_voip`. Generate a separate key pair for the `ios_apns_voip` registration. The two registrations get two identifiers and two independent lifetimes. Removing one leaves the other in place.
|
||||
|
||||
## Device registration object
|
||||
|
||||
@@ -65,7 +68,7 @@ Stores a push registration for the current account and returns its [device regis
|
||||
|
||||
<sup>2</sup> Sent together or not at all. Sending one alone is refused at the missing field
|
||||
|
||||
An `ios_apns` registration with no `provider_environment` is stored as `production`. Every other platform stores no environment.
|
||||
An `ios_apns` or `ios_apns_voip` registration with no `provider_environment` is stored as `production`. Every other platform stores no environment.
|
||||
|
||||
Register an `https` endpoint. A Web Push registration whose `token` is not a valid URL is refused at `token`, and one whose host is a private or reserved address is refused with `URL_NOT_PUBLICLY_ROUTABLE`.
|
||||
|
||||
@@ -122,8 +125,8 @@ Fluxer posts one encrypted record to the registered endpoint for each notificati
|
||||
| --- | --- |
|
||||
| Content-Encoding | Always `aes128gcm` |
|
||||
| Content-Type | Always `application/octet-stream` |
|
||||
| TTL | `86400` on a notification and `3600` on a clear |
|
||||
| Urgency | `high` on a notification and `low` on a clear |
|
||||
| TTL | `86400` on a notification, `3600` on a clear, `0` on a call ring |
|
||||
| Urgency | `high` on a notification and on a call ring, `low` on a clear |
|
||||
| Authorization | A VAPID token and the instance public key |
|
||||
|
||||
The body is one `aes128gcm` record encrypted to the `encryption_key` and `auth_secret` the client registered. The client decrypts it locally with the private half of its key pair and its auth secret. Fluxer holds no key that opens the record after it is sealed.
|
||||
@@ -139,10 +142,44 @@ A record is 2816 bytes and its plaintext is at most 2713 bytes of JSON. A notifi
|
||||
|
||||
A client has to tolerate a missing field.
|
||||
|
||||
Two kinds of payload arrive. A notification payload describes something to show. A clear payload sets `type` to `notification_clear` and `action` to `clear_channel`, and asks the client to dismiss what it already showed for one channel.
|
||||
Three kinds of payload arrive. A notification payload describes something to show. A clear payload sets `type` to `notification_clear` and `action` to `clear_channel`, and asks the client to dismiss what it already showed for one channel. A [call ring](#call-ring) payload sets `type` to `call_ring` and announces an incoming call.
|
||||
|
||||
An endpoint that answers 404 or 410 removes the registration. Fluxer retries a transient failure and keeps the registration.
|
||||
|
||||
### Call ring
|
||||
|
||||
A call ring is the only payload an `ios_apns_voip` registration receives. Every other payload for that device goes to its `ios_apns` registration. The fields below sit under `data`.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| type | string | Always `call_ring` |
|
||||
| channel_id | string | The private channel the call is in |
|
||||
| message_id | string | The call message, which names the call |
|
||||
| target_user_id | string | The account being rung |
|
||||
| started_at_ms | integer | When the ring started, in milliseconds since the Unix epoch |
|
||||
|
||||
A call ring is not stored for later delivery. A device that cannot be reached while the call rings does not get the ring afterwards.
|
||||
|
||||
Nothing cancels a ring with a second push. Fluxer ends the call over the gateway connection the woken client opens.
|
||||
|
||||
### What PushKit requires of the client
|
||||
|
||||
iOS terminates an application that takes a PushKit push without reporting a call to CallKit. Repeated failures stop PushKit delivery to that device. The report is due before the record is decrypted. Decryption cannot be what decides whether to ring.
|
||||
|
||||
Report a call for every PushKit push, before decrypting. Then end that call at once in each of these three cases.
|
||||
|
||||
| Case | What it means |
|
||||
| --- | --- |
|
||||
| The record does not decrypt | The registered keys no longer match the pair the client holds |
|
||||
| `type` is not `call_ring` | The push did not come from Fluxer |
|
||||
| The gateway names no live call for `channel_id` | The call ended before the ring arrived |
|
||||
|
||||
Anyone who learns a PushKit token can send to it. Those three rules are what keeps a forged push from showing a caller.
|
||||
|
||||
Derive the CallKit call identifier from `message_id`. The gateway ends the call over the connection under that same identity. Two rings for one call then name one call.
|
||||
|
||||
Keep the registered private key and auth secret readable while the device is locked. A call ring arrives on a locked device. A key that cannot be read then costs the report.
|
||||
|
||||
### When decryption fails
|
||||
|
||||
A record that does not decrypt cannot be recovered. Discard it and show nothing.
|
||||
|
||||
@@ -73,6 +73,7 @@ apply_pending_ringing(PendingUnique, State, DispatchUpdates) ->
|
||||
_ -> lists:usort(AlreadyRinging ++ ToAdd)
|
||||
end,
|
||||
StateWithRinging = State#{pending_ringing => [], ringing => NewRinging},
|
||||
ok = publish_rings(ToAdd, StateWithRinging),
|
||||
StateWithTimers = start_ringing_timers(ToAdd, StateWithRinging),
|
||||
case ToAdd of
|
||||
[] ->
|
||||
@@ -84,6 +85,55 @@ apply_pending_ringing(PendingUnique, State, DispatchUpdates) ->
|
||||
{StateWithTimers, false}
|
||||
end.
|
||||
|
||||
-spec publish_rings([integer()], map()) -> ok.
|
||||
publish_rings([], _State) ->
|
||||
ok;
|
||||
publish_rings(UserIds, State) ->
|
||||
ChannelId = maps:get(channel_id, State),
|
||||
MessageId = maps:get(message_id, State),
|
||||
Recipients = maps:get(recipients, State),
|
||||
StartedAt = erlang:system_time(millisecond),
|
||||
ExpiresAt = StartedAt + ?RING_TIMEOUT_MS,
|
||||
_ = proc_lib:spawn(fun() ->
|
||||
publish_ring_jobs(UserIds, Recipients, ChannelId, MessageId, StartedAt, ExpiresAt)
|
||||
end),
|
||||
ok.
|
||||
|
||||
-spec publish_ring_jobs(
|
||||
[integer()], [integer()], integer(), integer(), integer(), integer()
|
||||
) -> ok.
|
||||
publish_ring_jobs(UserIds, Recipients, ChannelId, MessageId, StartedAt, ExpiresAt) ->
|
||||
lists:foreach(
|
||||
fun(UserId) ->
|
||||
publish_ring_job(UserId, Recipients, ChannelId, MessageId, StartedAt, ExpiresAt)
|
||||
end,
|
||||
UserIds
|
||||
).
|
||||
|
||||
-spec publish_ring_job(
|
||||
integer(), [integer()], integer(), integer(), integer(), integer()
|
||||
) -> ok.
|
||||
publish_ring_job(UserId, Recipients, ChannelId, MessageId, StartedAt, ExpiresAt) ->
|
||||
case ring_suppressed(UserId, Recipients) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
_ = push_job_publisher:publish_ring(
|
||||
UserId, ChannelId, MessageId, StartedAt, ExpiresAt
|
||||
),
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec ring_suppressed(integer(), [integer()]) -> boolean().
|
||||
ring_suppressed(UserId, Recipients) ->
|
||||
all_blocked(UserId, [Other || Other <- Recipients, Other =/= UserId]).
|
||||
|
||||
-spec all_blocked(integer(), [integer()]) -> boolean().
|
||||
all_blocked(_UserId, []) ->
|
||||
false;
|
||||
all_blocked(UserId, Others) ->
|
||||
lists:all(fun(Other) -> push_eligibility:is_user_blocked(UserId, Other) end, Others).
|
||||
|
||||
-spec maybe_dispatch_state_update(map(), map()) -> {map(), boolean()}.
|
||||
maybe_dispatch_state_update(PrevState, NewState) ->
|
||||
CountedState = call_state:sync_voice_state_count_diff(PrevState, NewState),
|
||||
@@ -309,3 +359,126 @@ maybe_stop_if_empty(State) ->
|
||||
-spec call_has_activity(map()) -> boolean().
|
||||
call_has_activity(State) ->
|
||||
maps:size(maps:get(voice_states, State)) > 0 orelse maps:get(ringing, State) =/= [].
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
ring_state(Recipients, Ringing, Pending, VoiceStates) ->
|
||||
#{
|
||||
channel_id => 100,
|
||||
message_id => 200,
|
||||
region => undefined,
|
||||
ringing => Ringing,
|
||||
pending_ringing => Pending,
|
||||
recipients => Recipients,
|
||||
voice_states => VoiceStates,
|
||||
ringing_timers => #{},
|
||||
initiator_ready => true,
|
||||
last_call_event => undefined
|
||||
}.
|
||||
|
||||
published_ring_args() ->
|
||||
History = meck:history(push_job_publisher),
|
||||
[Args || {_Pid, {push_job_publisher, publish_ring, Args}, _Result} <- History].
|
||||
|
||||
published_ring_users() ->
|
||||
lists:usort([UserId || [UserId, _C, _M, _S, _E] <- published_ring_args()]).
|
||||
|
||||
mock_ring_publisher() ->
|
||||
ok = meck:new(push_job_publisher, [passthrough, no_link]),
|
||||
meck:expect(push_job_publisher, publish_ring, fun(_U, _C, _M, _S, _E) -> ok end).
|
||||
|
||||
apply_pending_ringing_publishes_one_ring_per_new_user_test() ->
|
||||
ok = mock_ring_publisher(),
|
||||
try
|
||||
State = ring_state([1, 2, 3], [], [2, 3], #{}),
|
||||
_ = maybe_dispatch_pending_ringing(State, false),
|
||||
ok = meck:wait(2, push_job_publisher, publish_ring, '_', 2000),
|
||||
?assertEqual([2, 3], published_ring_users())
|
||||
after
|
||||
meck:unload(push_job_publisher)
|
||||
end.
|
||||
|
||||
apply_pending_ringing_publishes_the_frozen_ring_fields_test() ->
|
||||
ok = mock_ring_publisher(),
|
||||
try
|
||||
State = ring_state([1, 2], [], [2], #{}),
|
||||
_ = maybe_dispatch_pending_ringing(State, false),
|
||||
ok = meck:wait(1, push_job_publisher, publish_ring, '_', 2000),
|
||||
[[UserId, ChannelId, MessageId, StartedAt, ExpiresAt]] = published_ring_args(),
|
||||
?assertEqual({2, 100, 200}, {UserId, ChannelId, MessageId}),
|
||||
?assertEqual(?RING_TIMEOUT_MS, ExpiresAt - StartedAt)
|
||||
after
|
||||
meck:unload(push_job_publisher)
|
||||
end.
|
||||
|
||||
apply_pending_ringing_does_not_publish_for_a_user_already_ringing_test() ->
|
||||
ok = mock_ring_publisher(),
|
||||
try
|
||||
State = ring_state([1, 2], [2], [2], #{}),
|
||||
_ = maybe_dispatch_pending_ringing(State, false),
|
||||
?assertEqual([], published_ring_args())
|
||||
after
|
||||
meck:unload(push_job_publisher)
|
||||
end.
|
||||
|
||||
apply_pending_ringing_does_not_publish_for_a_connected_user_test() ->
|
||||
ok = mock_ring_publisher(),
|
||||
try
|
||||
State = ring_state([1, 2], [], [2], #{2 => #{}}),
|
||||
_ = maybe_dispatch_pending_ringing(State, false),
|
||||
?assertEqual([], published_ring_args())
|
||||
after
|
||||
meck:unload(push_job_publisher)
|
||||
end.
|
||||
|
||||
start_ringing_timers_does_not_publish_test() ->
|
||||
ok = mock_ring_publisher(),
|
||||
try
|
||||
State = ring_state([1, 2], [2], [], #{}),
|
||||
_ = start_ringing_timers([2], State),
|
||||
?assertEqual([], published_ring_args())
|
||||
after
|
||||
meck:unload(push_job_publisher)
|
||||
end.
|
||||
|
||||
publish_ring_jobs_suppresses_a_fully_blocked_ring_test() ->
|
||||
ok = mock_ring_publisher(),
|
||||
ok = meck:new(push_eligibility, [passthrough, no_link]),
|
||||
try
|
||||
ok = meck:expect(push_eligibility, is_user_blocked, fun(_U, _O) -> true end),
|
||||
ok = publish_ring_jobs([2], [1, 2], 100, 200, 1, 2),
|
||||
?assertEqual([], published_ring_args())
|
||||
after
|
||||
meck:unload(push_eligibility),
|
||||
meck:unload(push_job_publisher)
|
||||
end.
|
||||
|
||||
ring_is_suppressed_when_every_other_recipient_is_blocked_test() ->
|
||||
ok = meck:new(push_eligibility, [passthrough, no_link]),
|
||||
try
|
||||
ok = meck:expect(push_eligibility, is_user_blocked, fun(_U, _O) -> true end),
|
||||
?assertEqual(true, ring_suppressed(2, [1, 2, 3]))
|
||||
after
|
||||
meck:unload(push_eligibility)
|
||||
end.
|
||||
|
||||
ring_is_not_suppressed_when_one_other_recipient_is_unblocked_test() ->
|
||||
ok = meck:new(push_eligibility, [passthrough, no_link]),
|
||||
try
|
||||
ok = meck:expect(push_eligibility, is_user_blocked, fun(_U, Other) -> Other =:= 1 end),
|
||||
?assertEqual(false, ring_suppressed(2, [1, 2, 3]))
|
||||
after
|
||||
meck:unload(push_eligibility)
|
||||
end.
|
||||
|
||||
ring_is_not_suppressed_for_a_lone_recipient_test() ->
|
||||
ok = meck:new(push_eligibility, [passthrough, no_link]),
|
||||
try
|
||||
ok = meck:expect(push_eligibility, is_user_blocked, fun(_U, _O) -> true end),
|
||||
?assertEqual(false, ring_suppressed(2, [2]))
|
||||
after
|
||||
meck:unload(push_eligibility)
|
||||
end.
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
-module(push_job_publisher).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([publish_message/8, publish_message/10, publish_clear/3, publish_clear/5, request/3]).
|
||||
-export([publish_message/8, publish_message/10, publish_clear/3, publish_clear/5]).
|
||||
-export([publish_ring/5, request/3]).
|
||||
|
||||
-define(SUBJECT_MESSAGE, <<"push.job.message">>).
|
||||
-define(SUBJECT_CLEAR, <<"push.job.clear">>).
|
||||
-define(SUBJECT_RING, <<"push.job.ring">>).
|
||||
-define(JOB_VERSION, 1).
|
||||
-define(NATS_MAX_PAYLOAD_BYTES, 1048576).
|
||||
|
||||
-type meta() :: #{
|
||||
kind := message | clear,
|
||||
kind := message | clear | ring,
|
||||
user_ids := [integer()],
|
||||
channel_id := integer(),
|
||||
message_id := integer(),
|
||||
@@ -132,6 +134,26 @@ publish_clear(UserId, ChannelId, MessageId, ConfigVersion, Fallback) ->
|
||||
fallback => Fallback
|
||||
}).
|
||||
|
||||
-spec publish_ring(integer(), integer(), integer(), integer(), integer()) ->
|
||||
ok | {error, term()}.
|
||||
publish_ring(UserId, ChannelId, MessageId, StartedAtMs, ExpiresAtMs) ->
|
||||
Job = #{
|
||||
<<"v">> => ?JOB_VERSION,
|
||||
<<"config_version">> => push_delivery_config:config_version(),
|
||||
<<"user_id">> => integer_to_binary(UserId),
|
||||
<<"channel_id">> => integer_to_binary(ChannelId),
|
||||
<<"message_id">> => integer_to_binary(MessageId),
|
||||
<<"started_at_ms">> => StartedAtMs,
|
||||
<<"expires_at_ms">> => ExpiresAtMs
|
||||
},
|
||||
publish(?SUBJECT_RING, Job, #{
|
||||
kind => ring,
|
||||
user_ids => [UserId],
|
||||
channel_id => ChannelId,
|
||||
message_id => MessageId,
|
||||
fallback => fun ignore_fallback/1
|
||||
}).
|
||||
|
||||
-spec request(binary(), binary(), pos_integer()) -> ok | {error, term()}.
|
||||
request(Subject, Body, Timeout) ->
|
||||
case gateway_nats_pool_conn:get_pool_conn() of
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
-define(STATS_TIMEOUT_MS, 1000).
|
||||
-define(PRUNE_INTERVAL_MS, 30000).
|
||||
|
||||
-type kind() :: message | clear.
|
||||
-type kind() :: message | clear | ring.
|
||||
-type fallback() :: fun(([integer()]) -> term()).
|
||||
-type job() :: #{
|
||||
kind := kind(),
|
||||
@@ -283,7 +283,7 @@ send_or_fall_back(Entry, State) ->
|
||||
end.
|
||||
|
||||
-spec prepare(entry(), state()) -> {skip, state()} | {send, entry(), state()}.
|
||||
prepare(#{kind := clear} = Entry, State) ->
|
||||
prepare(#{kind := Kind} = Entry, State) when Kind =:= clear; Kind =:= ring ->
|
||||
{send, Entry, State};
|
||||
prepare(#{user_ids := UserIds} = Entry, #{reads := Reads, active := Active} = State) ->
|
||||
#{channel_id := ChannelId, message_id := MessageId, seq := Seq} = Entry,
|
||||
@@ -537,6 +537,8 @@ settle_if_stale(#{config_version := Version} = Entry, State) ->
|
||||
end.
|
||||
|
||||
-spec settle(entry(), state()) -> settled().
|
||||
settle(#{kind := ring} = Entry, State) ->
|
||||
{keep, Entry, State};
|
||||
settle(Entry, State) ->
|
||||
case prepare(Entry, State) of
|
||||
{skip, State1} ->
|
||||
|
||||
@@ -193,12 +193,16 @@ send_notification_to_subscription(UserId, Subscription, Payload) ->
|
||||
platform => Platform,
|
||||
web_push_shape => WebPushShape
|
||||
}),
|
||||
case WebPushShape of
|
||||
true ->
|
||||
push_sender_delivery:send_webpush_notification(UserId, Subscription, Payload);
|
||||
false ->
|
||||
send_platform_notification(UserId, Platform, Subscription, Payload)
|
||||
end.
|
||||
route_subscription(UserId, Platform, WebPushShape, Subscription, Payload).
|
||||
|
||||
-spec route_subscription(integer(), binary(), boolean(), map(), map()) -> false | {true, map()}.
|
||||
route_subscription(UserId, <<"ios_apns_voip">>, _WebPushShape, _Subscription, _Payload) ->
|
||||
logger:debug("Push: skipping a VoIP subscription", #{user_id => UserId}),
|
||||
false;
|
||||
route_subscription(UserId, _Platform, true, Subscription, Payload) ->
|
||||
push_sender_delivery:send_webpush_notification(UserId, Subscription, Payload);
|
||||
route_subscription(UserId, Platform, false, Subscription, Payload) ->
|
||||
send_platform_notification(UserId, Platform, Subscription, Payload).
|
||||
|
||||
-spec send_platform_notification(integer(), binary(), map(), map()) -> false | {true, map()}.
|
||||
send_platform_notification(UserId, <<"web_push">>, Subscription, Payload) ->
|
||||
@@ -486,6 +490,25 @@ empty_key_does_not_take_web_push_path_test() ->
|
||||
Subscription = maps:put(<<"auth_key">>, <<>>, web_push_row(<<"android_fcm">>)),
|
||||
?assertEqual(fcm, routed_target(Subscription)).
|
||||
|
||||
voip_row_is_skipped_test() ->
|
||||
?assertEqual({0, 0, 0}, routed_target(web_push_row(<<"ios_apns_voip">>))).
|
||||
|
||||
voip_row_without_keys_is_skipped_test() ->
|
||||
?assertEqual({0, 0, 0}, routed_target(legacy_row(<<"ios_apns_voip">>))).
|
||||
|
||||
send_subscriptions_skips_voip_rows_test() ->
|
||||
ok = meck:new(push_sender_delivery, [passthrough, no_link]),
|
||||
try
|
||||
ok = meck:expect(push_sender_delivery, send_webpush_notification, fun(_U, _S, _P) ->
|
||||
false
|
||||
end),
|
||||
Rows = [web_push_row(<<"ios_apns_voip">>), web_push_row(<<"ios_apns">>)],
|
||||
?assertEqual([], send_subscriptions(7, #{}, Rows, [])),
|
||||
?assertEqual(1, meck:num_calls(push_sender_delivery, send_webpush_notification, '_'))
|
||||
after
|
||||
meck:unload(push_sender_delivery)
|
||||
end.
|
||||
|
||||
web_push_row_takes_web_push_path_on_unknown_platform_test() ->
|
||||
?assertEqual(web_push, routed_target(web_push_row(<<"desktop_widget">>))).
|
||||
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ const MobilePushPlatformSchema = createNamedStringLiteralUnion(
|
||||
[
|
||||
['android_fcm', 'ANDROID_FCM', 'Firebase Cloud Messaging (Android)'],
|
||||
['ios_apns', 'IOS_APNS', 'Apple Push Notification Service (iOS)'],
|
||||
['ios_apns_voip', 'IOS_APNS_VOIP', 'Apple PushKit VoIP push, used only to ring an incoming call (iOS)'],
|
||||
['android_unified_push', 'ANDROID_UNIFIED_PUSH', 'UnifiedPush (Android without Google services)'],
|
||||
],
|
||||
'The mobile push notification platform',
|
||||
@@ -501,7 +502,10 @@ export const RegisterMobileDeviceRequest = z
|
||||
.superRefine((value, ctx) => {
|
||||
const tokenIsUrl = URLType.safeParse(value.token).success;
|
||||
const isWebPushRegistration =
|
||||
value.platform === 'android_unified_push' || value.encryption_key != null || value.auth_secret != null;
|
||||
value.platform === 'android_unified_push' ||
|
||||
value.platform === 'ios_apns_voip' ||
|
||||
value.encryption_key != null ||
|
||||
value.auth_secret != null;
|
||||
if (!isWebPushRegistration) {
|
||||
if (tokenIsUrl) {
|
||||
ctx.addIssue({
|
||||
|
||||
Reference in New Issue
Block a user