From befed0267a9b7c16ba040b3c5c0b55b929029ce2 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 2 Aug 2026 06:51:20 +0800 Subject: [PATCH] fix(webhook): dedup duplicate deliveries, cache HMAC key, pass ExecutionContext Skip re-processing when the same X-GitHub-Delivery id is seen (KV, 300s TTL) to stop double pushes when both an org and an App webhook target the worker. Pass ExecutionContext through so dispatch runs via waitUntil instead of a fire-and-forget that gets frozen. Cache the imported HMAC key per secret. --- src/index.ts | 4 ++-- src/server.ts | 14 +++++++++++++- src/webhook.ts | 32 ++++++++++++++++++++++---------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 847c052..a3984c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,8 @@ export { DiscordGateway }; const app = createServer(); export default { - async fetch(request: Request, env: Env): Promise { - return app.fetch(request, env); + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return app.fetch(request, env, ctx); }, async scheduled(_event: ScheduledEvent, env: Env): Promise { diff --git a/src/server.ts b/src/server.ts index 59180ba..d4457fb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,9 +45,21 @@ export function createServer(): Hono<{ Bindings: Env }> { return c.json({ error: "Invalid event" }, 400); } + const delivery = headers["x-github-delivery"]; + if (delivery) { + const seen = await c.env.KV.get(`delivery:${delivery}`); + if (seen) { + return c.json({ ok: true, duplicate: true }); + } + await c.env.KV.put(`delivery:${delivery}`, "1", { expirationTtl: 300 }); + } + const { loadConfig } = await import("./config"); const config = await loadConfig(c.env); - dispatchEvent(config, event, c.env).catch((err) => log.error(err, "Dispatch failed")); + const dispatch = dispatchEvent(config, event, c.env).catch((err) => + log.error(err, "Dispatch failed"), + ); + c.executionCtx.waitUntil(dispatch); return c.json({ ok: true }); }); diff --git a/src/webhook.ts b/src/webhook.ts index 9a7ecec..312dd1e 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -1,5 +1,21 @@ import type { WebhookEvent, Route, Filter } from "./types"; +const keyCache = new Map(); + +async function getHmacKey(secret: string): Promise { + const cached = keyCache.get(secret); + if (cached) return cached; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + keyCache.set(secret, key); + return key; +} + export async function verifySignature( payload: string, signature: string | undefined, @@ -8,13 +24,7 @@ export async function verifySignature( if (!signature) return false; const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); + const key = await getHmacKey(secret); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); const expected = `sha256=${Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, "0")) @@ -79,7 +89,7 @@ function extractBranch(event: WebhookEvent): string | undefined { return undefined; } -function matchFilter(filter: Filter, event: WebhookEvent): boolean { +function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean { let value: string | undefined; switch (filter.type) { @@ -99,7 +109,7 @@ function matchFilter(filter: Filter, event: WebhookEvent): boolean { value = extractBranch(event); break; case "keyword": { - const body = JSON.stringify(event.payload).toLowerCase(); + const body = keywordBody ?? JSON.stringify(event.payload).toLowerCase(); const patterns = Array.isArray(filter.match) ? filter.match : [filter.match]; return patterns.some((p) => { try { @@ -123,5 +133,7 @@ function matchFilter(filter: Filter, event: WebhookEvent): boolean { export function matchRoute(route: Route, event: WebhookEvent): boolean { if (!route.enabled) return false; - return route.filters.every((f) => matchFilter(f, event)); + const hasKeyword = route.filters.some((f) => f.type === "keyword"); + const keywordBody = hasKeyword ? JSON.stringify(event.payload).toLowerCase() : undefined; + return route.filters.every((f) => matchFilter(f, event, keywordBody)); }