- 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>
310 lines
8.3 KiB
TypeScript
310 lines
8.3 KiB
TypeScript
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',
|
|
};
|
|
}
|
|
}
|
|
}
|