Files
blinds_express/push.js
Aditya Pulipaka 7da8bda5eb
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
end-to-end CI to match containerization on rest of adipu_server
2026-05-05 00:10:39 +00:00

173 lines
5.8 KiB
JavaScript

// APNs push helper — direct port of LockInBroAPI's services/push.py.
// Uses Node's built-in http2 + crypto: no third-party APNs library.
//
// Required env vars (names match LockInBroAPI for consistency):
// APNS_KEY_ID — 10-char Key ID from Apple Developer portal
// APNS_TEAM_ID — 10-char Team ID
// APNS_P8_PATH — absolute path to AuthKey_XXXXXXXXXX.p8 inside the container
// APPLE_BUNDLE_ID — iOS app bundle ID (becomes apns-topic)
// APNS_SANDBOX — "true" for development/TestFlight, anything else = production
const http2 = require('http2');
const crypto = require('crypto');
const fs = require('fs');
let cachedJwt = null;
let cachedJwtExp = 0;
let cachedKeyObj = null;
function isConfigured() {
return Boolean(
process.env.APNS_KEY_ID &&
process.env.APNS_TEAM_ID &&
process.env.APNS_P8_PATH &&
process.env.APPLE_BUNDLE_ID
);
}
function b64url(buf) {
return Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function makeJwt() {
const now = Math.floor(Date.now() / 1000);
if (cachedJwt && now < cachedJwtExp) return cachedJwt;
if (!cachedKeyObj) {
const pem = fs.readFileSync(process.env.APNS_P8_PATH);
cachedKeyObj = crypto.createPrivateKey(pem);
}
const header = b64url(JSON.stringify({ alg: 'ES256', kid: process.env.APNS_KEY_ID }));
const payload = b64url(JSON.stringify({ iss: process.env.APNS_TEAM_ID, iat: now }));
const signingInput = `${header}.${payload}`;
// Sign with ES256 — Node returns DER by default; APNs needs P1363 (raw r||s).
const sig = crypto.sign('sha256', Buffer.from(signingInput), {
key: cachedKeyObj,
dsaEncoding: 'ieee-p1363',
});
cachedJwt = `${signingInput}.${b64url(sig)}`;
cachedJwtExp = now + 3300; // 55 min — APNs rejects tokens older than 60 min
return cachedJwt;
}
// Reuse the HTTP/2 session per host to avoid setup cost on every push.
const sessions = {};
function getSession(host) {
if (sessions[host] && !sessions[host].closed && !sessions[host].destroyed) {
return sessions[host];
}
const session = http2.connect(`https://${host}`);
session.on('error', (err) => console.error(`[push] APNs session error (${host}):`, err.message));
session.on('close', () => { if (sessions[host] === session) delete sessions[host]; });
sessions[host] = session;
return session;
}
// Posts a single notification to APNs.
// `apsPayload` is the full body, e.g. { aps: { alert: { title, body }, sound: 'default' } }.
// `pushType` is 'alert' (default), 'background', or 'liveactivity'.
async function sendApns(deviceToken, apsPayload, pushType = 'alert') {
if (!isConfigured()) {
console.warn(`[push] APNs not configured — skipping push to …${deviceToken.slice(-8)}`);
return { ok: false, status: 0, body: 'not_configured' };
}
const sandbox = process.env.APNS_SANDBOX === 'true';
const host = sandbox ? 'api.sandbox.push.apple.com' : 'api.push.apple.com';
let topic = process.env.APPLE_BUNDLE_ID;
if (pushType === 'liveactivity') topic += '.push-type.liveactivity';
const session = getSession(host);
const body = Buffer.from(JSON.stringify(apsPayload));
const req = session.request({
':method': 'POST',
':path': `/3/device/${deviceToken}`,
'authorization': `bearer ${makeJwt()}`,
'apns-topic': topic,
'apns-push-type': pushType,
'apns-priority': '10',
'content-type': 'application/json',
'content-length': body.length,
});
return new Promise((resolve) => {
let status = 0;
let chunks = [];
let timeout = setTimeout(() => { req.close(http2.constants.NGHTTP2_CANCEL); }, 10000);
req.on('response', (headers) => { status = headers[':status']; });
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
clearTimeout(timeout);
const respBody = Buffer.concat(chunks).toString();
const tail = deviceToken.slice(-8);
if (status === 200) {
resolve({ ok: true, status, body: respBody });
} else {
console.error(`[push] APNs ${status} for token …${tail}: ${respBody}`);
resolve({ ok: false, status, body: respBody });
}
});
req.on('error', (err) => {
clearTimeout(timeout);
console.error(`[push] APNs request error for token …${deviceToken.slice(-8)}:`, err.message);
resolve({ ok: false, status: 0, body: err.message });
});
req.end(body);
});
}
// Public helper used by routes/agenda jobs. `data` is delivered as custom keys
// alongside `aps`, matching how the iOS app reads userInfo.
async function sendNotification(deviceToken, { title, body, data = {}, pool = null }) {
if (!deviceToken) return null;
const apsPayload = {
aps: {
alert: { title, body },
sound: 'default',
'content-available': 1,
},
...data,
};
const result = await sendApns(deviceToken, apsPayload, 'alert');
// 410 Unregistered — token is dead, scrub it.
if (result.status === 410 && pool) {
try {
await pool.query("UPDATE users SET apns_token=NULL WHERE apns_token=$1", [deviceToken]);
console.warn(`[push] APNs 410 — cleared dead token …${deviceToken.slice(-8)}`);
} catch (err) {
console.error('[push] failed to clear dead token:', err.message);
}
}
return result;
}
function init() {
if (!isConfigured()) {
console.warn('[push] APNs env not fully set (APNS_KEY_ID/APNS_TEAM_ID/APNS_P8_PATH/APPLE_BUNDLE_ID) — push disabled');
return false;
}
// Pre-warm key + JWT so the first send doesn't pay the cost.
makeJwt();
console.log(`[push] APNs ready (topic=${process.env.APPLE_BUNDLE_ID}, sandbox=${process.env.APNS_SANDBOX === 'true'})`);
return true;
}
function shutdown() {
for (const host of Object.keys(sessions)) {
try { sessions[host].close(); } catch {}
delete sessions[host];
}
}
module.exports = { init, sendNotification, isConfigured, shutdown };