From 37567b4638387642d5ae79f24e56595c7eb3dbdc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Mar 2025 17:08:19 -0700 Subject: [PATCH] feat[webhook]: added generic webhook (#130) * added logic to support generic webhooks * fixed style of generic webhook * fixed style of generic webhook --- .github/PULL_REQUEST_TEMPLATE.md | 2 - package-lock.json | 60 ++ package.json | 5 + sim/app/api/webhooks/test/route.ts | 54 ++ sim/app/api/webhooks/trigger/[path]/route.ts | 54 +- sim/app/blocks/blocks/starter.ts | 4 +- .../webhook/components/webhook-modal.tsx | 559 ++++++++++++++++-- .../components/webhook/webhook-config.tsx | 46 +- 8 files changed, 715 insertions(+), 69 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4fb5b75b2..3852985a8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,3 @@ -# Pull Request Template - ## Description Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..256819495 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "sim", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "framer-motion": "^12.5.0" + } + }, + "node_modules/framer-motion": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.5.0.tgz", + "integrity": "sha512-buPlioFbH9/W7rDzYh1C09AuZHAk2D1xTA1BlounJ2Rb9aRg84OXexP0GLd+R83v0khURdMX7b5MKnGTaSg5iA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.5.0", + "motion-utils": "^12.5.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.5.0.tgz", + "integrity": "sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.5.0" + } + }, + "node_modules/motion-utils": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.5.0.tgz", + "integrity": "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..4ff6a286e --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "framer-motion": "^12.5.0" + } +} diff --git a/sim/app/api/webhooks/test/route.ts b/sim/app/api/webhooks/test/route.ts index 14c9f9d36..3f52bbc14 100644 --- a/sim/app/api/webhooks/test/route.ts +++ b/sim/app/api/webhooks/test/route.ts @@ -170,6 +170,60 @@ export async function GET(request: NextRequest) { }) } + case 'generic': { + // Get the general webhook configuration + const token = providerConfig.token + const secretHeaderName = providerConfig.secretHeaderName + const requireAuth = providerConfig.requireAuth + const allowedIps = providerConfig.allowedIps + + // Generate sample curl command for testing + let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"` + + // Add auth headers to the curl command if required + if (requireAuth && token) { + if (secretHeaderName) { + curlCommand += ` -H "${secretHeaderName}: ${token}"` + } else { + curlCommand += ` -H "Authorization: Bearer ${token}"` + } + } + + // Add a sample payload + curlCommand += ` -d '{"event":"test_event","timestamp":"${new Date().toISOString()}"}'` + + logger.info(`[${requestId}] General webhook test successful: ${webhookId}`) + return NextResponse.json({ + success: true, + webhook: { + id: foundWebhook.id, + url: webhookUrl, + isActive: foundWebhook.isActive, + }, + message: + 'General webhook configuration is valid. Use the URL and authentication details as needed.', + details: { + requireAuth: requireAuth || false, + hasToken: !!token, + hasCustomHeader: !!secretHeaderName, + customHeaderName: secretHeaderName, + hasIpRestrictions: Array.isArray(allowedIps) && allowedIps.length > 0, + }, + test: { + curlCommand, + headers: requireAuth + ? secretHeaderName + ? { [secretHeaderName]: token } + : { Authorization: `Bearer ${token}` } + : {}, + samplePayload: { + event: 'test_event', + timestamp: new Date().toISOString(), + }, + }, + }) + } + default: { // Generic webhook test logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`) diff --git a/sim/app/api/webhooks/trigger/[path]/route.ts b/sim/app/api/webhooks/trigger/[path]/route.ts index 0969eced3..c02f75239 100644 --- a/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/sim/app/api/webhooks/trigger/[path]/route.ts @@ -13,9 +13,7 @@ import { Serializer } from '@/serializer' const logger = createLogger('WebhookTriggerAPI') -// Force dynamic rendering for webhook endpoints export const dynamic = 'force-dynamic' -// Increase the response size limit for webhook payloads export const maxDuration = 300 // 5 minutes max execution time for long-running webhooks /** @@ -306,8 +304,58 @@ async function processWebhook( // Stripe verification would go here if needed break + case 'generic': + // Enhanced general webhook authentication + if (providerConfig.requireAuth) { + let isAuthenticated = false + + // Check for token in Authorization header (Bearer token) + if (providerConfig.token) { + const providedToken = authHeader?.startsWith('Bearer ') + ? authHeader.substring(7) + : null + if (providedToken === providerConfig.token) { + isAuthenticated = true + } + + // Check for token in custom header if specified + if (!isAuthenticated && providerConfig.secretHeaderName) { + const customHeaderValue = request.headers.get(providerConfig.secretHeaderName) + if (customHeaderValue === providerConfig.token) { + isAuthenticated = true + } + } + + // Return 401 if authentication failed + if (!isAuthenticated) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + return new NextResponse('Unauthorized', { status: 401 }) + } + } + } + + // IP restriction check + if ( + providerConfig.allowedIps && + Array.isArray(providerConfig.allowedIps) && + providerConfig.allowedIps.length > 0 + ) { + const clientIp = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown' + + if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) { + logger.warn( + `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` + ) + return new NextResponse('Forbidden - IP not allowed', { status: 403 }) + } + } + break + default: - // For generic webhooks, check for a token if provided in providerConfig + // For other generic webhooks, check for a token if provided in providerConfig if (providerConfig.token) { const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null if (!providedToken || providedToken !== providerConfig.token) { diff --git a/sim/app/blocks/blocks/starter.ts b/sim/app/blocks/blocks/starter.ts index 43e9fdaaf..163bd7f27 100644 --- a/sim/app/blocks/blocks/starter.ts +++ b/sim/app/blocks/blocks/starter.ts @@ -40,8 +40,8 @@ export const StarterBlock: BlockConfig = { options: [ { label: 'Generic', id: 'generic' }, { label: 'WhatsApp', id: 'whatsapp' }, - { label: 'GitHub', id: 'github' }, - { label: 'Stripe', id: 'stripe' }, + // { label: 'GitHub', id: 'github' }, + // { label: 'Stripe', id: 'stripe' }, ], value: () => 'generic', condition: { field: 'startWorkflow', value: 'webhook' }, diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx index 54140b107..2e2086863 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Check, Copy, Loader2, Trash2, X } from 'lucide-react' -import { GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons' +import { motion } from 'framer-motion' +import { Check, Copy, Loader2, Trash2 } from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -13,6 +13,7 @@ import { } from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, @@ -34,8 +35,8 @@ interface WebhookModalProps { webhookPath: string webhookProvider: string workflowId: string - onSave?: (path: string, providerConfig: ProviderConfig) => void - onDelete?: () => void + onSave?: (path: string, providerConfig: ProviderConfig) => Promise + onDelete?: () => Promise webhookId?: string } @@ -57,14 +58,40 @@ export function WebhookModal({ const [testResult, setTestResult] = useState<{ success: boolean message?: string + test?: { + curlCommand?: string + status?: number + contentType?: string + responseText?: string + headers?: Record + samplePayload?: Record + } } | null>(null) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [showUnsavedChangesConfirm, setShowUnsavedChangesConfirm] = useState(false) const isConfigured = Boolean(webhookId) // Provider-specific configuration state const [whatsappVerificationToken, setWhatsappVerificationToken] = useState('') const [githubContentType, setGithubContentType] = useState('application/json') + // General webhook configuration state + const [generalToken, setGeneralToken] = useState('') + const [secretHeaderName, setSecretHeaderName] = useState('') + const [requireAuth, setRequireAuth] = useState(false) + const [allowedIps, setAllowedIps] = useState('') + + // Original values to track changes + const [originalValues, setOriginalValues] = useState({ + whatsappVerificationToken: '', + githubContentType: 'application/json', + generalToken: '', + secretHeaderName: '', + requireAuth: false, + allowedIps: '', + }) + // Get the current provider configuration const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic @@ -78,8 +105,29 @@ export function WebhookModal({ ) { const randomToken = Math.random().toString(36).substring(2, 10) setWhatsappVerificationToken(randomToken) + setOriginalValues((prev) => ({ ...prev, whatsappVerificationToken: randomToken })) } - }, [webhookProvider, whatsappVerificationToken, webhookId, isLoadingToken]) + + // Generate a random token for general webhook if none exists and auth is required + if ( + webhookProvider === 'generic' && + !generalToken && + !webhookId && + !isLoadingToken && + requireAuth + ) { + const randomToken = crypto.randomUUID() + setGeneralToken(randomToken) + setOriginalValues((prev) => ({ ...prev, generalToken: randomToken })) + } + }, [ + webhookProvider, + whatsappVerificationToken, + generalToken, + webhookId, + isLoadingToken, + requireAuth, + ]) // Load existing configuration values useEffect(() => { @@ -96,9 +144,34 @@ export function WebhookModal({ // Check provider type and set appropriate state if (webhookProvider === 'whatsapp' && 'verificationToken' in config) { - setWhatsappVerificationToken(config.verificationToken) + const token = config.verificationToken || '' + setWhatsappVerificationToken(token) + setOriginalValues((prev) => ({ ...prev, whatsappVerificationToken: token })) } else if (webhookProvider === 'github' && 'contentType' in config) { - setGithubContentType(config.contentType) + const contentType = config.contentType || 'application/json' + setGithubContentType(contentType) + setOriginalValues((prev) => ({ ...prev, githubContentType: contentType })) + } else if (webhookProvider === 'generic') { + // Set general webhook configuration + const token = config.token || '' + const headerName = config.secretHeaderName || '' + const auth = !!config.requireAuth + const ips = Array.isArray(config.allowedIps) + ? config.allowedIps.join(', ') + : config.allowedIps || '' + + setGeneralToken(token) + setSecretHeaderName(headerName) + setRequireAuth(auth) + setAllowedIps(ips) + + setOriginalValues((prev) => ({ + ...prev, + generalToken: token, + secretHeaderName: headerName, + requireAuth: auth, + allowedIps: ips, + })) } } } @@ -117,6 +190,32 @@ export function WebhookModal({ } }, [webhookId, webhookProvider]) + // Check for unsaved changes + useEffect(() => { + // Compare current values with original values + if (webhookProvider === 'whatsapp') { + setHasUnsavedChanges(whatsappVerificationToken !== originalValues.whatsappVerificationToken) + } else if (webhookProvider === 'github') { + setHasUnsavedChanges(githubContentType !== originalValues.githubContentType) + } else if (webhookProvider === 'generic') { + setHasUnsavedChanges( + generalToken !== originalValues.generalToken || + secretHeaderName !== originalValues.secretHeaderName || + requireAuth !== originalValues.requireAuth || + allowedIps !== originalValues.allowedIps + ) + } + }, [ + webhookProvider, + whatsappVerificationToken, + githubContentType, + generalToken, + secretHeaderName, + requireAuth, + allowedIps, + originalValues, + ]) + // Use the provided path or generate a UUID-based path const formattedPath = webhookPath && webhookPath.trim() !== '' ? webhookPath : crypto.randomUUID() @@ -142,6 +241,21 @@ export function WebhookModal({ return { contentType: githubContentType } case 'stripe': return {} + case 'generic': + // Parse the allowed IPs into an array + const parsedIps = allowedIps + ? allowedIps + .split(',') + .map((ip) => ip.trim()) + .filter((ip) => ip) + : [] + + return { + token: generalToken || undefined, + secretHeaderName: secretHeaderName || undefined, + requireAuth, + allowedIps: parsedIps.length > 0 ? parsedIps : undefined, + } default: return {} } @@ -158,7 +272,20 @@ export function WebhookModal({ ? formattedPath.substring(1) : formattedPath + await new Promise((resolve) => setTimeout(resolve, 100)) await onSave(pathToSave, providerConfig) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update original values to match current values after successful save + setOriginalValues({ + whatsappVerificationToken, + githubContentType, + generalToken, + secretHeaderName, + requireAuth, + allowedIps, + }) + setHasUnsavedChanges(false) } } catch (error) { logger.error('Error saving webhook:', { error }) @@ -181,6 +308,23 @@ export function WebhookModal({ } } + const handleClose = () => { + if (hasUnsavedChanges) { + setShowUnsavedChangesConfirm(true) + } else { + onClose() + } + } + + const handleCancelClose = () => { + setShowUnsavedChangesConfirm(false) + } + + const handleConfirmClose = () => { + setShowUnsavedChangesConfirm(false) + onClose() + } + // Test the webhook configuration const testWebhook = async () => { if (!webhookId) return @@ -199,11 +343,15 @@ export function WebhookModal({ const data = await response.json() + // Add a slight delay before showing the result for smoother animation + await new Promise((resolve) => setTimeout(resolve, 300)) + // If the test was successful, show a success message if (data.success) { setTestResult({ success: true, message: data.message || 'Webhook configuration is valid.', + test: data.test, }) } else { setTestResult({ @@ -276,7 +424,10 @@ export function WebhookModal({ {testResult && ( -

{testResult.message}

-
+ )}
@@ -411,18 +562,295 @@ export function WebhookModal({
) - default: + case 'generic': return ( -
-

Generic Webhook Setup

-

Use the URL above to send webhook events to this workflow.

+
+
+
+ setRequireAuth(checked as boolean)} + /> + +
+
-
-

- 💡 - You can test your webhook by sending a POST request to the URL. + {requireAuth && ( +

+
+ +
+ {isLoadingToken ? ( +
+ +
+ ) : ( + setGeneralToken(e.target.value)} + placeholder="Enter an auth token" + className="flex-1" + /> + )} + +
+

+ This token will be used to authenticate requests to your webhook (via Bearer + token). +

+
+ +
+ + setSecretHeaderName(e.target.value)} + placeholder="X-Secret-Key" + className="flex-1" + /> +

+ Custom HTTP header name for passing the authentication token instead of using + Bearer authentication. +

+
+
+ )} + +
+ + setAllowedIps(e.target.value)} + placeholder="192.168.1.1, 10.0.0.1" + className="flex-1" + /> +

