109 lines
3.2 KiB
JavaScript
109 lines
3.2 KiB
JavaScript
// 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);
|
|
});
|
|
}
|