feat(api): signature-verified Forgejo webhook endpoint
This commit is contained in:
parent
198a7a8ceb
commit
e8cd596702
1 changed files with 64 additions and 0 deletions
64
src/app/api/webhooks/forgejo/[projectId]/route.ts
Normal file
64
src/app/api/webhooks/forgejo/[projectId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue