All checks were successful
Deploy to Server / deploy (push) Successful in 18s
173 lines
5.8 KiB
JavaScript
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 };
|