const crypto = require('node:crypto') const fs = require('node:fs') const http = require('node:http') const https = require('node:https') const os = require('node:os') const path = require('node:path') const { spawn } = require('node:child_process') function loadEnvFile() { const envPath = path.join(__dirname, '.env') if (!fs.existsSync(envPath)) return const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const separatorIndex = trimmed.indexOf('=') if (separatorIndex === -1) continue const key = trimmed.slice(0, separatorIndex).trim() let value = trimmed.slice(separatorIndex + 1).trim() if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } if (key && (!process.env[key] || process.env[key] === '')) { process.env[key] = value } } } loadEnvFile() const PORT = Number(process.env.WEBHOOK_PORT || 3011) const SECRET = process.env.GITHUB_WEBHOOK_SECRET || '' const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || '' 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 commandFor(command) { if (process.platform !== 'win32') return command if (command === 'npm') return 'npm.cmd' if (command === 'pm2') return 'pm2.cmd' return command } function run(command, args, options = {}) { return new Promise((resolve, reject) => { const label = [command, ...args].join(' ') console.log(`deploy step started: ${label}`) const child = spawn(commandFor(command), args, { cwd: __dirname, env: { ...process.env, ...options.env }, shell: process.platform === 'win32', stdio: 'inherit', }) child.on('error', reject) child.on('exit', (code) => { if (code === 0) { console.log(`deploy step completed: ${label}`) resolve() } else { reject(new Error(`${label} exited with ${code}`)) } }) }) } async function ensureBuildDependencies() { await run('npm', ['install', '--production=false', '--include=dev', '--omit=optional'], { env: { NODE_ENV: 'development', npm_config_include: 'dev', npm_config_omit: 'optional', npm_config_production: 'false', }, }) try { require.resolve('@vitejs/plugin-react') } catch { throw new Error('@vitejs/plugin-react is missing after npm install; dev dependencies were not installed') } } function postDiscordWebhook(payload) { if (!DISCORD_WEBHOOK_URL) return Promise.resolve() return new Promise((resolve) => { let url try { url = new URL(DISCORD_WEBHOOK_URL) } catch (error) { console.error('Discord restart webhook URL is invalid:', error.message) resolve() return } const body = Buffer.from(JSON.stringify(payload)) const client = url.protocol === 'http:' ? http : https const req = client.request( url, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': body.length, 'user-agent': 'tssbot-webhook', }, timeout: 5000, }, (res) => { res.resume() res.on('end', resolve) }, ) req.on('timeout', () => { req.destroy(new Error('Discord restart webhook timed out')) }) req.on('error', (error) => { console.error('Discord restart webhook failed:', error.message) resolve() }) req.end(body) }) } function notifyDiscordRestart() { const startedAt = new Date() postDiscordWebhook({ username: 'tssbot webhook', embeds: [ { title: 'Webhook listener restarted', color: 0x00f2ff, fields: [ { name: 'Host', value: os.hostname(), inline: true }, { name: 'Port', value: String(PORT), inline: true }, { name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false }, ], timestamp: startedAt.toISOString(), }, ], }) } async function deploy() { await run('git', ['pull', '--ff-only']) await ensureBuildDependencies() 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(', ')}`) notifyDiscordRestart() }) setTimeout(() => { console.log('24 hour webhook refresh reached; exiting for PM2 restart') process.exit(0) }, RESTART_AFTER_MS).unref()