From e8cd596702a2bd494612f5915b80d86669d8238b Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:48:20 +0200 Subject: [PATCH] feat(api): signature-verified Forgejo webhook endpoint --- .../api/webhooks/forgejo/[projectId]/route.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/app/api/webhooks/forgejo/[projectId]/route.ts diff --git a/src/app/api/webhooks/forgejo/[projectId]/route.ts b/src/app/api/webhooks/forgejo/[projectId]/route.ts new file mode 100644 index 0000000..daf855b --- /dev/null +++ b/src/app/api/webhooks/forgejo/[projectId]/route.ts @@ -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 { + 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(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); + } +} \ No newline at end of file