+ Comma-separated list of IP addresses that are allowed to access this webhook.

+ + {testResult && testResult.success && ( + +

{testResult.message}

+
+ )} + +
+

Setup Instructions

+
    +
  1. Copy the Webhook URL above
  2. +
  3. Configure your service to send HTTP POST requests to this URL
  4. + {requireAuth && ( + <> +
  5. + {secretHeaderName + ? `Add the "${secretHeaderName}" header with your token to all requests` + : 'Add an "Authorization: Bearer YOUR_TOKEN" header to all requests'} +
  6. + + )} +
+ +
+

+ 💡 + The webhook will receive all HTTP POST requests and pass the data to your + workflow. +

+
+
+ + {testResult && testResult.success && testResult.test?.curlCommand && ( + + +
+                  {testResult.test.curlCommand}
+                
+
+ )} + + {testResult && !testResult.success && ( + +

{testResult.message}

+
+ )} +
+ ) + default: + return ( +
+
+ setRequireAuth(checked === true)} + className="h-3.5 w-3.5" + /> +
+ +

+ Enable authentication to secure your webhook endpoint. +

+
+
+ + {requireAuth && ( +
+
+ +
+ {isLoadingToken ? ( +
+ +
+ ) : ( + setGeneralToken(e.target.value)} + placeholder="Enter an authentication token" + className="flex-1" + /> + )} + +
+

+ This token will be used to authenticate requests to your webhook (via Bearer + token). +

+
+ +
+ + setSecretHeaderName(e.target.value)} + placeholder="X-Secret-Key" + className="flex-1" + /> +

