Ctrl K
ComunidadGuías…

push-sidecar/fcm.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 };