Files
dashcaddy/dashcaddy-api/scripts/webhook-handler.js

137 lines
3.7 KiB
JavaScript

#!/usr/bin/env node
/**
* DashCaddy Release Webhook Handler
* Receives push webhooks from Gitea, verifies HMAC signature,
* and triggers build-release.sh.
*
* Usage: node webhook-handler.js
* Env vars:
* WEBHOOK_SECRET — Gitea webhook secret (required)
* WEBHOOK_PORT — Listen port (default: 9090)
* BUILD_SCRIPT — Path to build script (default: /opt/dashcaddy-release/build-release.sh)
*/
const http = require('http');
const crypto = require('crypto');
const { spawn } = require('child_process');
const fs = require('fs');
const PORT = parseInt(process.env.WEBHOOK_PORT || '9090', 10);
const SECRET = process.env.WEBHOOK_SECRET;
const BUILD_SCRIPT = process.env.BUILD_SCRIPT || '/opt/dashcaddy-release/build-release.sh';
const LOG_FILE = '/var/log/dashcaddy-release.log';
if (!SECRET) {
console.error('WEBHOOK_SECRET environment variable is required');
process.exit(1);
}
let buildRunning = false;
function log(msg) {
const line = `[webhook] ${new Date().toISOString()} ${msg}`;
console.log(line);
fs.appendFileSync(LOG_FILE, `${line }\n`);
}
function verifySignature(body, signature) {
if (!signature) return false;
const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(hmac),
);
}
function triggerBuild() {
if (buildRunning) {
log('Build already in progress, skipping');
return;
}
buildRunning = true;
log('Triggering build...');
const child = spawn('bash', [BUILD_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PATH: process.env.PATH },
});
child.stdout.on('data', (data) => {
const lines = data.toString().trim().split('\n');
lines.forEach(line => log(`[build] ${line}`));
});
child.stderr.on('data', (data) => {
const lines = data.toString().trim().split('\n');
lines.forEach(line => log(`[build:err] ${line}`));
});
child.on('close', (code) => {
buildRunning = false;
if (code === 0) {
log('Build completed successfully');
} else {
log(`Build FAILED with exit code ${code}`);
}
});
}
const server = http.createServer((req, res) => {
// Health check
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', buildRunning }));
return;
}
// Only accept POST to /webhook
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404);
res.end('Not found');
return;
}
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
// Verify Gitea HMAC signature
const sig = req.headers['x-gitea-signature'] || '';
if (!verifySignature(body, sig)) {
log('Signature verification FAILED');
res.writeHead(403);
res.end('Invalid signature');
return;
}
try {
const payload = JSON.parse(body);
const ref = payload.ref || '';
const branch = ref.replace('refs/heads/', '');
if (branch !== 'main') {
log(`Ignoring push to ${branch} (not main)`);
res.writeHead(200);
res.end('Ignored (not main branch)');
return;
}
const pusher = payload.pusher?.login || 'unknown';
const commits = payload.commits?.length || 0;
log(`Push to main by ${pusher}: ${commits} commit(s)`);
triggerBuild();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ accepted: true }));
} catch (e) {
log(`Failed to parse webhook payload: ${ e.message}`);
res.writeHead(400);
res.end('Invalid payload');
}
});
});
server.listen(PORT, '0.0.0.0', () => {
log(`Webhook handler listening on 127.0.0.1:${PORT}`);
});