push-sidecar/fcm.js
Rama main · réplica de lectura del repositorio original.
const crypto = require('crypto');
const webpush = require('web-push');
// The one data field a sealed wake carries (AR-26). The app registers its FCM or APNs
// token as {v:2, fcm, p256dh, auth}; a bare token is a 0.12.1 client and gets the plain
// payload it was built for.
const SEALED_FIELD = 'sealed';
const MAX_FCM_TOKEN = 4096;
const FCM_TOKEN = /^[A-Za-z0-9_:.-]+$/;
const B64URL = /^[A-Za-z0-9_-]+$/;
// Every sealed wake is padded to this many bytes, so its length tells a DM from a
// channel wake no more than its content does.
const PAD_TO = 512;
// Deterministic 31-bit hash of a peer_id, reproduced byte-for-byte in Dart
// (push_notification_service.dart `_iosCollapseId`): a 0.12.1 phone cancels the APNs
// banner by it. FNV-1a, masked to 31 bits so it fits a signed int. NOT for security.
function iosCollapseId(peerId) {
let h = 0x811c9dc5;
for (let i = 0; i < peerId.length; i++) {
h ^= peerId.charCodeAt(i) & 0xff;
h = Math.imul(h, 0x01000193) >>> 0;
}
return (h & 0x7fffffff).toString();
}
function decodeKey(value, length) {
if (typeof value !== 'string' || !B64URL.test(value)) return null;
const buf = Buffer.from(value, 'base64url');
return buf.length === length && buf.toString('base64url') === value ? buf : null;
}
function isCurvePoint(raw) {
if (raw[0] !== 0x04) return false;
try {
crypto.createPublicKey({
key: { kty: 'EC', crv: 'P-256', x: raw.subarray(1, 33).toString('base64url'), y: raw.subarray(33).toString('base64url') },
format: 'jwk',
});
return true;
} catch (_) {
return false;
}
}
// {fcm, keys} for a token the relay forwarded, or null when it is JSON that is not a
// valid key set (never handed to FCM as a token).
function parseNativeToken(token) {
if (typeof token !== 'string' || token.length === 0) return null;
if (!token.startsWith('{')) return { fcm: token, keys: null };
let t;
try {
t = JSON.parse(token);
} catch (_) {
return null;
}
if (!t || t.v !== 2) return null;
const { fcm } = t;
if (typeof fcm !== 'string' || fcm.length === 0 || fcm.length > MAX_FCM_TOKEN || !FCM_TOKEN.test(fcm)) return null;
const p256dh = decodeKey(t.p256dh, 65);
const auth = decodeKey(t.auth, 16);
if (!p256dh || !auth || !isCurvePoint(p256dh)) return null;
return { fcm, keys: { p256dh: t.p256dh, auth: t.auth } };
}
// The wake fields the relay supplies, as FCM data strings.
function wakeData({ sender, server, channel, mention }) {
return server
? {
type: 'channel_wake',
...(sender ? { sender } : {}),
server,
...(channel ? { channel } : {}),
mention: mention ? '1' : '0',
}
: { type: 'wake', ...(sender ? { sender } : {}) };
}
function seal(keys, inner) {
const json = JSON.stringify(inner);
const size = json.length <= PAD_TO ? PAD_TO : Math.ceil(json.length / 128) * 128;
// JSON allows trailing whitespace, so the padding needs no framing of its own.
const { cipherText } = webpush.encrypt(keys.p256dh, keys.auth, json.padEnd(size, ' '), 'aes128gcm');
return cipherText.toString('base64url');
}
// VISIBLE alert push (priority 10), not a silent background push, which iOS throttles
// to a few an hour. The alert is generic; mutable-content lets the extension fill it in
// on the device, content-available wakes the Dart handler.
function apnsBlock(collapseId) {
return {
headers: {
'apns-priority': '10',
'apns-push-type': 'alert',
...(collapseId ? { 'apns-collapse-id': collapseId } : {}),
},
payload: {
aps: {
alert: { title: 'Hollow', body: 'New message' },
sound: 'default',
'mutable-content': 1,
'content-available': 1,
},
},
};
}
// The firebase-admin message for one wake, or {error} for a token that cannot carry one.
function buildMessage({ token, platform, sender, server, channel, mention }, now = Date.now()) {
const parsed = parseNativeToken(token);
if (!parsed) return { error: 'bad_token' };
const data = wakeData({ sender, server, channel, mention });
// One banner per sender, or per channel, replaced by newer wakes.
const collapseKey = server ? `${server}:${channel || ''}` : sender || '';
if (!parsed.keys) {
const message = { token: parsed.fcm, data };
if (platform === 'android') message.android = { priority: 'high' };
else if (platform === 'ios') message.apns = apnsBlock(collapseKey ? iosCollapseId(collapseKey) : null);
return { message };
}
// The seal time lets the phone refuse a blob delivered again later. Grouping and
// replacing banners happen on the phone, so Apple gets no collapse id to link
// wakes of one conversation.
const inner = { ...data, ts: Math.floor(now / 1000) };
const message = { token: parsed.fcm, data: { [SEALED_FIELD]: seal(parsed.keys, inner) } };
if (platform === 'android') message.android = { priority: 'high' };
else if (platform === 'ios') message.apns = apnsBlock(null);
return { message };
}
module.exports = { buildMessage, parseNativeToken, wakeData, iosCollapseId, SEALED_FIELD, PAD_TO };