import http from 'node:http' import { execFile } from 'node:child_process' import { createHmac, timingSafeEqual } from 'node:crypto' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { config } from 'dotenv' const __dirname = dirname(fileURLToPath(import.meta.url)) // Shares backend/.env with the audit endpoint rather than keeping a second // secrets file - one chmod 600 file to manage, not two. config({ path: join(__dirname, '..', '.env') }) const PORT = Number(process.env.WEBHOOK_PORT ?? 3002) const SECRET = process.env.WEBHOOK_SECRET const BRANCH = process.env.WEBHOOK_BRANCH ?? 'refs/heads/master' if (!SECRET) { console.error('WEBHOOK_SECRET is not configured. Refusing to start.') process.exit(1) } // Only one deploy runs at a time. Two pushes seconds apart would otherwise // race two concurrent `git reset --hard` + npm install in the same checkout. let deploying = false /** * Gitea/Forgejo send `X-Gitea-Signature: `. * GitHub-compatible integrations send `X-Hub-Signature-256: sha256=`. * Accept either so this works regardless of which the instance uses. */ function verifySignature(rawBody, header) { if (!header) return false const provided = header.startsWith('sha256=') ? header.slice(7) : header const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex') const a = Buffer.from(provided) const b = Buffer.from(expected) // Length check first: timingSafeEqual throws on mismatched lengths rather // than returning false. return a.length === b.length && timingSafeEqual(a, b) } function runDeploy() { if (deploying) { console.log('Deploy already in progress, ignoring this push.') return } deploying = true console.log('Deploy started.') // No shell, no string interpolation - argv is a fixed one-element array. execFile( 'bash', ['deploy.sh'], { cwd: __dirname, timeout: 5 * 60 * 1000 }, (error, stdout, stderr) => { deploying = false if (error) { console.error('Deploy failed:', error.message) if (stderr) console.error(stderr.trim()) return } console.log(stdout.trim() || 'Deploy finished.') } ) } const server = http.createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/deploy-webhook') { res.writeHead(404).end() return } const chunks = [] let size = 0 req.on('data', (chunk) => { size += chunk.length // A webhook payload is a few KB. Anything past 1 MB is not Gitea. if (size > 1_000_000) { req.destroy() return } chunks.push(chunk) }) req.on('end', () => { const rawBody = Buffer.concat(chunks) const signatureHeader = req.headers['x-gitea-signature'] ?? req.headers['x-hub-signature-256'] const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader if (!verifySignature(rawBody, signature)) { console.warn('Webhook signature mismatch - ignoring request.') res.writeHead(401).end() return } let payload try { payload = JSON.parse(rawBody.toString('utf8')) } catch { res.writeHead(400).end('Invalid JSON.') return } if (payload.ref !== BRANCH) { res.writeHead(200).end(`Ignored (push to ${payload.ref}, watching ${BRANCH}).`) return } // Respond before deploying: Gitea has its own webhook timeout, and the // deploy (npm install + restart) can take longer than that. res.writeHead(202).end('Deploy queued.') runDeploy() }) }) server.listen(PORT, '127.0.0.1', () => { console.log(`Deploy webhook listening on 127.0.0.1:${PORT}`) })