feat(api): signature-verified Forgejo webhook endpoint

This commit is contained in:
Dave 2026-09-16 11:48:20 +02:00
parent 198a7a8ceb
commit e8cd596702

View file

@ -0,0 +1,64 @@
import { getAdminPb } from "@/lib/pb/admin";
import { signatureHeader, verifyWebhookSignature } from "@/lib/git/signature";
import { ingestProject } from "@/lib/git/ingest";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { ProjectRecord } from "@/lib/pb/types";
/**
* POST /api/webhooks/forgejo/:projectId
*
* Unauthenticated by necessity — Forgejo holds no session with us — so the
* HMAC signature is the only control on this endpoint. Order matters:
*
* 1. read the RAW body as text
* 2. verify the signature against this project's secret
* 3. only then parse
*
* Parsing before verifying is already doing work on behalf of whoever sent it.
*
* The payload's own commit list is deliberately ignored. It is convenient and
* it is a liability: trusting it means a forged-but-correctly-signed-by-a-leaked
* -secret request could inject arbitrary history. Instead the webhook is
* treated purely as a signal that something changed, and the commits are then
* pulled from the Forgejo API, which is authoritative.
*/
export async function POST(
request: Request,
context: { params: Promise<{ projectId: string }> }
): Promise<Response> {
const { projectId } = await context.params;
// Raw text, before any parsing — the signature covers these exact bytes.
const rawBody = await request.text();
const pb = await getAdminPb();
let project: ProjectRecord;
try {
project = await pb.collection("projects").getOne<ProjectRecord>(projectId);
} catch {
// Same answer as a bad signature, so the endpoint cannot be used to
// discover which project ids exist.
return jsonError("Unauthorized", 401);
}
const provided = signatureHeader(request.headers);
if (!verifyWebhookSignature(rawBody, project.webhook_secret, provided)) {
console.warn(`[webhook] rejected delivery for project ${projectId}`);
return jsonError("Unauthorized", 401);
}
try {
const result = await ingestProject(project, { stopAfterKnownPages: 1 });
console.log(
`[webhook] project=${projectId} created=${result.created} skipped=${result.skipped}`
);
return jsonOk({ created: result.created, skipped: result.skipped });
} catch (error) {
console.error(`[webhook] ingest failed for ${projectId}`, error);
// 500 so Forgejo marks the delivery failed and it is visible in its UI.
// The hourly reconciliation will pick the commits up regardless.
return jsonError("Ingest failed", 500);
}
}