import { createHmac, timingSafeEqual } from "node:crypto"; import { eq } from "drizzle-orm"; import { db } from "@tasks/database"; import { objects } from "@tasks/database/schema"; import { transitionConvoy } from "@/server/lib/convoy/convoy-service"; import { rateLimit } from "@/server/lib/rate-limit"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const CONVOY_MARKER_RE = /echodo-convoy:\s*([0-9a-f-]{36})/i; const RATE_LIMIT = { limit: 60, windowMs: 60_000 } as const; function getClientIp(req: Request): string { const fwd = req.headers.get("x-forwarded-for"); if (fwd) { const first = fwd.split(",")[0]?.trim(); if (first) return first; } const realIp = req.headers.get("x-real-ip"); if (realIp?.trim()) return realIp.trim(); return "unknown"; } function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean { if (!signatureHeader) return false; let sig = signatureHeader.trim(); if (sig.startsWith("sha256=")) sig = sig.slice(7); const computed = createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("hex"); const a = Buffer.from(computed, "utf8"); const b = Buffer.from(sig, "utf8"); if (a.length !== b.length) { try { timingSafeEqual(a, a); } catch { // ignore } return false; } return timingSafeEqual(a, b); } function json(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } export async function POST(req: Request): Promise { const ip = getClientIp(req); const rl = rateLimit({ key: `forge-webhook:${ip}`, limit: RATE_LIMIT.limit, windowMs: RATE_LIMIT.windowMs, }); if (!rl.allowed) { return json(429, { ok: false, error: "rate_limited", retryAfterMs: rl.retryAfterMs }); } const secret = process.env.FORGE_WEBHOOK_SECRET?.trim(); if (!secret) { return json(500, { ok: false, error: "webhook_not_configured" }); } let rawBody: string; try { rawBody = await req.text(); } catch { return json(400, { ok: false, error: "invalid_body" }); } const hubSig = req.headers.get("x-hub-signature-256") ?? req.headers.get("x-hub-signature"); const forgejoSig = req.headers.get("x-forgejo-signature"); const providedSig = hubSig ?? forgejoSig; if (!providedSig) { return json(401, { ok: false, error: "missing_signature" }); } if (!verifyHmacSignature(rawBody, providedSig, secret)) { return json(401, { ok: false, error: "invalid_signature" }); } let payload: Record; try { payload = rawBody ? (JSON.parse(rawBody) as Record) : {}; } catch { return json(200, { ok: true }); } const action = typeof payload.action === "string" ? payload.action : null; const prRaw = payload.pull_request as Record | null | undefined; if (action !== "closed" || !prRaw || typeof prRaw !== "object") { return json(200, { ok: true }); } const merged = prRaw.merged === true; if (!merged) { return json(200, { ok: true }); } const bodyText = typeof prRaw.body === "string" ? prRaw.body : ""; const match = CONVOY_MARKER_RE.exec(bodyText); if (!match?.[1]) { return json(200, { ok: true }); } const convoyId = match[1].toLowerCase(); const prNumber = typeof prRaw.number === "number" ? prRaw.number : null; const [convoyRow] = await db .select({ id: objects.id, workspaceId: objects.workspaceId, status: objects.status, type: objects.type }) .from(objects) .where(eq(objects.id, convoyId)) .limit(1); if (!convoyRow || convoyRow.type !== "project") { return json(200, { ok: true }); } if (convoyRow.status !== "pr_review") { return json(200, { ok: true }); } const reason = prNumber !== null ? `pr#${prNumber} merged` : "pr merged"; try { await transitionConvoy(db, { workspaceId: convoyRow.workspaceId, convoyId: convoyRow.id, toStatus: "merged", actor: "automation", reason, }); } catch { return json(200, { ok: true }); } return json(200, { ok: true }); }