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