import { env } from "cloudflare:workers"; import { webhookEventOf } from "~/lib/webhook-events"; import { findWebhookDelivery, listWebhookDeliveries } from "../webhook-log"; import { z } from "zod"; import { repoDb } from "../scoped-db"; import { forgetLocator, registerLocator } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { randomId } from "../crypto"; import { notFoundError } from "../errors"; import { isForgeWebhookUrl } from "../events/consumer"; import { assertWebhookTarget, deliverWebhook, MAX_WEBHOOK_SECRET, MIN_WEBHOOK_SECRET, replayableBody, sealWebhookSecret, } from "../webhooks/legacy"; import type { WebhookEvent } from "../events/emit"; import { assertBelowCap, CAPS, limitWrite } from "../limits"; import { noContent, readJson, requireScopedAdminWrite, requireScopedRepo } from "./http"; import type { ApiContext } from "./http"; import { WEBHOOK_EVENTS } from "~/lib/webhook-events"; const DELIVERY_LIMIT = 100; type WebhookRow = { id: string; url: string; secret: string | null; events: string; active: number; created_at: number; }; function hookJson(hook: WebhookRow) { return { id: hook.id, url: hook.url, has_secret: Boolean(hook.secret), events: JSON.parse(hook.events) as string[], active: hook.active === 1, created_at: hook.created_at, }; } /** Repo webhooks need an admin-area grant, matching the settings page's requireRepoAccess(..., "admin"). */ async function adminRepo(ctx: ApiContext, owner: string, name: string) { return (await requireScopedRepo(ctx, owner, name, "admin", "admin")).repo; } async function adminWriteRepo(ctx: ApiContext, owner: string, name: string) { return (await requireScopedAdminWrite(ctx, owner, name)).repo; } async function loadHook(repoId: string, id: string): Promise { const hook = await repoDb(repoId).first( "SELECT * FROM webhooks WHERE id = ? AND repo_id = ?", id, repoId, ); if (!hook) throw notFoundError("Webhook not found"); return hook; } export async function listWebhooksApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await adminRepo(ctx, owner, name); const hooks = await repoDb(repo.id).all( "SELECT * FROM webhooks WHERE repo_id = ? ORDER BY created_at", repo.id, ); return Response.json(hooks.map(hookJson)); } const webhookEvent = z.custom( (value) => value === "*" || WEBHOOK_EVENTS.some((event) => event === value), "Unknown webhook event", ); const hookInput = z.object({ url: z .url() .refine((value) => /^https?:\/\//.test(value), "Webhook URLs must be http(s)") .refine((value) => !isForgeWebhookUrl(value), "Webhook URLs may not point at this forge"), secret: z.string().min(MIN_WEBHOOK_SECRET).max(MAX_WEBHOOK_SECRET).nullable().default(null), events: z.array(webhookEvent).min(1).default(["*"]), active: z.boolean().default(true), }); /** Same insert and initial ping as the settings page's saveWebhook server function. */ export async function createWebhookApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, hookInput); const repo = await adminWriteRepo(ctx, owner, name); await limitWrite(ctx.viewer); await assertWebhookTarget(data.url); assertBelowCap( ( await repoDb(repo.id).first<{ count: number }>( "SELECT COUNT(*) AS count FROM webhooks WHERE repo_id = ?", repo.id, ) )?.count ?? 0, CAPS.webhooksPerRepo, "webhooks", ); const id = scopedId(repo.id); await repoDb(repo.id).run( "INSERT INTO webhooks (id, repo_id, url, secret, events, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", id, repo.id, data.url, await sealWebhookSecret(data.secret), JSON.stringify(data.events), data.active ? 1 : 0, Date.now(), ); await registerLocator("webhook", id, repo.id); await env.EVENTS.send({ kind: "webhook.deliver", webhookId: id, event: "ping", deliveryId: randomId(), payload: { zen: "Keep it logically awesome.", hook_id: id }, }); return Response.json(hookJson(await loadHook(repo.id, id)), { status: 201 }); } export async function deleteWebhookApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { const repo = await adminRepo(ctx, owner, name); await limitWrite(ctx.viewer); await loadHook(repo.id, id); await repoDb(repo.id).run("DELETE FROM webhooks WHERE id = ? AND repo_id = ?", id, repo.id); await forgetLocator("webhook", id); return noContent(); } export async function listWebhookDeliveriesApi( ctx: ApiContext, owner: string, name: string, id: string, ): Promise { const repo = await adminRepo(ctx, owner, name); await loadHook(repo.id, id); const rows = await listWebhookDeliveries(repo.id, id, DELIVERY_LIMIT); return Response.json(rows); } /** Same lookup and resend as the settings page's redeliverWebhook server function. */ export async function redeliverWebhookApi( ctx: ApiContext, owner: string, name: string, id: string, deliveryId: string, ): Promise { const repo = await adminWriteRepo(ctx, owner, name); await limitWrite(ctx.viewer); const delivery = await findWebhookDelivery(repo.id, deliveryId, id); if (!delivery) throw notFoundError("Delivery not found"); const ok = await deliverWebhook( { id: delivery.webhook_id, repo_id: repo.id, url: delivery.url, secret: delivery.secret }, webhookEventOf(delivery.event), randomId(), replayableBody(delivery.request_body), 1, ); return Response.json({ ok }); }