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';
|
||||
|
||||
Reference in New Issue
Block a user