Fix code review findings for FCM/APNs push notifications (FRE-5345)
- P0: Add missing jwt import (remove duplicate getAPNSToken from push.service.ts) - P0: Fix race condition in getFCMApp() with promise-based initialization lock - P0: Fix preHandler short-circuit in device.routes.ts (add return before reply.send) - P1: Replace non-null assertions with safe defaults in notification config - P1: Add rate limiting on device registration endpoint (10 req/5min per user) - P2: Add push notification deduplication using content hash - P2: Add APNs payload size validation (256KB limit) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
309
packages/shared-notifications/src/services/apns.service.ts
Normal file
309
packages/shared-notifications/src/services/apns.service.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import https from 'https';
|
||||
import { loadNotificationConfig } from '../config/notification.config';
|
||||
import type { PushNotification, NotificationResult } from '../types/notification.types';
|
||||
|
||||
const config = loadNotificationConfig();
|
||||
|
||||
let apnsToken: string | null = null;
|
||||
let tokenExpiration: number = 0;
|
||||
|
||||
interface APNsPayload {
|
||||
aps: {
|
||||
alert: {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
badge?: number;
|
||||
sound?: string | {
|
||||
critical: number;
|
||||
name: string;
|
||||
volume: number;
|
||||
};
|
||||
category?: string;
|
||||
'content-available'?: number;
|
||||
'mutable-content'?: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function generateAPNSToken(): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const token = jwt.sign(
|
||||
{},
|
||||
config.apns.key,
|
||||
{
|
||||
algorithm: 'ES256' as any,
|
||||
headers: {
|
||||
alg: 'ES256',
|
||||
kty: 'EC',
|
||||
kid: config.apns.keyId,
|
||||
},
|
||||
expiresIn: '1h',
|
||||
issuer: config.apns.teamId,
|
||||
} as any
|
||||
);
|
||||
|
||||
tokenExpiration = now + 3500; // Refresh 1 minute before expiration
|
||||
return token;
|
||||
}
|
||||
|
||||
function getAPNSToken(): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!apnsToken || now > tokenExpiration) {
|
||||
apnsToken = generateAPNSToken();
|
||||
}
|
||||
|
||||
return apnsToken;
|
||||
}
|
||||
|
||||
export class APNSService {
|
||||
private static instance: APNSService;
|
||||
private host: string;
|
||||
|
||||
private constructor() {
|
||||
this.host = process.env.NODE_ENV === 'production'
|
||||
? 'api.push.apple.com'
|
||||
: 'api.sandbox.push.apple.com';
|
||||
}
|
||||
|
||||
static getInstance(): APNSService {
|
||||
if (!APNSService.instance) {
|
||||
APNSService.instance = new APNSService();
|
||||
}
|
||||
return APNSService.instance;
|
||||
}
|
||||
|
||||
async send(notification: PushNotification): Promise<NotificationResult> {
|
||||
try {
|
||||
const token = getAPNSToken();
|
||||
const payload = this.buildPayload(notification);
|
||||
const apnsToken = notification.data?.apnsToken as string || notification.userId;
|
||||
|
||||
// Validate payload size (APNs limit: 4KB for alert, 256KB total)
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const payloadSize = Buffer.byteLength(payloadStr, 'utf8');
|
||||
if (payloadSize > 256 * 1024) {
|
||||
return {
|
||||
notificationId: `apns-${Date.now()}`,
|
||||
channel: 'push',
|
||||
status: 'failed',
|
||||
error: `Payload size ${payloadSize} exceeds APNs limit of 256KB`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.sendToDevice(apnsToken, payload, token);
|
||||
|
||||
return {
|
||||
notificationId: `apns-${Date.now()}`,
|
||||
channel: 'push',
|
||||
status: result.success ? 'sent' : 'failed',
|
||||
externalId: result.responseId,
|
||||
error: result.error,
|
||||
deliveredAt: result.success ? new Date() : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
notificationId: `apns-${Date.now()}`,
|
||||
channel: 'push',
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildPayload(notification: PushNotification): APNsPayload {
|
||||
const payload: APNsPayload = {
|
||||
aps: {
|
||||
alert: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
},
|
||||
sound: notification.sound || 'default',
|
||||
},
|
||||
};
|
||||
|
||||
if (notification.badge !== undefined) {
|
||||
payload.aps.badge = notification.badge;
|
||||
}
|
||||
|
||||
if (notification.category) {
|
||||
payload.aps.category = notification.category;
|
||||
}
|
||||
|
||||
if (notification.data) {
|
||||
Object.entries(notification.data).forEach(([key, value]) => {
|
||||
if (key !== 'apnsToken') {
|
||||
payload[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private sendToDevice(
|
||||
deviceToken: string,
|
||||
payload: APNsPayload,
|
||||
authToken: string
|
||||
): Promise<{ success: boolean; responseId?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const url = `https://${this.host}:443/3/device/${deviceToken}`;
|
||||
|
||||
const options = {
|
||||
hostname: this.host,
|
||||
port: 443,
|
||||
path: `/3/device/${deviceToken}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `bearer ${authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': '10',
|
||||
'apns-topic': config.apns.bundleId,
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(url, options, (res) => {
|
||||
let responseData = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve({
|
||||
success: true,
|
||||
responseId: res.headers['apns-id'] as string,
|
||||
});
|
||||
} else {
|
||||
let error = `APNs error: ${res.statusCode}`;
|
||||
try {
|
||||
const errorBody = JSON.parse(responseData);
|
||||
error = errorBody.reason || errorBody.message || error;
|
||||
} catch {
|
||||
// Keep default error message
|
||||
}
|
||||
resolve({
|
||||
success: false,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
req.write(JSON.stringify(payload));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async sendBatch(notifications: PushNotification[]): Promise<NotificationResult[]> {
|
||||
const results = await Promise.all(
|
||||
notifications.map(n => this.send(n))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test APNs connection by validating configuration and making a test request
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// Validate required configuration
|
||||
if (!config.apns.keyId || !config.apns.teamId || !config.apns.bundleId) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Missing required APNs configuration: keyId, teamId, or bundleId',
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.apns.key) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Missing APNs private key',
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a token to verify the key is valid
|
||||
const token = getAPNSToken();
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to generate APNs authentication token',
|
||||
};
|
||||
}
|
||||
|
||||
// Make a minimal test request to APNs to verify connection
|
||||
return new Promise((resolve) => {
|
||||
const testDeviceToken = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const url = `https://${this.host}:443/3/device/${testDeviceToken}`;
|
||||
|
||||
const options = {
|
||||
hostname: this.host,
|
||||
port: 443,
|
||||
path: `/3/device/${testDeviceToken}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': '10',
|
||||
'apns-topic': config.apns.bundleId,
|
||||
},
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
const req = https.request(url, options, (res) => {
|
||||
// Any response means we connected successfully
|
||||
// Even 410 (device token not registered) means connection works
|
||||
if (res.statusCode && res.statusCode < 500) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `APNs connection successful (status: ${res.statusCode})`,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
message: `APNs connection failed with status: ${res.statusCode}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
// Connection errors are expected if no real APNs credentials
|
||||
resolve({
|
||||
success: false,
|
||||
message: `APNs connection error: ${error.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'APNs connection timeout',
|
||||
});
|
||||
});
|
||||
|
||||
// Send minimal payload
|
||||
req.write(JSON.stringify({ aps: { sound: 'default' } }));
|
||||
req.end();
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user