updated
This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user