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

This commit is contained in:
Hampus
2026-09-23 20:21:08 +02:00
committed by GitHub
parent c9754ac11a
commit b16989d567
25 changed files with 878 additions and 83 deletions
@@ -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);
+6 -4
View File
@@ -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';