Submatter/apps/web/app/api/webhooks/forge/route.ts

144 lines
4.1 KiB
TypeScript
Raw Normal View History

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<Response> {
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<string, unknown>;
try {
payload = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {};
} catch {
return json(200, { ok: true });
}
const action = typeof payload.action === "string" ? payload.action : null;
const prRaw = payload.pull_request as Record<string, unknown> | 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 });
}