68 lines
2.2 KiB
TypeScript
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);
|
|
}
|
|
}
|