mirror of
https://github.com/bulwarkmail/relay.git
synced 2026-11-01 16:11:18 +00:00
chore: bring the live relay into the repository
This commit is contained in:
@@ -8,6 +8,8 @@ services:
|
||||
- 127.0.0.1:3003:3003
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3003
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import http2 from 'node:http2';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import {
|
||||
apnsJwt,
|
||||
apnsPayload,
|
||||
classifyApnsResponse,
|
||||
resetApnsState,
|
||||
sendApnsPush,
|
||||
type ApnsConfig,
|
||||
} from './apns.js';
|
||||
import type { ApnsSubscriptionRecord, ForwardPayload } from './types.js';
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const config: ApnsConfig = {
|
||||
keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
keyId: 'KEY1234567',
|
||||
teamId: 'TEAM123456',
|
||||
topic: 'org.bulwarkmail.mobile',
|
||||
};
|
||||
|
||||
const payload: ForwardPayload = {
|
||||
kind: 'jmap-email-push',
|
||||
accountLabel: 'ada@example.org',
|
||||
accountId: 'c',
|
||||
emailIds: ['e1', 'e2'],
|
||||
changed: { c: { EmailDelivery: 's9' } },
|
||||
};
|
||||
|
||||
const token = 'ab'.repeat(32);
|
||||
const record: ApnsSubscriptionRecord = {
|
||||
kind: 'apns',
|
||||
deviceToken: token,
|
||||
environment: 'sandbox',
|
||||
verificationCode: null,
|
||||
createdAt: 0,
|
||||
lastPushAt: null,
|
||||
};
|
||||
|
||||
describe('apnsPayload', () => {
|
||||
it('carries ids and a placeholder text, nothing about the message', () => {
|
||||
const body = apnsPayload(payload) as {
|
||||
aps: { alert: unknown; 'mutable-content': number; 'thread-id': string };
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
assert.deepEqual(Object.keys(body).sort(), ['aps', 'body']);
|
||||
assert.deepEqual(body.aps.alert, { title: 'Bulwark Mail', body: 'New message' });
|
||||
// The extension only runs for mutable notifications.
|
||||
assert.equal(body.aps['mutable-content'], 1);
|
||||
assert.equal(body.aps['thread-id'], 'c');
|
||||
assert.deepEqual(body.body, {
|
||||
kind: 'jmap-email-push',
|
||||
accountLabel: 'ada@example.org',
|
||||
accountId: 'c',
|
||||
emailIds: ['e1', 'e2'],
|
||||
changed: { c: { EmailDelivery: 's9' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyApnsResponse', () => {
|
||||
it('accepts 200', () => {
|
||||
assert.deepEqual(classifyApnsResponse(200, ''), { ok: true, status: 200, unregistered: false, reason: undefined });
|
||||
});
|
||||
|
||||
it('drops tokens APNs calls dead', () => {
|
||||
assert.equal(classifyApnsResponse(410, '{"reason":"Unregistered"}').unregistered, true);
|
||||
assert.equal(classifyApnsResponse(400, '{"reason":"BadDeviceToken"}').unregistered, true);
|
||||
assert.equal(classifyApnsResponse(400, '{"reason":"DeviceTokenNotForTopic"}').unregistered, true);
|
||||
});
|
||||
|
||||
it('keeps the token for errors on our side', () => {
|
||||
const r = classifyApnsResponse(403, '{"reason":"ExpiredProviderToken"}');
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.unregistered, false);
|
||||
assert.equal(r.reason, 'ExpiredProviderToken');
|
||||
assert.equal(classifyApnsResponse(429, '{"reason":"TooManyRequests"}').unregistered, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apnsJwt', () => {
|
||||
beforeEach(() => resetApnsState(config));
|
||||
|
||||
it('signs ES256 with the key id and team id', () => {
|
||||
const jwt = apnsJwt(config, 1_700_000_000_000);
|
||||
const [h, c, s] = jwt.split('.');
|
||||
assert.deepEqual(JSON.parse(Buffer.from(h, 'base64url').toString()), { alg: 'ES256', kid: 'KEY1234567' });
|
||||
assert.deepEqual(JSON.parse(Buffer.from(c, 'base64url').toString()), { iss: 'TEAM123456', iat: 1_700_000_000 });
|
||||
const valid = crypto.verify('sha256', Buffer.from(`${h}.${c}`), { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url'));
|
||||
assert.equal(valid, true);
|
||||
});
|
||||
|
||||
it('reuses a token for 40 minutes, then signs a new one', () => {
|
||||
const first = apnsJwt(config, 1_700_000_000_000);
|
||||
assert.equal(apnsJwt(config, 1_700_000_000_000 + 39 * 60_000), first);
|
||||
assert.notEqual(apnsJwt(config, 1_700_000_000_000 + 41 * 60_000), first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendApnsPush', () => {
|
||||
let server: http2.Http2Server;
|
||||
let seen: { headers: http2.IncomingHttpHeaders; body: string } | null;
|
||||
let answer: { status: number; body: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
resetApnsState(config);
|
||||
seen = null;
|
||||
answer = { status: 200, body: '' };
|
||||
server = http2.createServer();
|
||||
server.on('stream', (stream, headers) => {
|
||||
let body = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => { body += chunk; });
|
||||
stream.on('end', () => {
|
||||
seen = { headers, body };
|
||||
stream.respond({ ':status': answer.status, 'content-type': 'application/json' });
|
||||
stream.end(answer.body);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
process.env.APNS_ORIGIN = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.APNS_ORIGIN;
|
||||
resetApnsState(null);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('posts an alert push for the device token with the bearer token and topic', async () => {
|
||||
const result = await sendApnsPush(record, payload);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(seen);
|
||||
assert.equal(seen.headers[':path'], `/3/device/${token}`);
|
||||
assert.equal(seen.headers['apns-topic'], 'org.bulwarkmail.mobile');
|
||||
assert.equal(seen.headers['apns-push-type'], 'alert');
|
||||
assert.match(String(seen.headers.authorization), /^bearer [\w-]+\.[\w-]+\.[\w-]+$/);
|
||||
assert.deepEqual(JSON.parse(seen.body), apnsPayload(payload));
|
||||
});
|
||||
|
||||
it('reports a removed app so the relay can drop the subscription', async () => {
|
||||
answer = { status: 410, body: '{"reason":"Unregistered"}' };
|
||||
const result = await sendApnsPush(record, payload);
|
||||
assert.equal(result.unregistered, true);
|
||||
assert.equal(result.status, 410);
|
||||
});
|
||||
|
||||
it('fails without a key instead of sending', async () => {
|
||||
resetApnsState(null);
|
||||
const result = await sendApnsPush(record, payload);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(seen, null);
|
||||
});
|
||||
});
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import http2 from 'node:http2';
|
||||
import path from 'node:path';
|
||||
import { failureReason, type SendResult } from './forward-result.js';
|
||||
import type { ApnsSubscriptionRecord, ForwardPayload } from './types.js';
|
||||
|
||||
// Direct delivery to Apple Push Notification service for the iOS app.
|
||||
//
|
||||
// Token-based auth: a .p8 signing key (APNS_KEY: inline PEM or a path,
|
||||
// default <PUSH_DATA_DIR>/apns-key.p8), its key id (APNS_KEY_ID) and the
|
||||
// team id (APNS_TEAM_ID). The JWT is reused for 40 minutes; Apple rejects
|
||||
// one older than an hour and throttles fresh ones more often than every 20.
|
||||
//
|
||||
// Content-blind like the other transports: the push carries a fixed
|
||||
// placeholder text and the ids, never a subject or sender. The app's
|
||||
// Notification Service Extension fetches those over its own JMAP session on
|
||||
// the device and rewrites the notification before it is shown.
|
||||
|
||||
const HOSTS = {
|
||||
production: 'https://api.push.apple.com',
|
||||
sandbox: 'https://api.sandbox.push.apple.com',
|
||||
} as const;
|
||||
|
||||
const JWT_LIFETIME_MS = 40 * 60 * 1000;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface ApnsConfig {
|
||||
keyPem: string;
|
||||
keyId: string;
|
||||
teamId: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
let configPromise: Promise<ApnsConfig | null> | null = null;
|
||||
let cachedJwt: { token: string; issuedAt: number } | null = null;
|
||||
const sessions = new Map<string, http2.ClientHttp2Session>();
|
||||
|
||||
async function loadConfig(): Promise<ApnsConfig | null> {
|
||||
const keyId = process.env.APNS_KEY_ID?.trim();
|
||||
const teamId = process.env.APNS_TEAM_ID?.trim();
|
||||
if (!keyId || !teamId) return null;
|
||||
const inline = process.env.APNS_KEY?.trim();
|
||||
let keyPem: string;
|
||||
if (inline && inline.startsWith('-----BEGIN')) {
|
||||
keyPem = inline;
|
||||
} else {
|
||||
const file = inline || path.join(process.env.PUSH_DATA_DIR ?? './data', 'apns-key.p8');
|
||||
try {
|
||||
keyPem = await fs.readFile(file, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { keyPem, keyId, teamId, topic: process.env.APNS_TOPIC?.trim() || 'org.bulwarkmail.mobile' };
|
||||
}
|
||||
|
||||
/** The APNs configuration, or null when the relay has no key (APNs off). */
|
||||
export function getApnsConfig(): Promise<ApnsConfig | null> {
|
||||
configPromise ??= loadConfig();
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/** Test hook: forget the configuration, the JWT and open connections. */
|
||||
export function resetApnsState(config?: ApnsConfig | null): void {
|
||||
configPromise = config === undefined ? null : Promise.resolve(config);
|
||||
cachedJwt = null;
|
||||
for (const session of sessions.values()) session.close();
|
||||
sessions.clear();
|
||||
}
|
||||
|
||||
function base64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
export function apnsJwt(config: ApnsConfig, now = Date.now()): string {
|
||||
if (cachedJwt && now - cachedJwt.issuedAt < JWT_LIFETIME_MS) return cachedJwt.token;
|
||||
const header = base64url(JSON.stringify({ alg: 'ES256', kid: config.keyId }));
|
||||
const claims = base64url(JSON.stringify({ iss: config.teamId, iat: Math.floor(now / 1000) }));
|
||||
const signature = crypto.sign('sha256', Buffer.from(`${header}.${claims}`), {
|
||||
key: config.keyPem,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
const token = `${header}.${claims}.${base64url(signature)}`;
|
||||
cachedJwt = { token, issuedAt: now };
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification Apple shows if the app's extension can't fetch the
|
||||
* message. Only ids leave the relay; `mutable-content` hands the
|
||||
* notification to the extension first.
|
||||
*/
|
||||
export function apnsPayload(payload: ForwardPayload): Record<string, unknown> {
|
||||
return {
|
||||
aps: {
|
||||
alert: { title: 'Bulwark Mail', body: 'New message' },
|
||||
sound: 'default',
|
||||
'mutable-content': 1,
|
||||
// Groups the notifications of one account.
|
||||
'thread-id': payload.accountId || payload.accountLabel,
|
||||
},
|
||||
// expo-notifications hands an app `userInfo.body` as the notification's
|
||||
// data, so the ids go there; the extension adds the message it showed.
|
||||
body: {
|
||||
kind: payload.kind,
|
||||
accountLabel: payload.accountLabel,
|
||||
accountId: payload.accountId,
|
||||
emailIds: payload.emailIds,
|
||||
changed: payload.changed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// APNs reasons that mean the token will never work again here: the app was
|
||||
// removed, or the token belongs to the other environment or another topic.
|
||||
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
|
||||
|
||||
export function classifyApnsResponse(status: number, body: string): SendResult {
|
||||
let reason: string | undefined;
|
||||
try {
|
||||
reason = body ? (JSON.parse(body) as { reason?: string }).reason : undefined;
|
||||
} catch {
|
||||
reason = body || undefined;
|
||||
}
|
||||
const ok = status === 200;
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
unregistered: status === 410 || (status === 400 && !!reason && DEAD_TOKEN_REASONS.has(reason)),
|
||||
reason: ok ? undefined : failureReason(reason ?? `HTTP ${status}`),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionFor(origin: string): http2.ClientHttp2Session {
|
||||
const existing = sessions.get(origin);
|
||||
if (existing && !existing.closed && !existing.destroyed) return existing;
|
||||
const session = http2.connect(origin);
|
||||
const forget = () => {
|
||||
if (sessions.get(origin) === session) sessions.delete(origin);
|
||||
};
|
||||
session.on('close', forget);
|
||||
session.on('error', forget);
|
||||
session.on('goaway', forget);
|
||||
// Don't keep the process alive just for an idle connection.
|
||||
session.unref();
|
||||
sessions.set(origin, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function sendApnsPush(
|
||||
record: ApnsSubscriptionRecord,
|
||||
payload: ForwardPayload,
|
||||
): Promise<SendResult> {
|
||||
const config = await getApnsConfig();
|
||||
if (!config) {
|
||||
return { ok: false, status: 0, unregistered: false, reason: 'APNs not configured on this relay' };
|
||||
}
|
||||
// APNS_ORIGIN points every push at another server (tests, a local mock).
|
||||
const origin = process.env.APNS_ORIGIN || HOSTS[record.environment];
|
||||
const body = JSON.stringify(apnsPayload(payload));
|
||||
const session = sessionFor(origin);
|
||||
return new Promise<SendResult>((resolve, reject) => {
|
||||
const req = session.request({
|
||||
':method': 'POST',
|
||||
':path': `/3/device/${record.deviceToken}`,
|
||||
authorization: `bearer ${apnsJwt(config)}`,
|
||||
'apns-topic': config.topic,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': '10',
|
||||
// A day: mail older than that is better found by opening the app.
|
||||
'apns-expiration': String(Math.floor(Date.now() / 1000) + 24 * 3600),
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
let status = 0;
|
||||
let text = '';
|
||||
req.setEncoding('utf8');
|
||||
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.close(http2.constants.NGHTTP2_CANCEL));
|
||||
req.on('response', (headers) => {
|
||||
status = Number(headers[':status'] ?? 0);
|
||||
});
|
||||
req.on('data', (chunk: string) => {
|
||||
text += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
// A stream closed without a response (timeout, reset) has no status.
|
||||
if (status === 0) {
|
||||
resolve({ ok: false, status: 0, unregistered: false, reason: 'no response from APNs' });
|
||||
return;
|
||||
}
|
||||
resolve(classifyApnsResponse(status, text));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { isSenderMismatch, resetFcmState, sendFcmPush } from './fcm.js';
|
||||
import type { FcmSubscriptionRecord, ForwardPayload } from './types.js';
|
||||
|
||||
const payload = {
|
||||
kind: 'state',
|
||||
accountLabel: 'a',
|
||||
accountId: 'a',
|
||||
emailIds: [],
|
||||
changed: {},
|
||||
} as unknown as ForwardPayload;
|
||||
|
||||
function record(token: string): FcmSubscriptionRecord {
|
||||
return { kind: 'fcm', fcmToken: token } as unknown as FcmSubscriptionRecord;
|
||||
}
|
||||
|
||||
function account(projectId: string) {
|
||||
const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
return {
|
||||
type: 'service_account',
|
||||
project_id: projectId,
|
||||
private_key_id: `${projectId}-key`,
|
||||
private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
client_email: `relay@${projectId}.iam.gserviceaccount.com`,
|
||||
token_uri: 'https://oauth.test/token',
|
||||
};
|
||||
}
|
||||
|
||||
// Each fake project accepts only its own tokens ("<project>:<id>"), like FCM.
|
||||
const sends: string[] = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fcm-'));
|
||||
fs.writeFileSync(path.join(dir, 'fcm-service-account.json'), JSON.stringify(account('new-project')));
|
||||
fs.writeFileSync(path.join(dir, 'fcm-service-account-old.json'), JSON.stringify(account('old-project')));
|
||||
process.env.PUSH_DATA_DIR = dir;
|
||||
delete process.env.FCM_SERVICE_ACCOUNT_JSON;
|
||||
resetFcmState();
|
||||
sends.length = 0;
|
||||
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === 'https://oauth.test/token') {
|
||||
return new Response(JSON.stringify({ access_token: 't', expires_in: 3600 }), { status: 200 });
|
||||
}
|
||||
const project = decodeURIComponent(url.match(/projects\/([^/]+)\/messages:send/)![1]);
|
||||
const token = JSON.parse(String(init!.body)).message.token as string;
|
||||
sends.push(project);
|
||||
if (token.startsWith(`${project}:`)) {
|
||||
return new Response(JSON.stringify({ name: 'm' }), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ error: { code: 403, status: 'PERMISSION_DENIED', message: 'SenderId mismatch' } }),
|
||||
{ status: 403 },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('sendFcmPush across Firebase projects', () => {
|
||||
it('sends a new-project token through the main account only', async () => {
|
||||
const result = await sendFcmPush(record('new-project:1'), payload);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(sends, ['new-project']);
|
||||
});
|
||||
|
||||
it('falls back to the old project on a sender mismatch, then goes there first', async () => {
|
||||
const first = await sendFcmPush(record('old-project:1'), payload);
|
||||
assert.equal(first.ok, true);
|
||||
assert.deepEqual(sends, ['new-project', 'old-project']);
|
||||
|
||||
sends.length = 0;
|
||||
const second = await sendFcmPush(record('old-project:1'), payload);
|
||||
assert.equal(second.ok, true);
|
||||
assert.deepEqual(sends, ['old-project']);
|
||||
});
|
||||
|
||||
it('reports the failure when no project takes the token', async () => {
|
||||
const result = await sendFcmPush(record('third-project:1'), payload);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, 403);
|
||||
assert.deepEqual(sends, ['new-project', 'old-project']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSenderMismatch', () => {
|
||||
it('matches FCM\'s answers for a token of another project', () => {
|
||||
assert.equal(isSenderMismatch(403, 'PERMISSION_DENIED', 'SenderId mismatch'), true);
|
||||
assert.equal(isSenderMismatch(403, 'SENDER_ID_MISMATCH', ''), true);
|
||||
assert.equal(isSenderMismatch(403, 'PERMISSION_DENIED', 'The caller does not have permission'), false);
|
||||
assert.equal(isSenderMismatch(404, 'NOT_FOUND', 'SenderId mismatch'), false);
|
||||
});
|
||||
});
|
||||
+97
-27
@@ -1,6 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { failureReason, type SendResult } from './forward-result.js';
|
||||
import type { FcmSubscriptionRecord, ForwardPayload } from './types.js';
|
||||
|
||||
interface ServiceAccount {
|
||||
@@ -12,25 +13,57 @@ interface ServiceAccount {
|
||||
token_uri: string;
|
||||
}
|
||||
|
||||
let cachedAccount: ServiceAccount | null = null;
|
||||
let cachedAccessToken: { token: string; expiresAt: number } | null = null;
|
||||
/**
|
||||
* One Firebase project the relay can send through. A token only works with
|
||||
* the project of the app that issued it, so the relay keeps a project per
|
||||
* app generation: the main service account (FCM_SERVICE_ACCOUNT_JSON or
|
||||
* fcm-service-account.json) and any fcm-service-account-<name>.json next to
|
||||
* it, e.g. the old project while installed apps still hold its tokens.
|
||||
*/
|
||||
interface FcmProject {
|
||||
account: ServiceAccount;
|
||||
accessToken: { token: string; expiresAt: number } | null;
|
||||
}
|
||||
|
||||
async function loadServiceAccount(): Promise<ServiceAccount> {
|
||||
if (cachedAccount) return cachedAccount;
|
||||
let cachedProjects: FcmProject[] | null = null;
|
||||
|
||||
/** The project that last accepted each token, so it is tried first. */
|
||||
const projectForToken = new Map<string, string>();
|
||||
|
||||
async function readAccount(filePath: string): Promise<ServiceAccount> {
|
||||
return JSON.parse(await fs.readFile(filePath, 'utf8')) as ServiceAccount;
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<FcmProject[]> {
|
||||
if (cachedProjects) return cachedProjects;
|
||||
const dir = process.env.PUSH_DATA_DIR ?? './data';
|
||||
|
||||
const inline = process.env.FCM_SERVICE_ACCOUNT_JSON;
|
||||
let main: ServiceAccount;
|
||||
if (inline && inline.trim().startsWith('{')) {
|
||||
cachedAccount = JSON.parse(inline) as ServiceAccount;
|
||||
return cachedAccount;
|
||||
main = JSON.parse(inline) as ServiceAccount;
|
||||
} else {
|
||||
main = await readAccount(
|
||||
inline && inline.trim().length > 0 ? inline : path.join(dir, 'fcm-service-account.json'),
|
||||
);
|
||||
}
|
||||
|
||||
const filePath =
|
||||
inline && inline.trim().length > 0
|
||||
? inline
|
||||
: path.join(process.env.PUSH_DATA_DIR ?? './data', 'fcm-service-account.json');
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
cachedAccount = JSON.parse(raw) as ServiceAccount;
|
||||
return cachedAccount;
|
||||
const extraFiles = (await fs.readdir(dir).catch(() => [] as string[]))
|
||||
.filter((f) => /^fcm-service-account-.+\.json$/.test(f))
|
||||
.sort();
|
||||
const accounts = [main];
|
||||
for (const f of extraFiles) {
|
||||
const account = await readAccount(path.join(dir, f));
|
||||
if (!accounts.some((a) => a.project_id === account.project_id)) accounts.push(account);
|
||||
}
|
||||
cachedProjects = accounts.map((account) => ({ account, accessToken: null }));
|
||||
return cachedProjects;
|
||||
}
|
||||
|
||||
/** Test hook: forget loaded accounts, tokens and which project took which token. */
|
||||
export function resetFcmState(): void {
|
||||
cachedProjects = null;
|
||||
projectForToken.clear();
|
||||
}
|
||||
|
||||
function base64url(buf: Buffer | string): string {
|
||||
@@ -38,13 +71,13 @@ function base64url(buf: Buffer | string): string {
|
||||
return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function mintAccessToken(): Promise<string> {
|
||||
async function mintAccessToken(project: FcmProject): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (cachedAccessToken && cachedAccessToken.expiresAt - 60_000 > now) {
|
||||
return cachedAccessToken.token;
|
||||
if (project.accessToken && project.accessToken.expiresAt - 60_000 > now) {
|
||||
return project.accessToken.token;
|
||||
}
|
||||
|
||||
const account = await loadServiceAccount();
|
||||
const { account } = project;
|
||||
const iat = Math.floor(now / 1000);
|
||||
const exp = iat + 3600;
|
||||
const header = { alg: 'RS256', typ: 'JWT', kid: account.private_key_id };
|
||||
@@ -74,17 +107,22 @@ async function mintAccessToken(): Promise<string> {
|
||||
throw new Error(`FCM oauth2 token failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as { access_token: string; expires_in: number };
|
||||
cachedAccessToken = {
|
||||
project.accessToken = {
|
||||
token: body.access_token,
|
||||
expiresAt: now + body.expires_in * 1000,
|
||||
};
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
export interface FcmSendResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
unregistered: boolean;
|
||||
/**
|
||||
* FCM's answer to a token from another Firebase project: 403 with
|
||||
* PERMISSION_DENIED "SenderId mismatch" (SENDER_ID_MISMATCH in the details).
|
||||
*/
|
||||
export function isSenderMismatch(status: number, errStatus: string, message: string): boolean {
|
||||
return status === 403 && (errStatus === 'SENDER_ID_MISMATCH' || /sender ?id mismatch/i.test(message));
|
||||
}
|
||||
|
||||
export interface FcmSendResult extends SendResult {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
@@ -92,8 +130,34 @@ export async function sendFcmPush(
|
||||
record: FcmSubscriptionRecord,
|
||||
payload: ForwardPayload,
|
||||
): Promise<FcmSendResult> {
|
||||
const account = await loadServiceAccount();
|
||||
const accessToken = await mintAccessToken();
|
||||
const projects = await loadProjects();
|
||||
const known = projectForToken.get(record.fcmToken);
|
||||
const ordered = [
|
||||
...projects.filter((p) => p.account.project_id === known),
|
||||
...projects.filter((p) => p.account.project_id !== known),
|
||||
];
|
||||
let result: FcmSendResult & { senderMismatch?: boolean; projectId?: string } = {
|
||||
ok: false,
|
||||
status: 0,
|
||||
unregistered: false,
|
||||
};
|
||||
for (const project of ordered) {
|
||||
result = await sendWithProject(project, record, payload);
|
||||
if (!result.senderMismatch) break;
|
||||
}
|
||||
if (result.ok && result.projectId) projectForToken.set(record.fcmToken, result.projectId);
|
||||
if (result.unregistered) projectForToken.delete(record.fcmToken);
|
||||
const { senderMismatch: _mismatch, projectId: _project, ...rest } = result;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function sendWithProject(
|
||||
project: FcmProject,
|
||||
record: FcmSubscriptionRecord,
|
||||
payload: ForwardPayload,
|
||||
): Promise<FcmSendResult & { senderMismatch: boolean; projectId: string }> {
|
||||
const { account } = project;
|
||||
const accessToken = await mintAccessToken(project);
|
||||
// Data-only push so onMessageReceived always fires — the app enriches it
|
||||
// (sender/subject/avatar) via JMAP fetch before posting a notification.
|
||||
// FCM data values must be strings, hence the JSON-encoded fields.
|
||||
@@ -133,10 +197,11 @@ export async function sendFcmPush(
|
||||
// keep raw text
|
||||
}
|
||||
|
||||
const errStatus =
|
||||
const fcmError =
|
||||
typeof parsed === 'object' && parsed && 'error' in parsed
|
||||
? ((parsed as { error?: { status?: string } }).error?.status ?? '')
|
||||
: '';
|
||||
? (parsed as { error?: { status?: string; message?: string } }).error
|
||||
: undefined;
|
||||
const errStatus = fcmError?.status ?? '';
|
||||
const unregistered =
|
||||
res.status === 404 || errStatus === 'UNREGISTERED' || errStatus === 'NOT_FOUND';
|
||||
|
||||
@@ -144,6 +209,11 @@ export async function sendFcmPush(
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
unregistered,
|
||||
senderMismatch: isSenderMismatch(res.status, errStatus, fcmError?.message ?? ''),
|
||||
projectId: account.project_id,
|
||||
reason: res.ok
|
||||
? undefined
|
||||
: failureReason(fcmError ? `${errStatus}: ${fcmError.message ?? ''}` : rawBody),
|
||||
body: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
classifyForward,
|
||||
failureReason,
|
||||
pushServiceHost,
|
||||
statusLabel,
|
||||
} from './forward-result.js';
|
||||
import type { SubscriptionRecord } from './types.js';
|
||||
|
||||
const base = { verificationCode: null, createdAt: 0, lastPushAt: null };
|
||||
|
||||
describe('classifyForward', () => {
|
||||
it('prefers unregistered over the status class', () => {
|
||||
assert.equal(classifyForward({ ok: false, status: 410, unregistered: true }), 'unregistered');
|
||||
});
|
||||
|
||||
it('buckets failures by status class, and no response as fail', () => {
|
||||
assert.equal(classifyForward({ ok: true, status: 201, unregistered: false }), 'ok');
|
||||
assert.equal(classifyForward({ ok: false, status: 429, unregistered: false }), 'http-4xx');
|
||||
assert.equal(classifyForward({ ok: false, status: 503, unregistered: false }), 'http-5xx');
|
||||
assert.equal(classifyForward({ ok: false, status: 0, unregistered: false }), 'fail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusLabel', () => {
|
||||
it('uses the HTTP status, or none when nothing came back', () => {
|
||||
assert.equal(statusLabel(429), '429');
|
||||
assert.equal(statusLabel(0), 'none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushServiceHost', () => {
|
||||
it('returns only the host, never the endpoint path', () => {
|
||||
const web: SubscriptionRecord = {
|
||||
...base,
|
||||
kind: 'web',
|
||||
webPush: {
|
||||
endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/secret-token',
|
||||
keys: { p256dh: 'p', auth: 'a' },
|
||||
},
|
||||
};
|
||||
const up: SubscriptionRecord = {
|
||||
...base,
|
||||
kind: 'up',
|
||||
endpoint: 'https://ntfy.example.org/upSecretTopic?up=1',
|
||||
keys: null,
|
||||
};
|
||||
const fcm: SubscriptionRecord = { ...base, kind: 'fcm', fcmToken: 'token' };
|
||||
assert.equal(pushServiceHost(web), 'updates.push.services.mozilla.com');
|
||||
assert.equal(pushServiceHost(up), 'ntfy.example.org');
|
||||
assert.equal(pushServiceHost(fcm), 'fcm.googleapis.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failureReason', () => {
|
||||
it('prefers the push service body over the generic web-push message', () => {
|
||||
const error = Object.assign(new Error('Received unexpected response code'), {
|
||||
statusCode: 403,
|
||||
body: '{"reason":"BadJwtToken"}\n',
|
||||
});
|
||||
assert.equal(failureReason(error), '{"reason":"BadJwtToken"}');
|
||||
});
|
||||
|
||||
it('adds the network error code from the cause', () => {
|
||||
const error = new Error('fetch failed', { cause: { code: 'ECONNRESET' } });
|
||||
assert.equal(failureReason(error), 'fetch failed (ECONNRESET)');
|
||||
});
|
||||
|
||||
it('collapses whitespace, truncates long bodies and drops empty ones', () => {
|
||||
assert.equal(failureReason('<html>\n <body>Too Many Requests</body>\n</html>'),
|
||||
'<html> <body>Too Many Requests</body> </html>');
|
||||
assert.equal(failureReason('x'.repeat(500))?.length, 201);
|
||||
assert.equal(failureReason(' '), undefined);
|
||||
assert.equal(failureReason(null), undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SubscriptionRecord } from './types.js';
|
||||
|
||||
export type ForwardOutcome = 'ok' | 'unregistered' | 'http-4xx' | 'http-5xx' | 'fail';
|
||||
|
||||
// What a transport reports after trying to deliver one push. `status` is the
|
||||
// push service's HTTP status, 0 when no response came back. `reason` is a
|
||||
// short single-line explanation (the service's error body or the network
|
||||
// error) for the failure log.
|
||||
export interface SendResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
unregistered: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const MAX_REASON_CHARS = 200;
|
||||
|
||||
export function classifyForward(result: SendResult): ForwardOutcome {
|
||||
if (result.unregistered) return 'unregistered';
|
||||
if (result.ok) return 'ok';
|
||||
if (result.status >= 500) return 'http-5xx';
|
||||
if (result.status >= 400) return 'http-4xx';
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
// Metric label for the push service's answer. "none" covers network errors,
|
||||
// timeouts and a relay without VAPID keys - nothing came back to look at.
|
||||
export function statusLabel(status: number): string {
|
||||
return status > 0 ? String(status) : 'none';
|
||||
}
|
||||
|
||||
// Only the host: Web Push and UnifiedPush endpoint paths are bearer
|
||||
// capabilities and must not end up in logs.
|
||||
export function pushServiceHost(record: SubscriptionRecord): string {
|
||||
if (record.kind === 'fcm') return 'fcm.googleapis.com';
|
||||
if (record.kind === 'apns') {
|
||||
return record.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com';
|
||||
}
|
||||
const endpoint = record.kind === 'web' ? record.webPush.endpoint : record.endpoint;
|
||||
try {
|
||||
return new URL(endpoint).hostname;
|
||||
} catch {
|
||||
return 'invalid-endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
// Turn a push service error body, a thrown error or plain text into one
|
||||
// short log-friendly line. web-push's WebPushError carries the service's
|
||||
// response body, which says far more than its generic message.
|
||||
export function failureReason(error: unknown): string | undefined {
|
||||
let text = '';
|
||||
if (typeof error === 'string') {
|
||||
text = error;
|
||||
} else if (error instanceof Error) {
|
||||
const body = (error as { body?: unknown }).body;
|
||||
text = typeof body === 'string' && body.trim() ? body : error.message;
|
||||
const code =
|
||||
(error.cause as { code?: unknown } | undefined)?.code ??
|
||||
(error as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && !text.includes(code)) text += ` (${code})`;
|
||||
}
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
if (!text) return undefined;
|
||||
return text.length > MAX_REASON_CHARS ? `${text.slice(0, MAX_REASON_CHARS)}…` : text;
|
||||
}
|
||||
+9
-2
@@ -54,8 +54,8 @@ export const pushesReceived = new Counter({
|
||||
|
||||
export const pushesForwarded = new Counter({
|
||||
name: 'relay_pushes_forwarded_total',
|
||||
help: 'Push forwarding outcomes by transport',
|
||||
labelNames: ['result', 'transport'] as const,
|
||||
help: 'Push forwarding outcomes by transport and push service HTTP status ("none" = no response)',
|
||||
labelNames: ['result', 'transport', 'status'] as const,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
@@ -66,6 +66,13 @@ export const fcmDurationSeconds = new Histogram({
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
export const apnsDurationSeconds = new Histogram({
|
||||
name: 'relay_apns_send_duration_seconds',
|
||||
help: 'Latency of POST to api.push.apple.com (or the sandbox)',
|
||||
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
export const webPushDurationSeconds = new Histogram({
|
||||
name: 'relay_webpush_send_duration_seconds',
|
||||
help: 'Latency of encrypted Web Push delivery',
|
||||
|
||||
+116
-12
@@ -3,10 +3,21 @@ import { URL } from 'node:url';
|
||||
import { logger } from './logger.js';
|
||||
import { subscriptionStore } from './store.js';
|
||||
import { sendFcmPush } from './fcm.js';
|
||||
import { getApnsConfig, sendApnsPush } from './apns.js';
|
||||
import { getVapidPublicKey, sendWebPush } from './webpush.js';
|
||||
import { sendUnifiedPush } from './unifiedpush.js';
|
||||
import { isForwardableBody, toForwardPayload } from './payload.js';
|
||||
import {
|
||||
classifyForward,
|
||||
failureReason,
|
||||
pushServiceHost,
|
||||
statusLabel,
|
||||
type ForwardOutcome,
|
||||
type SendResult,
|
||||
} from './forward-result.js';
|
||||
import {
|
||||
isValidApnsEnvironment,
|
||||
isValidApnsToken,
|
||||
isValidFcmToken,
|
||||
isValidSubscriptionId,
|
||||
isValidUnifiedPushEndpoint,
|
||||
@@ -14,6 +25,7 @@ import {
|
||||
isValidWebPushSubscription,
|
||||
} from './validation.js';
|
||||
import type {
|
||||
ApnsSubscriptionRecord,
|
||||
FcmSubscriptionRecord,
|
||||
ForwardPayload,
|
||||
JmapPushBody,
|
||||
@@ -31,6 +43,7 @@ import {
|
||||
pushesReceived,
|
||||
pushesForwarded,
|
||||
fcmDurationSeconds,
|
||||
apnsDurationSeconds,
|
||||
webPushDurationSeconds,
|
||||
upDurationSeconds,
|
||||
} from './metrics.js';
|
||||
@@ -169,6 +182,51 @@ async function handleRegister(
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// The iOS app. Refused up front when the relay has no APNs key, as Web Push
|
||||
// is without VAPID keys: the record would be accepted but every push fail.
|
||||
async function handleRegisterApns(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): Promise<void> {
|
||||
if (!(await getApnsConfig())) {
|
||||
return sendJson(res, 503, { error: 'APNs not configured' });
|
||||
}
|
||||
const body = (await readJson(req)) as
|
||||
| { subscriptionId?: unknown; deviceToken?: unknown; environment?: unknown; accountLabel?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
return sendJson(res, 400, { error: 'Invalid JSON' });
|
||||
}
|
||||
const { subscriptionId, deviceToken, environment, accountLabel } = body;
|
||||
if (!isValidSubscriptionId(subscriptionId)) {
|
||||
return sendJson(res, 400, { error: 'Invalid subscriptionId' });
|
||||
}
|
||||
if (!isValidApnsToken(deviceToken)) {
|
||||
return sendJson(res, 400, { error: 'Invalid deviceToken' });
|
||||
}
|
||||
if (!isValidApnsEnvironment(environment)) {
|
||||
return sendJson(res, 400, { error: 'Invalid environment' });
|
||||
}
|
||||
|
||||
const existing = await subscriptionStore.get(subscriptionId);
|
||||
const record: ApnsSubscriptionRecord = {
|
||||
kind: 'apns',
|
||||
deviceToken: deviceToken.toLowerCase(),
|
||||
environment,
|
||||
// See handleRegister for why we always reset this.
|
||||
verificationCode: null,
|
||||
createdAt: existing?.createdAt ?? Date.now(),
|
||||
lastPushAt: existing?.lastPushAt ?? null,
|
||||
accountLabel:
|
||||
typeof accountLabel === 'string' ? accountLabel.slice(0, 120) : undefined,
|
||||
};
|
||||
await subscriptionStore.put(subscriptionId, record);
|
||||
subscriptionsRegistered.inc({ transport: 'apns' });
|
||||
await refreshActiveGauge();
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
async function handleRegisterWeb(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
@@ -351,14 +409,25 @@ async function handleJmap(
|
||||
// messages that passed - both become the same content-blind device payload.
|
||||
if (isForwardableBody(body)) {
|
||||
pushesReceived.inc({ type: body['@type'] });
|
||||
const result = await dispatch(record, toForwardPayload(body, record.accountLabel));
|
||||
let outcome: 'ok' | 'unregistered' | 'http-4xx' | 'http-5xx' | 'fail';
|
||||
if (result.unregistered) outcome = 'unregistered';
|
||||
else if (result.ok) outcome = 'ok';
|
||||
else if (result.status >= 500) outcome = 'http-5xx';
|
||||
else if (result.status >= 400) outcome = 'http-4xx';
|
||||
else outcome = 'fail';
|
||||
pushesForwarded.inc({ result: outcome, transport: record.kind });
|
||||
let result: SendResult;
|
||||
try {
|
||||
result = await dispatch(record, toForwardPayload(body, record.accountLabel));
|
||||
} catch (error) {
|
||||
// FCM token minting and sending throw on network errors. Count and log
|
||||
// them like any other failed forward, then answer 500 as before so the
|
||||
// JMAP server may retry.
|
||||
const failed = { ok: false, status: 0, unregistered: false, reason: failureReason(error) };
|
||||
pushesForwarded.inc({ result: 'fail', transport: record.kind, status: 'none' });
|
||||
logForwardFailure(id, record, 'fail', failed);
|
||||
return sendJson(res, 500, { error: 'Internal server error' });
|
||||
}
|
||||
const outcome = classifyForward(result);
|
||||
pushesForwarded.inc({
|
||||
result: outcome,
|
||||
transport: record.kind,
|
||||
status: statusLabel(result.status),
|
||||
});
|
||||
if (outcome !== 'ok') logForwardFailure(id, record, outcome, result);
|
||||
record.lastPushAt = Date.now();
|
||||
await subscriptionStore.put(id, record);
|
||||
if (result.unregistered) {
|
||||
@@ -366,9 +435,11 @@ async function handleJmap(
|
||||
const reason =
|
||||
record.kind === 'fcm'
|
||||
? 'fcm-unregistered'
|
||||
: record.kind === 'up'
|
||||
? 'unifiedpush-gone'
|
||||
: 'webpush-gone';
|
||||
: record.kind === 'apns'
|
||||
? 'apns-unregistered'
|
||||
: record.kind === 'up'
|
||||
? 'unifiedpush-gone'
|
||||
: 'webpush-gone';
|
||||
subscriptionsUnregistered.inc({ reason, transport: record.kind });
|
||||
await refreshActiveGauge();
|
||||
}
|
||||
@@ -378,16 +449,43 @@ async function handleJmap(
|
||||
return sendJson(res, 400, { error: 'Unsupported JMAP push type' });
|
||||
}
|
||||
|
||||
// The request log shows a 200 for most failed forwards, so this line is the
|
||||
// only record of why a device missed a push.
|
||||
function logForwardFailure(
|
||||
id: string,
|
||||
record: SubscriptionRecord,
|
||||
outcome: ForwardOutcome,
|
||||
result: SendResult,
|
||||
): void {
|
||||
const fields = {
|
||||
subscriptionId: id,
|
||||
transport: record.kind,
|
||||
host: pushServiceHost(record),
|
||||
outcome,
|
||||
status: result.status,
|
||||
reason: result.reason,
|
||||
};
|
||||
// An expired subscription is routine cleanup, not a delivery problem.
|
||||
if (outcome === 'unregistered') logger.info('relay: push subscription gone', fields);
|
||||
else logger.warn('relay: push not delivered', fields);
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
record: SubscriptionRecord,
|
||||
payload: ForwardPayload,
|
||||
): Promise<{ ok: boolean; status: number; unregistered: boolean }> {
|
||||
): Promise<SendResult> {
|
||||
if (record.kind === 'fcm') {
|
||||
const timer = fcmDurationSeconds.startTimer();
|
||||
const result = await sendFcmPush(record, payload);
|
||||
timer();
|
||||
return result;
|
||||
}
|
||||
if (record.kind === 'apns') {
|
||||
const timer = apnsDurationSeconds.startTimer();
|
||||
const result = await sendApnsPush(record, payload);
|
||||
timer();
|
||||
return result;
|
||||
}
|
||||
if (record.kind === 'up') {
|
||||
const timer = upDurationSeconds.startTimer();
|
||||
const result = await sendUnifiedPush(record, payload);
|
||||
@@ -406,6 +504,7 @@ async function dispatch(
|
||||
async function refreshActiveGauge(): Promise<void> {
|
||||
const counts = await subscriptionStore.sizeByKind();
|
||||
subscriptionsActive.set({ transport: 'fcm' }, counts.fcm);
|
||||
subscriptionsActive.set({ transport: 'apns' }, counts.apns);
|
||||
subscriptionsActive.set({ transport: 'web' }, counts.web);
|
||||
subscriptionsActive.set({ transport: 'up' }, counts.up);
|
||||
}
|
||||
@@ -418,6 +517,7 @@ function normalizeRoute(method: string, path: string): string {
|
||||
if (method === 'POST' && path === '/api/push/register') return '/api/push/register';
|
||||
if (method === 'POST' && path === '/api/push/register/web') return '/api/push/register/web';
|
||||
if (method === 'POST' && path === '/api/push/register/unifiedpush') return '/api/push/register/unifiedpush';
|
||||
if (method === 'POST' && path === '/api/push/register/apns') return '/api/push/register/apns';
|
||||
if (/^\/api\/push\/register\/[^/]+$/.test(path)) return '/api/push/register/:id';
|
||||
if (/^\/api\/push\/verify\/[^/]+$/.test(path)) return '/api/push/verify/:id';
|
||||
if (/^\/api\/push\/active\/[^/]+$/.test(path)) return '/api/push/active/:id';
|
||||
@@ -490,6 +590,10 @@ const server = http.createServer(async (req, res) => {
|
||||
return await handleRegisterUnifiedPush(req, res);
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/api/push/register/apns') {
|
||||
return await handleRegisterApns(req, res);
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/api/push/vapid-public-key') {
|
||||
const key = getVapidPublicKey();
|
||||
if (!key) {
|
||||
|
||||
+4
-2
@@ -81,17 +81,19 @@ class SubscriptionStore {
|
||||
return Object.keys(this.cache).length;
|
||||
}
|
||||
|
||||
async sizeByKind(): Promise<{ fcm: number; web: number; up: number }> {
|
||||
async sizeByKind(): Promise<{ fcm: number; apns: number; web: number; up: number }> {
|
||||
await this.ensureLoaded();
|
||||
let fcm = 0;
|
||||
let apns = 0;
|
||||
let web = 0;
|
||||
let up = 0;
|
||||
for (const record of Object.values(this.cache)) {
|
||||
if (record.kind === 'fcm') fcm++;
|
||||
else if (record.kind === 'apns') apns++;
|
||||
else if (record.kind === 'web') web++;
|
||||
else if (record.kind === 'up') up++;
|
||||
}
|
||||
return { fcm, web, up };
|
||||
return { fcm, apns, web, up };
|
||||
}
|
||||
|
||||
private isExpired(record: SubscriptionRecord): boolean {
|
||||
|
||||
+13
-2
@@ -1,9 +1,11 @@
|
||||
// Records exist in one of three flavours: FCM (mobile app), Web Push (PWA in
|
||||
// the browser) or UnifiedPush (mobile app without Google Play services).
|
||||
// Records exist in one of four flavours: FCM (Android app), APNs (iOS app),
|
||||
// Web Push (PWA in the browser) or UnifiedPush (Android app without Google
|
||||
// Play services).
|
||||
// Older on-disk records predate the discriminator and only have `fcmToken` -
|
||||
// the loader fills in `kind: 'fcm'` for those.
|
||||
export type SubscriptionRecord =
|
||||
| FcmSubscriptionRecord
|
||||
| ApnsSubscriptionRecord
|
||||
| WebSubscriptionRecord
|
||||
| UnifiedPushSubscriptionRecord;
|
||||
|
||||
@@ -19,6 +21,15 @@ export interface FcmSubscriptionRecord extends BaseSubscriptionRecord {
|
||||
fcmToken: string;
|
||||
}
|
||||
|
||||
// The iOS app's APNs device token (hex). `environment` is the APNs host the
|
||||
// token belongs to: TestFlight and App Store builds get production tokens,
|
||||
// development builds and simulators sandbox ones.
|
||||
export interface ApnsSubscriptionRecord extends BaseSubscriptionRecord {
|
||||
kind: 'apns';
|
||||
deviceToken: string;
|
||||
environment: 'production' | 'sandbox';
|
||||
}
|
||||
|
||||
export interface WebSubscriptionRecord extends BaseSubscriptionRecord {
|
||||
kind: 'web';
|
||||
webPush: WebPushSubscription;
|
||||
|
||||
+14
-12
@@ -1,17 +1,12 @@
|
||||
import webpush from 'web-push';
|
||||
import { getVapidPublicKey } from './webpush.js';
|
||||
import { failureReason, type SendResult } from './forward-result.js';
|
||||
import type { ForwardPayload, UnifiedPushSubscriptionRecord } from './types.js';
|
||||
|
||||
// Drop the message if the device stays unreachable for an hour - matches the
|
||||
// Web Push transport; the app re-syncs over JMAP on next launch anyway.
|
||||
const TTL_SECONDS = 60 * 60;
|
||||
|
||||
export interface UnifiedPushSendResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
unregistered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a payload to a UnifiedPush endpoint. Modern connectors (>= 3.0)
|
||||
* hand out RFC 8291 keys, so the message goes out as standard encrypted Web
|
||||
@@ -28,7 +23,7 @@ export interface UnifiedPushSendResult {
|
||||
export async function sendUnifiedPush(
|
||||
record: UnifiedPushSubscriptionRecord,
|
||||
payload: ForwardPayload,
|
||||
): Promise<UnifiedPushSendResult> {
|
||||
): Promise<SendResult> {
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
if (record.keys) {
|
||||
@@ -46,7 +41,12 @@ export async function sendUnifiedPush(
|
||||
const status = (error as { statusCode?: number }).statusCode ?? 0;
|
||||
// 404 / 410 mean the distributor dropped the registration - mirrors the
|
||||
// FCM UNREGISTERED branch so the caller deletes the record.
|
||||
return { ok: false, status, unregistered: status === 404 || status === 410 };
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
unregistered: status === 404 || status === 410,
|
||||
reason: failureReason(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +60,16 @@ export async function sendUnifiedPush(
|
||||
},
|
||||
body,
|
||||
});
|
||||
// Drain so the socket can be reused; the body itself is uninteresting.
|
||||
await res.arrayBuffer().catch(() => undefined);
|
||||
// Always drain so the socket can be reused; the body only matters as the
|
||||
// distributor's explanation of a failure.
|
||||
const text = await res.text().catch(() => '');
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
unregistered: res.status === 404 || res.status === 410,
|
||||
reason: res.ok ? undefined : failureReason(text),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, status: 0, unregistered: false };
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, unregistered: false, reason: failureReason(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ export function isValidFcmToken(value: unknown): value is string {
|
||||
);
|
||||
}
|
||||
|
||||
// APNs device tokens are 32 bytes today, sent as hex; Apple says not to rely
|
||||
// on the length, so allow some room.
|
||||
export function isValidApnsToken(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-fA-F]{64,200}$/.test(value);
|
||||
}
|
||||
|
||||
export function isValidApnsEnvironment(value: unknown): value is 'production' | 'sandbox' {
|
||||
return value === 'production' || value === 'sandbox';
|
||||
}
|
||||
|
||||
// UnifiedPush endpoints point at whatever distributor server the user chose
|
||||
// (ntfy.sh, a self-hosted ntfy, NextPush, ...) so unlike Web Push there is no
|
||||
// short allowlist of origins - but they must still be https and sanely sized.
|
||||
|
||||
+4
-9
@@ -1,5 +1,6 @@
|
||||
import webpush from 'web-push';
|
||||
import { logger } from './logger.js';
|
||||
import { failureReason, type SendResult } from './forward-result.js';
|
||||
import type { ForwardPayload, WebSubscriptionRecord } from './types.js';
|
||||
|
||||
let configured = false;
|
||||
@@ -31,18 +32,12 @@ export function getVapidPublicKey(): string | null {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export interface WebPushSendResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
unregistered: boolean;
|
||||
}
|
||||
|
||||
export async function sendWebPush(
|
||||
record: WebSubscriptionRecord,
|
||||
payload: ForwardPayload,
|
||||
): Promise<WebPushSendResult> {
|
||||
): Promise<SendResult> {
|
||||
if (!ensureConfigured()) {
|
||||
return { ok: false, status: 0, unregistered: false };
|
||||
return { ok: false, status: 0, unregistered: false, reason: 'VAPID keys not configured' };
|
||||
}
|
||||
|
||||
// Mirror the FCM payload shape: a wake-up ping plus, at most, the ids of the
|
||||
@@ -60,6 +55,6 @@ export async function sendWebPush(
|
||||
// 404 / 410 mean the push service has dropped the subscription -
|
||||
// mirrors the FCM UNREGISTERED branch so the caller deletes the record.
|
||||
const unregistered = status === 404 || status === 410;
|
||||
return { ok: false, status, unregistered };
|
||||
return { ok: false, status, unregistered, reason: failureReason(error) };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user