This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';import {readFile} from 'node:fs/promises';import {type ClientHttp2Session, connect, constants} from 'node:http2';import {importPKCS8, SignJWT} from 'jose';import {Config} from '../Config';import type {PushProviderEnvironment} from '../config/APIConfig';import {Logger} from '../Logger';
const APNS_PROVIDER_TOKEN_TTL_SECONDS = 50 * 60;const APNS_REQUEST_TIMEOUT_MS = 5000;const APNS_MAX_RESPONSE_BYTES = 8192;const APNS_PUSH_TYPE_ALERT = 'alert';const APNS_PUSH_TYPE_BACKGROUND = 'background';const APNS_CATEGORY_MESSAGE = 'FLUXER_MESSAGE';const APNS_DEFAULT_SOUND = 'default';const HTTP2_OK_MIN = 200;const HTTP2_OK_MAX = 299;
interface SendApnsPushParams { userId: string; subscriptionId: string; deviceToken: string; appId: string; providerEnvironment: PushProviderEnvironment; payload: Record<string, unknown>;}
interface SendApnsPushResult { success: boolean; shouldDelete: boolean; reason?: string; statusCode?: number;}
interface CachedProviderToken { token: string; expiresAtSeconds: number;}
interface Http2Response { statusCode: number; body: string;}
const providerTokenCache = new Map<string, CachedProviderToken>();const apnsSessions = new Map<string, ClientHttp2Session>();
export async function sendApnsPush(params: SendApnsPushParams): Promise<SendApnsPushResult> { const cfg = Config.push.apns; if (!cfg.enabled) { return {success: false, shouldDelete: false, reason: 'apns_disabled'}; } const topic = resolveApnsTopic(params.appId, params.providerEnvironment); if (!topic) { return {success: false, shouldDelete: false, reason: 'apns_topic_missing'}; } const providerToken = await getProviderToken(); if (!providerToken) { return {success: false, shouldDelete: false, reason: 'apns_auth_missing'}; } const requestBody = JSON.stringify(buildApnsPayload(params.payload)); const host = params.providerEnvironment === 'development' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'; const headers = buildApnsHeaders({providerToken, topic, payload: params.payload}); try { const response = await sendApnsHttp2Request({ origin: `https://${host}`, path: `/3/device/${params.deviceToken}`, headers, body: requestBody, }); if (response.statusCode >= HTTP2_OK_MIN && response.statusCode <= HTTP2_OK_MAX) { return {success: true, shouldDelete: false, statusCode: response.statusCode}; } const reason = parseApnsReason(response.body) ?? `http_${response.statusCode}`; return { success: false, shouldDelete: isPermanentApnsFailure(response.statusCode, reason), reason, statusCode: response.statusCode, }; } catch (error) { Logger.warn({error, userId: params.userId, subscriptionId: params.subscriptionId}, 'APNs push request failed'); return {success: false, shouldDelete: false, reason: 'apns_network_error'}; }}
function resolveApnsTopic(appId: string, environment: PushProviderEnvironment): string | null { const apps = Config.push.apns.apps; const exact = apps.find((app) => app.appId === appId && app.environment === environment && app.topic); if (exact?.topic) return exact.topic; const fallback = apps.find((app) => app.appId === appId && app.topic); return fallback?.topic ?? null;}
async function getProviderToken(): Promise<string | null> { const cfg = Config.push.apns; if (!cfg.teamId || !cfg.keyId) return null; const privateKey = await resolveApnsPrivateKey(); if (!privateKey) return null; const cacheKey = createHash('sha256').update(`${cfg.teamId}:${cfg.keyId}:${privateKey}`).digest('hex'); const nowSeconds = Math.floor(Date.now() / 1000); const cached = providerTokenCache.get(cacheKey); if (cached && cached.expiresAtSeconds > nowSeconds) { return cached.token; } const key = await importPKCS8(privateKey, 'ES256'); const token = await new SignJWT({}) .setProtectedHeader({alg: 'ES256', kid: cfg.keyId}) .setIssuer(cfg.teamId) .setIssuedAt(nowSeconds) .sign(key); providerTokenCache.set(cacheKey, {token, expiresAtSeconds: nowSeconds + APNS_PROVIDER_TOKEN_TTL_SECONDS}); return token;}
async function resolveApnsPrivateKey(): Promise<string | null> { const cfg = Config.push.apns; if (cfg.privateKey && cfg.privateKey.trim().length > 0) { return normalizePem(cfg.privateKey); } if (!cfg.privateKeyPath) return null; const pem = await readFile(cfg.privateKeyPath, 'utf8'); return normalizePem(pem);}
function normalizePem(value: string): string { return value.replaceAll('\\n', '\n');}
function buildApnsHeaders(params: { providerToken: string; topic: string; payload: Record<string, unknown>;}): Record<string, string> { const data = isRecord(params.payload.data) ? params.payload.data : {}; const collapseId = optionalString(params.payload.tag) ?? optionalString(data.message_id); const isClear = isClearNotificationPayload(params.payload); const headers: Record<string, string> = { [constants.HTTP2_HEADER_METHOD]: constants.HTTP2_METHOD_POST, authorization: `bearer ${params.providerToken}`, 'apns-topic': params.topic, 'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT, 'apns-priority': isClear ? '5' : '10', 'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 300 : 86400)), 'content-type': 'application/json', }; if (collapseId && Buffer.byteLength(collapseId) <= 64) { headers['apns-collapse-id'] = collapseId; } return headers;}
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> { if (isClearNotificationPayload(payload)) { const data = isRecord(payload.data) ? payload.data : {}; const badge = normalizeBadgeCount(data.badge_count); const aps: Record<string, unknown> = { 'content-available': 1, }; if (badge !== undefined) { aps.badge = badge; } return { ...data, type: 'notification_clear', action: 'clear_channel', aps, }; } const data = isRecord(payload.data) ? payload.data : {}; const notification = isRecord(payload.notification) ? payload.notification : {}; const title = optionalString(notification.title) ?? optionalString(payload.title) ?? 'Fluxer'; const body = optionalString(notification.body) ?? optionalString(payload.body) ?? ''; const badge = normalizeBadgeCount(data.badge_count); const channelId = optionalString(data.channel_id); const threadId = optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message'; const imageUrl = firstString([payload.image_url, notification.image, notification.icon, payload.icon]); const aps: Record<string, unknown> = { alert: {title, body}, sound: APNS_DEFAULT_SOUND, badge, 'thread-id': threadId, category: APNS_CATEGORY_MESSAGE, 'interruption-level': 'active', 'relevance-score': 0.5, }; if (imageUrl) { aps['mutable-content'] = 1; } return { ...data, title, body, url: optionalString(data.url) ?? optionalString(notification.navigate), image_url: imageUrl, aps, };}
function isClearNotificationPayload(payload: Record<string, unknown>): boolean { return payload.type === 'notification_clear' || payload.action === 'clear_channel';}
async function sendApnsHttp2Request(params: { origin: string; path: string; headers: Record<string, string>; body: string;}): Promise<Http2Response> { const session = getApnsSession(params.origin); return new Promise<Http2Response>((resolve, reject) => { const req = session.request({ ...params.headers, [constants.HTTP2_HEADER_PATH]: params.path, }); const chunks: Array<Buffer> = []; let bodyBytes = 0; let statusCode = 0; const timeout = setTimeout(() => { req.close(constants.NGHTTP2_CANCEL); reject(new Error('APNs request timed out')); }, APNS_REQUEST_TIMEOUT_MS); req.on('response', (headers) => { const rawStatus = headers[constants.HTTP2_HEADER_STATUS]; statusCode = typeof rawStatus === 'number' ? rawStatus : Number(rawStatus ?? 0); }); req.on('data', (chunk) => { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bodyBytes += buffer.length; if (bodyBytes > APNS_MAX_RESPONSE_BYTES) { clearTimeout(timeout); req.close(constants.NGHTTP2_CANCEL); reject(new Error('APNs response body too large')); return; } chunks.push(buffer); }); req.on('end', () => { clearTimeout(timeout); resolve({statusCode, body: Buffer.concat(chunks).toString('utf8')}); }); req.on('error', (error) => { clearTimeout(timeout); dropApnsSession(params.origin); reject(error); }); req.end(params.body); });}
function getApnsSession(origin: string): ClientHttp2Session { const existing = apnsSessions.get(origin); if (existing && !existing.closed && !existing.destroyed) { return existing; } const session = connect(origin); apnsSessions.set(origin, session); session.on('goaway', () => dropApnsSession(origin)); session.on('error', (error) => { Logger.warn({error, origin}, 'APNs HTTP/2 session error'); dropApnsSession(origin); }); session.on('close', () => apnsSessions.delete(origin)); return session;}
function dropApnsSession(origin: string): void { const session = apnsSessions.get(origin); apnsSessions.delete(origin); if (session && !session.destroyed) { session.destroy(); }}
function parseApnsReason(body: string): string | null { if (!body) return null; try { const parsed: unknown = JSON.parse(body); if (isRecord(parsed) && typeof parsed.reason === 'string') return parsed.reason; return null; } catch { return null; }}
function isPermanentApnsFailure(statusCode: number, reason: string): boolean { if (statusCode === 410) return true; if (statusCode === 400 && (reason === 'BadDeviceToken' || reason === 'DeviceTokenNotForTopic')) return true; return reason === 'Unregistered';}
function normalizeBadgeCount(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value)); if (typeof value === 'string') { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; } return 0;}
function firstString(values: Array<unknown>): string | undefined { for (const value of values) { const stringValue = optionalString(value); if (stringValue) return stringValue; } return undefined;}
function optionalString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined;}
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value);}
export const ApnsPushServiceTestHooks = { buildApnsPayload, buildApnsHeaders, isPermanentApnsFailure,};