121 lines
3.6 KiB
JavaScript
121 lines
3.6 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'
|
|
|
|
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: <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.')
|
|
}
|
|
)
|
|
}
|
|
|
|
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}`)
|
|
})
|