// The webhook HTTP endpoint. Deliberately small: read the body, verify the // signature if configured, classify the event, and hand off to the job // queue. All decisions are logged so a silent drop is always explainable // from the logs alone. import http from 'node:http'; import crypto from 'node:crypto'; import { classifyEvent } from './events.js'; const MAX_BODY_BYTES = 5 * 1024 * 1024; function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let bytes = 0; req.on('data', (chunk) => { bytes += chunk.length; if (bytes > MAX_BODY_BYTES) { req.destroy(); reject(new Error('request body too large')); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } function verifySignature(secret, signatureHeader, rawBody) { if (!secret) return true; const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const given = signatureHeader || ''; const a = Buffer.from(expected); const b = Buffer.from(given); return a.length === b.length && crypto.timingSafeEqual(a, b); } /** * Builds the HTTP server. `onTrigger(trigger)` is called (fire-and-forget * from the server's point of view — the caller decides how to queue it) * whenever an incoming event should start a job. */ export function createServer({ config, logger, onTrigger }) { return http.createServer(async (req, res) => { const requestId = crypto.randomUUID(); const log = logger.child({ requestId }); if (req.method === 'GET' && req.url === '/healthz') { res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); return; } if (req.method !== 'POST') { res.writeHead(405).end(); return; } let rawBody; try { rawBody = await readBody(req); } catch (err) { log.warn('failed to read request body', { error: err }); res.writeHead(413).end(); return; } const eventName = req.headers['x-gitea-event']; const delivery = req.headers['x-gitea-delivery']; log.info('webhook received', { event: eventName, delivery, bytes: rawBody.length }); if (!verifySignature(config.webhookSecret, req.headers['x-gitea-signature'], rawBody)) { log.warn('rejected webhook: invalid signature', { delivery }); res.writeHead(401).end(); return; } // Acknowledge immediately; Gitea only cares that we accepted delivery. // The job itself can run far longer than any sane webhook timeout. res.writeHead(202).end('accepted'); if (!eventName) { log.debug('ignored: no X-Gitea-Event header (likely a health check)'); return; } let payload; try { payload = JSON.parse(rawBody.toString('utf8')); } catch (err) { log.warn('ignored: invalid JSON payload', { error: err }); return; } const { trigger, reason } = classifyEvent(eventName, payload, config); if (!trigger) { log.info('ignored', { reason }); return; } log.info('trigger matched', { repo: trigger.repo, issueNumber: trigger.issueNumber, isPullRequest: trigger.isPullRequest, }); onTrigger(trigger, log); }); }