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.
This commit is contained in:
RhenCloud 2026-08-02 06:51:20 +08:00
parent c75cc058fc
commit befed0267a
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
3 changed files with 37 additions and 13 deletions

View file

@ -9,8 +9,8 @@ export { DiscordGateway };
const app = createServer(); const app = createServer();
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return app.fetch(request, env); return app.fetch(request, env, ctx);
}, },
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> { async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {

View file

@ -45,9 +45,21 @@ export function createServer(): Hono<{ Bindings: Env }> {
return c.json({ error: "Invalid event" }, 400); 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 { loadConfig } = await import("./config");
const config = await loadConfig(c.env); 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 }); return c.json({ ok: true });
}); });

View file

@ -1,5 +1,21 @@
import type { WebhookEvent, Route, Filter } from "./types"; import type { WebhookEvent, Route, Filter } from "./types";
const keyCache = new Map<string, CryptoKey>();
async function getHmacKey(secret: string): Promise<CryptoKey> {
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( export async function verifySignature(
payload: string, payload: string,
signature: string | undefined, signature: string | undefined,
@ -8,13 +24,7 @@ export async function verifySignature(
if (!signature) return false; if (!signature) return false;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const expected = `sha256=${Array.from(new Uint8Array(sig)) const expected = `sha256=${Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
@ -79,7 +89,7 @@ function extractBranch(event: WebhookEvent): string | undefined {
return undefined; return undefined;
} }
function matchFilter(filter: Filter, event: WebhookEvent): boolean { function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
let value: string | undefined; let value: string | undefined;
switch (filter.type) { switch (filter.type) {
@ -99,7 +109,7 @@ function matchFilter(filter: Filter, event: WebhookEvent): boolean {
value = extractBranch(event); value = extractBranch(event);
break; break;
case "keyword": { 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]; const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
return patterns.some((p) => { return patterns.some((p) => {
try { try {
@ -123,5 +133,7 @@ function matchFilter(filter: Filter, event: WebhookEvent): boolean {
export function matchRoute(route: Route, event: WebhookEvent): boolean { export function matchRoute(route: Route, event: WebhookEvent): boolean {
if (!route.enabled) return false; 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));
} }