+ Custom HTTP header name for passing the authentication token instead of using + Bearer authentication. +

+
+
+ )} + +
+ + setAllowedIps(e.target.value)} + placeholder="192.168.1.1, 10.0.0.1" + className="flex-1" + /> +

+ Comma-separated list of IP addresses that are allowed to access this webhook. +

+
+ +
+

Setup Instructions

+
    +
  1. Copy the Webhook URL above
  2. +
  3. Configure your service to send HTTP POST requests to this URL
  4. + {requireAuth && ( + <> +
  5. + {secretHeaderName + ? `Add the "${secretHeaderName}" header with your token to all requests` + : 'Add an "Authorization: Bearer YOUR_TOKEN" header to all requests'} +
  6. + + )} +
+ +
+

+ 💡 + The webhook will receive all HTTP POST requests and pass the data to your + workflow. +

+
+
+ + {testResult && ( + +

{testResult.message}

+
+ )}
) } @@ -430,7 +858,7 @@ export function WebhookModal({ return ( <> - !open && onClose()}> +
@@ -482,61 +910,59 @@ export function WebhookModal({ {renderProviderContent()}
- - {webhookId && ( -
- - {webhookProvider === 'whatsapp' && ( + +
+ {webhookId && ( +
- )} -
- )} -
- + )} +
+ )} +
+
+