portfolio-page/backend/webhook/server.js

137 lines
4.4 KiB
JavaScript

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'
import { env } from '../services/env.js'
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(env('WEBHOOK_PORT', '3002'))
const SECRET = env('WEBHOOK_SECRET')
const BRANCH = 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: <hex hmac-sha256 of body>`.
* GitHub-compatible integrations send `X-Hub-Signature-256: sha256=<hex>`.
* 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.')
}
)
}
// Extracted so it can back two separate http.Server instances - see the
// dual-listen comment below.
function requestHandler(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()
})
}
http.createServer(requestHandler).listen(PORT, '127.0.0.1', () => {
console.log(`Deploy webhook listening on 127.0.0.1:${PORT}`)
})
// Optional second listener for when Gitea/Forgejo (or the reverse proxy in
// front of it, e.g. Caddy) runs in Docker: 127.0.0.1 inside that container is
// the container's own loopback, not this host's, so it cannot reach the
// listener above. Binds only the Docker bridge's gateway address rather than
// 0.0.0.0, so this stays unreachable from the VPS's public interface. See
// the matching comment in ../server.js for how to find the right value.
const dockerBridgeHost = env('DOCKER_BRIDGE_HOST')
if (dockerBridgeHost) {
http.createServer(requestHandler).listen(PORT, dockerBridgeHost, () => {
console.log(`Deploy webhook also listening on ${dockerBridgeHost}:${PORT} (Docker bridge)`)
})
}