end-to-end CI to match containerization on rest of adipu_server
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
This commit is contained in:
172
push.js
Normal file
172
push.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user