122 lines · 3.4 KB
| 1 | /** |
| 2 | * Webhook delivery service. |
| 3 | * Fires webhooks for push, MR, and comment events. |
| 4 | * Payloads are signed with HMAC-SHA256. |
| 5 | */ |
| 6 | |
| 7 | import type { DbWebhook } from '@gitfastr/shared'; |
| 8 | |
| 9 | const encoder = new TextEncoder(); |
| 10 | |
| 11 | export type WebhookEvent = 'push' | 'merge_request' | 'comment'; |
| 12 | |
| 13 | export interface WebhookPayload { |
| 14 | event: WebhookEvent; |
| 15 | repository: { |
| 16 | owner: string; |
| 17 | name: string; |
| 18 | full_name: string; |
| 19 | }; |
| 20 | sender: { |
| 21 | username: string; |
| 22 | }; |
| 23 | [key: string]: any; |
| 24 | } |
| 25 | |
| 26 | export class WebhookService { |
| 27 | constructor(private db: D1Database) {} |
| 28 | |
| 29 | /** Get all active webhooks for a repo that match the given event. */ |
| 30 | async getWebhooksForEvent(repoId: string, event: WebhookEvent): Promise<DbWebhook[]> { |
| 31 | const result = await this.db |
| 32 | .prepare('SELECT * FROM webhooks WHERE repo_id = ? AND is_active = 1') |
| 33 | .bind(repoId) |
| 34 | .all<DbWebhook>(); |
| 35 | |
| 36 | return result.results.filter((wh) => { |
| 37 | const events = wh.events.split(',').map((e) => e.trim()); |
| 38 | return events.includes(event) || events.includes('*'); |
| 39 | }); |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Fire webhooks for an event. Returns promises that can be passed to ctx.waitUntil(). |
| 44 | * Each webhook is delivered independently — failures don't block others. |
| 45 | */ |
| 46 | async fireWebhooks( |
| 47 | repoId: string, |
| 48 | event: WebhookEvent, |
| 49 | payload: WebhookPayload |
| 50 | ): Promise<Promise<void>[]> { |
| 51 | const webhooks = await this.getWebhooksForEvent(repoId, event); |
| 52 | if (webhooks.length === 0) return []; |
| 53 | |
| 54 | const body = JSON.stringify(payload); |
| 55 | |
| 56 | return webhooks.map((wh) => this.deliverWebhook(wh, event, body)); |
| 57 | } |
| 58 | |
| 59 | /** Deliver a single webhook with HMAC signing and retry. */ |
| 60 | private async deliverWebhook( |
| 61 | webhook: DbWebhook, |
| 62 | event: WebhookEvent, |
| 63 | body: string |
| 64 | ): Promise<void> { |
| 65 | const headers: Record<string, string> = { |
| 66 | 'Content-Type': 'application/json', |
| 67 | 'User-Agent': 'gitfastr-webhook/1.0', |
| 68 | 'X-Gitfastr-Event': event, |
| 69 | 'X-Gitfastr-Delivery': crypto.randomUUID(), |
| 70 | }; |
| 71 | |
| 72 | // Sign payload if webhook has a secret |
| 73 | if (webhook.secret) { |
| 74 | const signature = await signPayload(body, webhook.secret); |
| 75 | headers['X-Gitfastr-Signature-256'] = `sha256=${signature}`; |
| 76 | } |
| 77 | |
| 78 | // Attempt delivery with one retry |
| 79 | for (let attempt = 0; attempt < 2; attempt++) { |
| 80 | try { |
| 81 | const response = await fetch(webhook.url, { |
| 82 | method: 'POST', |
| 83 | headers, |
| 84 | body, |
| 85 | }); |
| 86 | |
| 87 | if (response.ok) return; |
| 88 | |
| 89 | // Log failure but don't throw on last attempt |
| 90 | if (attempt === 0) { |
| 91 | console.error( |
| 92 | `Webhook delivery failed (attempt ${attempt + 1}): ${webhook.url} returned ${response.status}` |
| 93 | ); |
| 94 | } |
| 95 | } catch (error) { |
| 96 | if (attempt === 0) { |
| 97 | console.error(`Webhook delivery error (attempt ${attempt + 1}): ${webhook.url}`, error); |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /** HMAC-SHA256 sign a payload string with a secret. Returns hex string. */ |
| 105 | async function signPayload(payload: string, secret: string): Promise<string> { |
| 106 | const key = await crypto.subtle.importKey( |
| 107 | 'raw', |
| 108 | encoder.encode(secret), |
| 109 | { name: 'HMAC', hash: 'SHA-256' }, |
| 110 | false, |
| 111 | ['sign'] |
| 112 | ); |
| 113 | |
| 114 | const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payload)); |
| 115 | const bytes = new Uint8Array(signature); |
| 116 | let hex = ''; |
| 117 | for (let i = 0; i < bytes.length; i++) { |
| 118 | hex += bytes[i].toString(16).padStart(2, '0'); |
| 119 | } |
| 120 | return hex; |
| 121 | } |
| 122 |