import crypto from 'node:crypto'; import { execFile } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express from 'express'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const port = Number(process.env.PORT || 3020); const webhookPath = process.env.WEBHOOK_PATH || '/webhook/github'; const webhookSecret = process.env.WEBHOOK_SECRET || ''; const pm2AppName = process.env.PM2_APP_NAME || 'linkweb'; const pm2Command = process.env.PM2_BIN || (process.platform === 'win32' ? 'pm2.cmd' : 'pm2'); const npmCommand = process.env.NPM_BIN || (process.platform === 'win32' ? 'npm.cmd' : 'npm'); const gitCommand = process.env.GIT_BIN || 'git'; let deployInProgress = false; function verifyGitHubSignature(req) { if (!webhookSecret) { return true; } const signature = req.get('x-hub-signature-256'); if (!signature?.startsWith('sha256=')) { return false; } const digest = `sha256=${crypto .createHmac('sha256', webhookSecret) .update(req.body) .digest('hex')}`; const received = Buffer.from(signature); const expected = Buffer.from(digest); return received.length === expected.length && crypto.timingSafeEqual(received, expected); } function runStep(label, command, args) { console.log(`[deploy] ${label}`); return new Promise((resolve, reject) => { execFile( command, args, { cwd: __dirname, maxBuffer: 1024 * 1024 * 10, windowsHide: true, }, (error, stdout, stderr) => { if (stdout) { console.log(stdout); } if (stderr) { console.error(stderr); } if (error) { reject(error); return; } resolve(); }, ); }); } async function runDeploy() { if (deployInProgress) { console.log('[deploy] Webhook ignored because a deploy is already running.'); return; } deployInProgress = true; try { await runStep('Pulling latest changes', gitCommand, ['pull', '--ff-only']); await runStep('Installing dependencies', npmCommand, ['install']); await runStep('Building site', npmCommand, ['run', 'build']); await runStep(`Restarting PM2 app "${pm2AppName}"`, pm2Command, ['restart', pm2AppName]); console.log('[deploy] Complete.'); } catch (error) { console.error('[deploy] Failed:', error.message); } finally { deployInProgress = false; } } app.post(webhookPath, express.raw({ type: '*/*' }), (req, res) => { if (!verifyGitHubSignature(req)) { res.status(401).json({ ok: false, error: 'Invalid webhook signature' }); return; } res.status(202).json({ ok: true, deploying: pm2AppName }); runDeploy(); }); app.get('/healthz', (_req, res) => { res.json({ ok: true }); }); app.use(express.static(path.join(__dirname, 'dist'))); app.use((_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); app.listen(port, () => { console.log(`Toothless' Bot Home listening on port ${port}`); });