const crypto = require('node:crypto') const http = require('node:http') const { spawn } = require('node:child_process') const PORT = Number(process.env.WEBHOOK_PORT || 3011) const SECRET = process.env.GITHUB_WEBHOOK_SECRET || '' const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web') .split(',') .map((target) => target.trim()) .filter(Boolean) const RESTART_AFTER_MS = 24 * 60 * 60 * 1000 let deploying = false function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) res.end(JSON.stringify(body)) } function verifySignature(rawBody, signature) { if (!SECRET) return true if (!signature || !signature.startsWith('sha256=')) return false const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}` const signatureBuffer = Buffer.from(signature) const expectedBuffer = Buffer.from(expected) return ( signatureBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(signatureBuffer, expectedBuffer) ) } function run(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: __dirname, shell: process.platform === 'win32', stdio: 'inherit', }) child.on('error', reject) child.on('exit', (code) => { if (code === 0) resolve() else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)) }) }) } async function deploy() { await run('git', ['pull', '--ff-only']) await run('npm', ['install']) await run('npm', ['run', 'build']) for (const target of RESTART_TARGETS) { await run('pm2', ['reload', target, '--update-env']) } } http .createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { json(res, 200, { ok: true, deploying, restart_targets: RESTART_TARGETS }) return } if (req.method !== 'POST' || req.url !== '/github') { json(res, 404, { error: 'Not found' }) return } const chunks = [] req.on('data', (chunk) => chunks.push(chunk)) req.on('end', () => { const rawBody = Buffer.concat(chunks) if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) { json(res, 401, { error: 'Invalid signature' }) return } if (req.headers['x-github-event'] !== 'push') { json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' }) return } if (deploying) { json(res, 202, { queued: false, deploying: true }) return } deploying = true json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS }) deploy() .then(() => console.log('GitHub push deploy completed')) .catch((error) => console.error('GitHub push deploy failed:', error)) .finally(() => { deploying = false }) }) }) .listen(PORT, '0.0.0.0', () => { console.log(`tssbot webhook listening on http://localhost:${PORT}/github`) console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`) }) setTimeout(() => { console.log('24 hour webhook refresh reached; exiting for PM2 restart') process.exit(0) }, RESTART_AFTER_MS).unref()