Files
stack/apps/api/src/gatekeeper/gatekeeper.controller.ts
Jason Woltje 6e2b9a307e
Some checks failed
ci/woodpecker/push/ci Pipeline failed
feat(gatekeeper): add PR merge automation service
2026-03-10 21:35:11 -05:00

68 lines
2.2 KiB
TypeScript

import { Body, Controller, Headers, Logger, Post, Req, type RawBodyRequest } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Request } from "express";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import { GiteaPrWebhookDto } from "./dto/gitea-pr-webhook.dto";
import { GatekeeperService } from "./gatekeeper.service";
@Controller("gatekeeper/webhook")
export class GatekeeperController {
private readonly logger = new Logger(GatekeeperController.name);
constructor(
private readonly gatekeeperService: GatekeeperService,
private readonly configService: ConfigService
) {}
@SkipCsrf()
@Post("gitea")
handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Body() body: GiteaPrWebhookDto,
@Headers("x-gitea-signature") signature: string | undefined
): Promise<{ ok: boolean }> {
const secret = this.configService.get<string>("GITEA_WEBHOOK_SECRET");
if (secret && !this.isValidSignature(this.getRequestBody(req, body), signature, secret)) {
this.logger.warn("Received invalid Gitea webhook signature");
return Promise.resolve({ ok: true });
}
if (!secret) {
this.logger.warn("GITEA_WEBHOOK_SECRET is not configured; accepting Gitea webhook");
}
void this.gatekeeperService.handlePrEvent(body).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to process Gitea PR webhook: ${message}`);
});
return Promise.resolve({ ok: true });
}
private getRequestBody(req: RawBodyRequest<Request>, body: GiteaPrWebhookDto): Buffer {
if (Buffer.isBuffer(req.rawBody)) {
return req.rawBody;
}
return Buffer.from(JSON.stringify(body));
}
private isValidSignature(body: Buffer, signature: string | undefined, secret: string): boolean {
if (!signature) {
return false;
}
const expected = createHmac("sha256", secret).update(body).digest("hex");
const actual = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (actual.length !== expectedBuffer.length) {
return false;
}
return timingSafeEqual(actual, expectedBuffer);
}
}