diff --git a/package-lock.json b/package-lock.json index 803ffb8..7516da8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "input-otp": "^1.2.4", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", + "node-fetch": "^3.3.2", "passport": "^0.7.0", "passport-local": "^1.0.0", "react": "^18.3.1", @@ -4152,6 +4153,15 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -5168,6 +5178,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5229,6 +5262,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5945,6 +5990,43 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", @@ -8622,6 +8704,15 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 31ded10..1bfe642 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "input-otp": "^1.2.4", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", + "node-fetch": "^3.3.2", "passport": "^0.7.0", "passport-local": "^1.0.0", "react": "^18.3.1", diff --git a/server/utils/webhook.ts b/server/utils/webhook.ts new file mode 100644 index 0000000..a2b8016 --- /dev/null +++ b/server/utils/webhook.ts @@ -0,0 +1,118 @@ +import { createHmac } from 'crypto'; +import fetch from 'node-fetch'; +import { Webhook } from '@shared/schema'; + +// Generates a signature for a webhook payload using the webhook's secret +export function generateSignature(payload: any, secret: string): string { + if (!secret) return ''; + + const hmac = createHmac('sha256', secret); + hmac.update(JSON.stringify(payload)); + return hmac.digest('hex'); +} + +// Interface for webhook delivery results +export interface WebhookDeliveryResult { + success: boolean; + statusCode?: number; + message: string; + timestamp: Date; + responseBody?: string; + retryCount?: number; +} + +// Interface for webhook delivery logs +export interface WebhookDeliveryLog { + id: string; + webhookId: string; + event: string; + payload: any; + signature: string; + result: WebhookDeliveryResult; + createdAt: Date; +} + +/** + * Delivers a webhook payload to the configured URL with retry capability + */ +export async function deliverWebhook( + webhook: Webhook, + payload: any, + maxRetries = 3, + initialRetryDelay = 2000 +): Promise { + let retryCount = 0; + let lastError: Error | null = null; + + // Generate HMAC signature if webhook has a secret + const signature = webhook.secret ? generateSignature(payload, webhook.secret) : ''; + + while (retryCount <= maxRetries) { + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'DNS-Manager-Webhook/1.0', + 'X-Webhook-ID': webhook.id, + 'X-Webhook-Event': payload.event + }; + + // Add signature header if available + if (signature) { + headers['X-Webhook-Signature'] = signature; + } + + const response = await fetch(webhook.url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + timeout: 10000 // 10 second timeout + }); + + const responseText = await response.text(); + + if (response.ok) { + return { + success: true, + statusCode: response.status, + message: 'Webhook delivered successfully', + timestamp: new Date(), + responseBody: responseText, + retryCount + }; + } else { + lastError = new Error(`HTTP error ${response.status}: ${responseText}`); + + // If we got a response but it's an error, check if it's a 5xx error that warrants retrying + if (response.status < 500 || retryCount >= maxRetries) { + break; // Don't retry client errors or if we've exhausted retries + } + } + } catch (error) { + lastError = error as Error; + } + + // Exponential backoff for retries + const delay = initialRetryDelay * Math.pow(2, retryCount); + await new Promise(resolve => setTimeout(resolve, delay)); + retryCount++; + } + + return { + success: false, + statusCode: lastError && 'statusCode' in lastError ? (lastError as any).statusCode : undefined, + message: lastError ? lastError.message : 'Unknown error delivering webhook', + timestamp: new Date(), + retryCount + }; +} + +/** + * Creates a verification helper for clients to validate webhook signatures + */ +export function createSignatureVerifier(secret: string) { + return { + verify: (signature: string, payload: any): boolean => { + return signature === generateSignature(payload, secret); + } + }; +} \ No newline at end of file