253 lines
6.6 KiB
TypeScript
253 lines
6.6 KiB
TypeScript
import { env } from "@code/env/convex";
|
|
import {
|
|
isProcessedEvent,
|
|
MAX_WEBHOOK_PAYLOAD_BYTES,
|
|
} from "@code/primitives/git-webhook";
|
|
import { makeFunctionReference } from "convex/server";
|
|
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { httpAction } from "./_generated/server";
|
|
|
|
const recordWebhookDeliveryRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
deliveryId: string;
|
|
event: string;
|
|
externalRepositoryId?: string;
|
|
payloadHash: string;
|
|
provider: "github" | "gitea";
|
|
},
|
|
{ deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean }
|
|
>("gitProvisioning:recordWebhookDelivery");
|
|
const resolveRepositoryByExternalIdRef = makeFunctionReference<
|
|
"query",
|
|
{
|
|
externalRepositoryId: string;
|
|
provider: "github" | "gitea";
|
|
},
|
|
{ _id: Id<"gitRepositories"> } | null
|
|
>("gitProvisioning:resolveRepositoryByExternalId");
|
|
const markDeliveryProcessedRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
deliveryId: Id<"gitWebhookDeliveries">;
|
|
repositoryRef?: Id<"gitRepositories">;
|
|
},
|
|
null
|
|
>("gitProvisioning:markDeliveryProcessed");
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any -- action ctx type is complex without codegen */
|
|
const processDelivery = async (
|
|
ctx: any,
|
|
input: {
|
|
readonly deliveryId: string;
|
|
readonly event: string;
|
|
readonly externalRepositoryId?: string;
|
|
readonly payloadHash: string;
|
|
readonly provider: "github" | "gitea";
|
|
}
|
|
): Promise<{ deliveryId: string; duplicate: boolean }> => {
|
|
const result: { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } =
|
|
await ctx.runMutation(recordWebhookDeliveryRef, input);
|
|
if (result.duplicate) {
|
|
return result;
|
|
}
|
|
// Resolve the repository reference if we have an external ID.
|
|
let repositoryRef: Id<"gitRepositories"> | undefined;
|
|
if (input.externalRepositoryId) {
|
|
const repo = await ctx.runQuery(resolveRepositoryByExternalIdRef, {
|
|
externalRepositoryId: input.externalRepositoryId,
|
|
provider: input.provider,
|
|
});
|
|
if (repo) {
|
|
repositoryRef = repo._id as Id<"gitRepositories">;
|
|
}
|
|
}
|
|
await ctx.runMutation(markDeliveryProcessedRef, {
|
|
deliveryId: result.deliveryId,
|
|
...(repositoryRef ? { repositoryRef } : {}),
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const verifyHmacSignature = async (
|
|
body: string,
|
|
signature: string,
|
|
secret: string,
|
|
algorithm: "sha256" | "sha1"
|
|
): Promise<boolean> => {
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(secret),
|
|
{ hash: algorithm, name: "HMAC" },
|
|
false,
|
|
["verify"]
|
|
);
|
|
let sigHex = signature;
|
|
if (signature.startsWith("sha256=")) {
|
|
sigHex = signature.slice(7);
|
|
} else if (signature.startsWith("sha1=")) {
|
|
sigHex = signature.slice(5);
|
|
}
|
|
const sigBytes = new Uint8Array(
|
|
sigHex.match(/.{2}/gu)?.flatMap((byte) => Number.parseInt(byte, 16)) ?? []
|
|
);
|
|
return await crypto.subtle.verify(
|
|
"HMAC",
|
|
key,
|
|
sigBytes,
|
|
new TextEncoder().encode(body)
|
|
);
|
|
};
|
|
|
|
const hashPayload = async (body: string): Promise<string> => {
|
|
const digest = await crypto.subtle.digest(
|
|
"SHA-256",
|
|
new TextEncoder().encode(body)
|
|
);
|
|
return [...new Uint8Array(digest)]
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
};
|
|
|
|
export const githubWebhook = httpAction(async (ctx, request) => {
|
|
const deliveryId = request.headers.get("x-github-delivery") ?? "";
|
|
const event = request.headers.get("x-github-event") ?? "";
|
|
const signature = request.headers.get("x-hub-signature-256") ?? "";
|
|
|
|
if (!deliveryId || !event) {
|
|
return new Response("Missing GitHub delivery headers", { status: 400 });
|
|
}
|
|
|
|
const rawBody = await request.text();
|
|
|
|
const payloadBytes = new TextEncoder().encode(rawBody).length;
|
|
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
|
|
return new Response("Payload too large", { status: 413 });
|
|
}
|
|
|
|
if (!env.GITHUB_WEBHOOK_SECRET) {
|
|
return new Response("GitHub webhook secret not configured", {
|
|
status: 500,
|
|
});
|
|
}
|
|
const valid = await verifyHmacSignature(
|
|
rawBody,
|
|
signature,
|
|
env.GITHUB_WEBHOOK_SECRET,
|
|
"sha256"
|
|
);
|
|
if (!valid) {
|
|
return new Response("Invalid signature", { status: 401 });
|
|
}
|
|
|
|
if (!isProcessedEvent(event)) {
|
|
return new Response("Event ignored", { status: 200 });
|
|
}
|
|
|
|
const payload = JSON.parse(rawBody) as {
|
|
action?: string;
|
|
repository?: {
|
|
full_name: string;
|
|
html_url: string;
|
|
id: number;
|
|
name: string;
|
|
owner: { login: string };
|
|
};
|
|
};
|
|
|
|
const externalRepositoryId = payload.repository
|
|
? String(payload.repository.id)
|
|
: undefined;
|
|
|
|
const payloadHash = await hashPayload(rawBody);
|
|
|
|
// Persist delivery, resolve repository, and mark processed.
|
|
const result = await processDelivery(ctx, {
|
|
deliveryId,
|
|
event,
|
|
externalRepositoryId,
|
|
payloadHash,
|
|
provider: "github",
|
|
});
|
|
|
|
return Response.json({
|
|
deliveryId,
|
|
duplicate: result.duplicate,
|
|
event,
|
|
externalRepositoryId,
|
|
payloadHash,
|
|
processed: true,
|
|
provider: "github",
|
|
});
|
|
});
|
|
|
|
export const puterWebhook = httpAction(async (ctx, request) => {
|
|
const deliveryId = request.headers.get("x-gitea-delivery") ?? "";
|
|
const event = request.headers.get("x-gitea-event") ?? "";
|
|
const signature = request.headers.get("x-gitea-signature") ?? "";
|
|
|
|
if (!deliveryId || !event) {
|
|
return new Response("Missing Gitea delivery headers", { status: 400 });
|
|
}
|
|
|
|
const rawBody = await request.text();
|
|
|
|
const payloadBytes = new TextEncoder().encode(rawBody).length;
|
|
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
|
|
return new Response("Payload too large", { status: 413 });
|
|
}
|
|
|
|
if (!env.GITEA_WEBHOOK_SECRET) {
|
|
return new Response("Gitea webhook secret not configured", { status: 500 });
|
|
}
|
|
const valid = await verifyHmacSignature(
|
|
rawBody,
|
|
signature,
|
|
env.GITEA_WEBHOOK_SECRET,
|
|
"sha256"
|
|
);
|
|
if (!valid) {
|
|
return new Response("Invalid signature", { status: 401 });
|
|
}
|
|
|
|
if (!isProcessedEvent(event)) {
|
|
return new Response("Event ignored", { status: 200 });
|
|
}
|
|
|
|
const payload = JSON.parse(rawBody) as {
|
|
action?: string;
|
|
repository?: {
|
|
full_name: string;
|
|
html_url: string;
|
|
id: number;
|
|
name: string;
|
|
owner: { login: string };
|
|
};
|
|
};
|
|
|
|
const externalRepositoryId = payload.repository
|
|
? String(payload.repository.id)
|
|
: undefined;
|
|
|
|
const payloadHash = await hashPayload(rawBody);
|
|
|
|
const result = await processDelivery(ctx, {
|
|
deliveryId,
|
|
event,
|
|
externalRepositoryId,
|
|
payloadHash,
|
|
provider: "gitea",
|
|
});
|
|
|
|
return Response.json({
|
|
deliveryId,
|
|
duplicate: result.duplicate,
|
|
event,
|
|
externalRepositoryId,
|
|
payloadHash,
|
|
processed: true,
|
|
provider: "gitea",
|
|
});
|
|
});
|