first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.webhook",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development", "Core Nodes"],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/"
|
||||
}
|
||||
],
|
||||
"generic": [
|
||||
{
|
||||
"label": "Learn how to automatically cross-post your content with n8n",
|
||||
"icon": "✍️",
|
||||
"url": "https://n8n.io/blog/learn-how-to-automatically-cross-post-your-content-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Running n8n on ships: An interview with Maranics",
|
||||
"icon": "🛳",
|
||||
"url": "https://n8n.io/blog/running-n8n-on-ships-an-interview-with-maranics/"
|
||||
},
|
||||
{
|
||||
"label": "How to build a low-code, self-hosted URL shortener in 3 steps",
|
||||
"icon": "🔗",
|
||||
"url": "https://n8n.io/blog/how-to-build-a-low-code-self-hosted-url-shortener/"
|
||||
},
|
||||
{
|
||||
"label": "What are APIs and how to use them with no code",
|
||||
"icon": " 🪢",
|
||||
"url": "https://n8n.io/blog/what-are-apis-how-to-use-them-with-no-code/"
|
||||
},
|
||||
{
|
||||
"label": "5 tasks you can automate with the new Notion API ",
|
||||
"icon": "⚡️",
|
||||
"url": "https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/"
|
||||
},
|
||||
{
|
||||
"label": "How a digital strategist uses n8n for online marketing",
|
||||
"icon": "💻",
|
||||
"url": "https://n8n.io/blog/how-a-digital-strategist-uses-n8n-for-online-marketing/"
|
||||
},
|
||||
{
|
||||
"label": "The ultimate guide to automate your video collaboration with Whereby, Mattermost, and n8n",
|
||||
"icon": "📹",
|
||||
"url": "https://n8n.io/blog/the-ultimate-guide-to-automate-your-video-collaboration-with-whereby-mattermost-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "How to automatically give kudos to contributors with GitHub, Slack, and n8n",
|
||||
"icon": "👏",
|
||||
"url": "https://n8n.io/blog/how-to-automatically-give-kudos-to-contributors-with-github-slack-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "5 workflow automations for Mattermost that we love at n8n",
|
||||
"icon": "🤖",
|
||||
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Why this Product Manager loves workflow automation with n8n",
|
||||
"icon": "🧠",
|
||||
"url": "https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "How to automate every step of an incident response workflow",
|
||||
"url": "https://n8n.io/blog/creating-custom-incident-response-workflows-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Learn to Build Powerful API Endpoints Using Webhooks",
|
||||
"icon": "🧰",
|
||||
"url": "https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/"
|
||||
},
|
||||
{
|
||||
"label": "Learn how to use webhooks with Mattermost slash commands",
|
||||
"icon": "🦄",
|
||||
"url": "https://n8n.io/blog/learn-how-to-use-webhooks-with-mattermost-slash-commands/"
|
||||
},
|
||||
{
|
||||
"label": "How Goomer automated their operations with over 200 n8n workflows",
|
||||
"icon": "🛵",
|
||||
"url": "https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"alias": ["HTTP", "API", "Build", "WH"],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Helpers"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/* eslint-disable n8n-nodes-base/node-execute-block-wrong-error-thrown */
|
||||
import { createWriteStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import isbot from 'isbot';
|
||||
import type {
|
||||
IWebhookFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeTypeDescription,
|
||||
IWebhookResponseData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { file as tmpFile } from 'tmp-promise';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
authenticationProperty,
|
||||
credentialsProperty,
|
||||
defaultWebhookDescription,
|
||||
httpMethodsProperty,
|
||||
optionsProperty,
|
||||
responseBinaryPropertyNameProperty,
|
||||
responseCodeOption,
|
||||
responseCodeProperty,
|
||||
responseDataProperty,
|
||||
responseModeProperty,
|
||||
responseModePropertyStreaming,
|
||||
} from './description';
|
||||
import { WebhookAuthorizationError } from './error';
|
||||
import {
|
||||
checkResponseModeConfiguration,
|
||||
configuredOutputs,
|
||||
handleFormData,
|
||||
isIpAllowed,
|
||||
setupOutputConnection,
|
||||
validateWebhookAuthentication,
|
||||
} from './utils';
|
||||
|
||||
export class Webhook extends Node {
|
||||
authPropertyName = 'authentication';
|
||||
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Webhook',
|
||||
icon: { light: 'file:webhook.svg', dark: 'file:webhook.dark.svg' },
|
||||
name: 'webhook',
|
||||
group: ['trigger'],
|
||||
version: [1, 1.1, 2, 2.1],
|
||||
defaultVersion: 2.1,
|
||||
description: 'Starts the workflow when a webhook is called',
|
||||
eventTriggerDescription: 'Waiting for you to call the Test URL',
|
||||
activationMessage: 'You can now make calls to your production webhook URL.',
|
||||
defaults: {
|
||||
name: 'Webhook',
|
||||
},
|
||||
supportsCORS: true,
|
||||
triggerPanel: {
|
||||
header: '',
|
||||
executionsHelp: {
|
||||
inactive:
|
||||
"Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'listen' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Publish the workflow, then make requests to the production URL. These executions will show up in the executions list, but not in the editor.",
|
||||
active:
|
||||
'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Since the workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key="executions">executions list</a>, but not in the editor.',
|
||||
},
|
||||
activationHint:
|
||||
"Once you've finished building your workflow, run it without having to click this button by using the production webhook URL.",
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
outputs: `={{(${configuredOutputs})($parameter)}}`,
|
||||
credentials: credentialsProperty(this.authPropertyName),
|
||||
webhooks: [defaultWebhookDescription],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Allow Multiple HTTP Methods',
|
||||
name: 'multipleMethods',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
isNodeSetting: true,
|
||||
description: 'Whether to allow the webhook to listen for multiple HTTP methods',
|
||||
},
|
||||
{
|
||||
...httpMethodsProperty,
|
||||
displayOptions: {
|
||||
show: {
|
||||
multipleMethods: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'HTTP Methods',
|
||||
name: 'httpMethod',
|
||||
type: 'multiOptions',
|
||||
options: [
|
||||
{
|
||||
name: 'DELETE',
|
||||
value: 'DELETE',
|
||||
},
|
||||
{
|
||||
name: 'GET',
|
||||
value: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'HEAD',
|
||||
value: 'HEAD',
|
||||
},
|
||||
{
|
||||
name: 'PATCH',
|
||||
value: 'PATCH',
|
||||
},
|
||||
{
|
||||
name: 'POST',
|
||||
value: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'PUT',
|
||||
value: 'PUT',
|
||||
},
|
||||
],
|
||||
default: ['GET', 'POST'],
|
||||
description: 'The HTTP methods to listen to',
|
||||
displayOptions: {
|
||||
show: {
|
||||
multipleMethods: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Path',
|
||||
name: 'path',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'webhook',
|
||||
builderHint: {
|
||||
message: 'The webhook path that triggers this workflow',
|
||||
placeholderSupported: false,
|
||||
},
|
||||
description:
|
||||
"The path to listen to, dynamic values could be specified by using ':', e.g. 'your-path/:dynamic-value'. If dynamic values are set 'webhookId' would be prepended to path.",
|
||||
},
|
||||
authenticationProperty(this.authPropertyName),
|
||||
responseModeProperty,
|
||||
responseModePropertyStreaming,
|
||||
{
|
||||
displayName:
|
||||
'Insert a \'Respond to Webhook\' node to control when and how you respond. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook/" target="_blank">More details</a>',
|
||||
name: 'webhookNotice',
|
||||
type: 'notice',
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseMode: ['responseNode'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Insert a node that supports streaming (e.g. \'AI Agent\') and enable streaming to stream directly to the response while the workflow is executed. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook/" target="_blank">More details</a>',
|
||||
name: 'webhookStreamingNotice',
|
||||
type: 'notice',
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseMode: ['streaming'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
...responseCodeProperty,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1, 1.1],
|
||||
},
|
||||
hide: {
|
||||
responseMode: ['responseNode'],
|
||||
},
|
||||
},
|
||||
},
|
||||
responseDataProperty,
|
||||
responseBinaryPropertyNameProperty,
|
||||
{
|
||||
displayName:
|
||||
'If you are sending back a response, add a "Content-Type" response header with the appropriate value to avoid unexpected behavior',
|
||||
name: 'contentTypeNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseMode: ['onReceived'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
...optionsProperty,
|
||||
options: [...(optionsProperty.options as INodeProperties[]), responseCodeOption].sort(
|
||||
(a, b) => {
|
||||
const nameA = a.displayName.toUpperCase();
|
||||
const nameB = b.displayName.toUpperCase();
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
return 0;
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const { typeVersion: nodeVersion, type: nodeType } = context.getNode();
|
||||
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
|
||||
|
||||
if (nodeVersion >= 2 && nodeType === 'n8n-nodes-base.webhook') {
|
||||
checkResponseModeConfiguration(context);
|
||||
}
|
||||
|
||||
const options = context.getNodeParameter('options', {}) as {
|
||||
binaryData: boolean;
|
||||
ignoreBots: boolean;
|
||||
rawBody: boolean;
|
||||
responseData?: string;
|
||||
ipWhitelist?: string;
|
||||
};
|
||||
const req = context.getRequestObject();
|
||||
const resp = context.getResponseObject();
|
||||
const requestMethod = context.getRequestObject().method;
|
||||
|
||||
if (!isIpAllowed(options.ipWhitelist, req.ips, req.ip)) {
|
||||
resp.writeHead(403);
|
||||
resp.end('IP is not allowed to access the webhook!');
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
let validationData: IDataObject | undefined;
|
||||
try {
|
||||
if (options.ignoreBots && isbot(req.headers['user-agent']))
|
||||
throw new WebhookAuthorizationError(403);
|
||||
validationData = await this.validateAuth(context);
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookAuthorizationError) {
|
||||
resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
|
||||
resp.end(error.message);
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const prepareOutput = setupOutputConnection(context, requestMethod, {
|
||||
jwtPayload: validationData,
|
||||
});
|
||||
|
||||
if (options.binaryData) {
|
||||
return await this.handleBinaryData(context, prepareOutput);
|
||||
}
|
||||
|
||||
if (req.contentType === 'multipart/form-data') {
|
||||
return await handleFormData(context, prepareOutput);
|
||||
}
|
||||
|
||||
if (nodeVersion > 1 && !req.body && !options.rawBody) {
|
||||
try {
|
||||
return await this.handleBinaryData(context, prepareOutput);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (options.rawBody && !req.rawBody) {
|
||||
await req.readRawBody();
|
||||
}
|
||||
|
||||
const response: INodeExecutionData = {
|
||||
json: {
|
||||
headers: req.headers,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
},
|
||||
binary: options.rawBody
|
||||
? {
|
||||
data: {
|
||||
data: (req.rawBody ?? '').toString(BINARY_ENCODING),
|
||||
mimeType: req.contentType ?? 'application/json',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (responseMode === 'streaming') {
|
||||
const res = context.getResponseObject();
|
||||
|
||||
// Set up streaming response headers
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
// Flush headers immediately
|
||||
res.flushHeaders();
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
workflowData: prepareOutput(response),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
webhookResponse: options.responseData,
|
||||
workflowData: prepareOutput(response),
|
||||
};
|
||||
}
|
||||
|
||||
private async validateAuth(context: IWebhookFunctions) {
|
||||
return await validateWebhookAuthentication(context, this.authPropertyName);
|
||||
}
|
||||
|
||||
private async handleBinaryData(
|
||||
context: IWebhookFunctions,
|
||||
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
|
||||
): Promise<IWebhookResponseData> {
|
||||
const req = context.getRequestObject();
|
||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
||||
|
||||
// TODO: create empty binaryData placeholder, stream into that path, and then finalize the binaryData
|
||||
const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' });
|
||||
|
||||
try {
|
||||
await pipeline(req, createWriteStream(binaryFile.path));
|
||||
|
||||
const returnItem: INodeExecutionData = {
|
||||
json: {
|
||||
headers: req.headers,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: {},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = await stat(binaryFile.path);
|
||||
if (stats.size) {
|
||||
const binaryPropertyName = (options.binaryPropertyName ?? 'data') as string;
|
||||
const fileName = req.contentDisposition?.filename ?? uuid();
|
||||
const binaryData = await context.nodeHelpers.copyBinaryFile(
|
||||
binaryFile.path,
|
||||
fileName,
|
||||
req.contentType ?? 'application/octet-stream',
|
||||
);
|
||||
returnItem.binary = { [binaryPropertyName]: binaryData };
|
||||
}
|
||||
|
||||
return { workflowData: prepareOutput(returnItem) };
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(context.getNode(), error as Error);
|
||||
} finally {
|
||||
await binaryFile.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"headers": {
|
||||
"type": "object"
|
||||
},
|
||||
"params": {
|
||||
"type": "object"
|
||||
},
|
||||
"query": {
|
||||
"type": "object"
|
||||
},
|
||||
"body": {
|
||||
"type": "object"
|
||||
},
|
||||
"webhookUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"executionMode": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
|
||||
|
||||
import { getResponseCode, getResponseData } from './utils';
|
||||
|
||||
export const defaultWebhookDescription: IWebhookDescription = {
|
||||
name: 'default',
|
||||
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
|
||||
isFullPath: true,
|
||||
responseCode: `={{(${getResponseCode})($parameter)}}`,
|
||||
responseMode: '={{$parameter["responseMode"]}}',
|
||||
responseData: `={{(${getResponseData})($parameter)}}`,
|
||||
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
|
||||
responseContentType: '={{$parameter["options"]["responseContentType"]}}',
|
||||
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
|
||||
responseHeaders: '={{$parameter["options"]["responseHeaders"]}}',
|
||||
path: '={{$parameter["path"]}}',
|
||||
};
|
||||
|
||||
export const credentialsProperty = (
|
||||
propertyName = 'authentication',
|
||||
): INodeTypeDescription['credentials'] => [
|
||||
{
|
||||
name: 'httpBasicAuth',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
[propertyName]: ['basicAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'httpHeaderAuth',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
[propertyName]: ['headerAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'jwtAuth',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
[propertyName]: ['jwtAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const authenticationProperty = (propertyName = 'authentication'): INodeProperties => ({
|
||||
displayName: 'Authentication',
|
||||
name: propertyName,
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Basic Auth',
|
||||
value: 'basicAuth',
|
||||
},
|
||||
{
|
||||
name: 'Header Auth',
|
||||
value: 'headerAuth',
|
||||
},
|
||||
{
|
||||
name: 'JWT Auth',
|
||||
value: 'jwtAuth',
|
||||
},
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
],
|
||||
default: 'none',
|
||||
description: 'The way to authenticate',
|
||||
});
|
||||
|
||||
export const httpMethodsProperty: INodeProperties = {
|
||||
displayName: 'HTTP Method',
|
||||
name: 'httpMethod',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'DELETE',
|
||||
value: 'DELETE',
|
||||
},
|
||||
{
|
||||
name: 'GET',
|
||||
value: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'HEAD',
|
||||
value: 'HEAD',
|
||||
},
|
||||
{
|
||||
name: 'PATCH',
|
||||
value: 'PATCH',
|
||||
},
|
||||
{
|
||||
name: 'POST',
|
||||
value: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'PUT',
|
||||
value: 'PUT',
|
||||
},
|
||||
],
|
||||
default: 'GET',
|
||||
description: 'The HTTP method to listen to',
|
||||
};
|
||||
|
||||
export const responseCodeProperty: INodeProperties = {
|
||||
displayName: 'Response Code',
|
||||
name: 'responseCode',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
responseMode: ['responseNode'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 100,
|
||||
maxValue: 599,
|
||||
},
|
||||
default: 200,
|
||||
description: 'The HTTP Response code to return',
|
||||
};
|
||||
|
||||
const responseModeOptions = [
|
||||
{
|
||||
name: 'Immediately',
|
||||
value: 'onReceived',
|
||||
description: 'As soon as this node executes',
|
||||
},
|
||||
{
|
||||
name: 'When Last Node Finishes',
|
||||
value: 'lastNode',
|
||||
description: 'Returns data of the last-executed node',
|
||||
},
|
||||
{
|
||||
name: "Using 'Respond to Webhook' Node",
|
||||
value: 'responseNode',
|
||||
description: 'Response defined in that node',
|
||||
},
|
||||
];
|
||||
|
||||
export const responseModeProperty: INodeProperties = {
|
||||
displayName: 'Respond',
|
||||
name: 'responseMode',
|
||||
type: 'options',
|
||||
options: responseModeOptions,
|
||||
default: 'onReceived',
|
||||
description: 'When and how to respond to the webhook',
|
||||
builderHint: {
|
||||
message: "Use 'responseNode' to respond via a 'Respond to Webhook' node later in the workflow",
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1, 1.1, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const responseModePropertyStreaming: INodeProperties = {
|
||||
displayName: 'Respond',
|
||||
name: 'responseMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
...responseModeOptions,
|
||||
{
|
||||
name: 'Streaming',
|
||||
value: 'streaming',
|
||||
description: 'Returns data in real time from streaming enabled nodes',
|
||||
},
|
||||
],
|
||||
default: 'onReceived',
|
||||
description: 'When and how to respond to the webhook',
|
||||
builderHint: {
|
||||
message: "Use 'responseNode' to respond via a 'Respond to Webhook' node later in the workflow",
|
||||
},
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1, 1.1, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const responseDataProperty: INodeProperties = {
|
||||
displayName: 'Response Data',
|
||||
name: 'responseData',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseMode: ['lastNode'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'All Entries',
|
||||
value: 'allEntries',
|
||||
description: 'Returns all the entries of the last node. Always returns an array.',
|
||||
},
|
||||
{
|
||||
name: 'First Entry JSON',
|
||||
value: 'firstEntryJson',
|
||||
description:
|
||||
'Returns the JSON data of the first entry of the last node. Always returns a JSON object.',
|
||||
},
|
||||
{
|
||||
name: 'First Entry Binary',
|
||||
value: 'firstEntryBinary',
|
||||
description:
|
||||
'Returns the binary data of the first entry of the last node. Always returns a binary file.',
|
||||
},
|
||||
{
|
||||
name: 'No Response Body',
|
||||
value: 'noData',
|
||||
description: 'Returns without a body',
|
||||
},
|
||||
],
|
||||
default: 'firstEntryJson',
|
||||
description:
|
||||
'What data should be returned. If it should return all items as an array or only the first item as object.',
|
||||
};
|
||||
|
||||
export const responseBinaryPropertyNameProperty: INodeProperties = {
|
||||
displayName: 'Property Name',
|
||||
name: 'responseBinaryPropertyName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseData: ['firstEntryBinary'],
|
||||
},
|
||||
},
|
||||
description: 'Name of the binary property to return',
|
||||
};
|
||||
|
||||
export const optionsProperty: INodeProperties = {
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Binary File',
|
||||
name: 'binaryData',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/httpMethod': ['PATCH', 'PUT', 'POST'],
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: 'Whether the webhook will receive binary data',
|
||||
},
|
||||
{
|
||||
displayName: 'Put Output File in Field',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
binaryData: [true],
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
hint: 'The name of the output binary field to put the file in',
|
||||
description:
|
||||
'If the data gets received via "Form-Data Multipart" it will be the prefix and a number starting with 0 will be attached to it',
|
||||
},
|
||||
{
|
||||
displayName: 'Field Name for Binary Data',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'The name of the output field to put any binary file data in. Only relevant if binary data is received.',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore Bots',
|
||||
name: 'ignoreBots',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
|
||||
},
|
||||
{
|
||||
displayName: 'IP(s) Allowlist',
|
||||
name: 'ipWhitelist',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. 127.0.0.1, 192.168.1.0/24',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of allowed IP addresses or CIDR ranges. Leave empty to allow all IPs.',
|
||||
},
|
||||
{
|
||||
displayName: 'No Response Body',
|
||||
name: 'noResponseBody',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to send any body in the response',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
rawBody: [true],
|
||||
},
|
||||
show: {
|
||||
'/responseMode': ['onReceived'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Raw Body',
|
||||
name: 'rawBody',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
hide: {
|
||||
binaryData: [true],
|
||||
noResponseBody: [true],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
|
||||
description: 'Raw body (binary)',
|
||||
},
|
||||
{
|
||||
displayName: 'Raw Body',
|
||||
name: 'rawBody',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
noResponseBody: [true],
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: 'Whether to return the raw body',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Data',
|
||||
name: 'responseData',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/responseMode': ['onReceived'],
|
||||
},
|
||||
hide: {
|
||||
noResponseBody: [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'success',
|
||||
description: 'Custom response data to send',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Content-Type',
|
||||
name: 'responseContentType',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/responseData': ['firstEntryJson'],
|
||||
'/responseMode': ['lastNode'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'application/xml',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json
|
||||
description:
|
||||
'Set a custom content-type to return if another one as the "application/json" should be returned',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Headers',
|
||||
name: 'responseHeaders',
|
||||
placeholder: 'Add Response Header',
|
||||
description: 'Add headers to the webhook response',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'entries',
|
||||
displayName: 'Entries',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Name of the header',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Value of the header',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Property Name',
|
||||
name: 'responsePropertyName',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/responseData': ['firstEntryJson'],
|
||||
'/responseMode': ['lastNode'],
|
||||
},
|
||||
},
|
||||
default: 'data',
|
||||
description: 'Name of the property to return the data of instead of the whole JSON',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const responseCodeSelector: INodeProperties = {
|
||||
displayName: 'Response Code',
|
||||
name: 'responseCode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: '200', value: 200, description: 'OK - Request has succeeded' },
|
||||
{ name: '201', value: 201, description: 'Created - Request has been fulfilled' },
|
||||
{ name: '204', value: 204, description: 'No Content - Request processed, no content returned' },
|
||||
{
|
||||
name: '301',
|
||||
value: 301,
|
||||
description: 'Moved Permanently - Requested resource moved permanently',
|
||||
},
|
||||
{ name: '302', value: 302, description: 'Found - Requested resource moved temporarily' },
|
||||
{ name: '304', value: 304, description: 'Not Modified - Resource has not been modified' },
|
||||
{ name: '400', value: 400, description: 'Bad Request - Request could not be understood' },
|
||||
{ name: '401', value: 401, description: 'Unauthorized - Request requires user authentication' },
|
||||
{
|
||||
name: '403',
|
||||
value: 403,
|
||||
description: 'Forbidden - Server understood, but refuses to fulfill',
|
||||
},
|
||||
{ name: '404', value: 404, description: 'Not Found - Server has not found a match' },
|
||||
{
|
||||
name: 'Custom Code',
|
||||
value: 'customCode',
|
||||
description: 'Write any HTTP code',
|
||||
},
|
||||
],
|
||||
default: 200,
|
||||
description: 'The HTTP response code to return',
|
||||
};
|
||||
|
||||
export const responseCodeOption: INodeProperties = {
|
||||
displayName: 'Response Code',
|
||||
name: 'responseCode',
|
||||
placeholder: 'Add Response Code',
|
||||
type: 'fixedCollection',
|
||||
default: {
|
||||
values: {
|
||||
responseCode: 200,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
responseCodeSelector,
|
||||
{
|
||||
displayName: 'Code',
|
||||
name: 'customCode',
|
||||
type: 'number',
|
||||
default: 200,
|
||||
placeholder: 'e.g. 400',
|
||||
typeOptions: {
|
||||
minValue: 100,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseCode: ['customCode'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2 } }],
|
||||
},
|
||||
hide: {
|
||||
'/responseMode': ['responseNode'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
|
||||
export class WebhookAuthorizationError extends ApplicationError {
|
||||
constructor(
|
||||
readonly responseCode: number,
|
||||
message?: string,
|
||||
) {
|
||||
if (message === undefined) {
|
||||
message = 'Authorization problem!';
|
||||
if (responseCode === 401) {
|
||||
message = 'Authorization is required!';
|
||||
} else if (responseCode === 403) {
|
||||
message = 'Authorization data is wrong!';
|
||||
}
|
||||
}
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "test",
|
||||
"options": {
|
||||
"binaryData": false
|
||||
}
|
||||
},
|
||||
"id": "ec188f16-b2c5-44e3-bd83-259a94f15c94",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"webhookId": "a59a3be7-6d43-47e7-951d-86b8172c2006"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [[]]
|
||||
}
|
||||
},
|
||||
"trigger": {
|
||||
"mode": "webhook",
|
||||
"input": {
|
||||
"json": {
|
||||
"headers": {
|
||||
"host": "localhost:5678",
|
||||
"user-agent": "curl/8.2.0",
|
||||
"accept": "*/*",
|
||||
"content-length": "137",
|
||||
"content-type": "multipart/form-data; boundary=--boundary"
|
||||
},
|
||||
"params": { "path": "test" },
|
||||
"query": {},
|
||||
"body": { "a": ["b"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"Webhook": [
|
||||
{
|
||||
"json": {
|
||||
"headers": {
|
||||
"host": "localhost:5678",
|
||||
"user-agent": "curl/8.2.0",
|
||||
"accept": "*/*",
|
||||
"content-length": "137",
|
||||
"content-type": "multipart/form-data; boundary=--boundary"
|
||||
},
|
||||
"params": { "path": "test" },
|
||||
"query": {},
|
||||
"body": {
|
||||
"a": ["b"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import type { Request, Response } from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { Webhook } from '../Webhook.node';
|
||||
|
||||
jest.mock('fs/promises');
|
||||
const mockFs = jest.mocked(fs);
|
||||
|
||||
describe('Test Webhook Node', () => {
|
||||
new NodeTestHarness().setupTests();
|
||||
|
||||
describe('handleFormData', () => {
|
||||
const node = new Webhook();
|
||||
const context = mock<IWebhookFunctions>({
|
||||
nodeHelpers: mock(),
|
||||
});
|
||||
context.getNodeParameter.calledWith('options').mockReturnValue({});
|
||||
context.getNode.calledWith().mockReturnValue({
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1.1,
|
||||
} as any);
|
||||
const req = mock<Request>();
|
||||
req.contentType = 'multipart/form-data';
|
||||
context.getRequestObject.mockReturnValue(req);
|
||||
|
||||
it('should handle when no files are present', async () => {
|
||||
req.body = {
|
||||
files: {},
|
||||
};
|
||||
const returnData = await node.webhook(context);
|
||||
expect(returnData.workflowData?.[0][0].binary).toBeUndefined();
|
||||
expect(context.nodeHelpers.copyBinaryFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle when files are present', async () => {
|
||||
req.body = {
|
||||
files: { file1: { filepath: '/tmp/test.txt' } },
|
||||
};
|
||||
const returnData = await node.webhook(context);
|
||||
expect(returnData.workflowData?.[0][0].binary).not.toBeUndefined();
|
||||
expect(context.nodeHelpers.copyBinaryFile).toHaveBeenCalled();
|
||||
expect(mockFs.rm).toHaveBeenCalledWith('/tmp/test.txt', { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming response mode', () => {
|
||||
const node = new Webhook();
|
||||
const context = mock<IWebhookFunctions>({
|
||||
nodeHelpers: mock(),
|
||||
});
|
||||
const req = mock<Request>();
|
||||
const res = mock<Response>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
context.getRequestObject.mockReturnValue(req);
|
||||
context.getResponseObject.mockReturnValue(res);
|
||||
context.getChildNodes.mockReturnValue([]);
|
||||
context.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
name: 'Webhook',
|
||||
} as any);
|
||||
context.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'responseMode') return 'streaming';
|
||||
return undefined;
|
||||
});
|
||||
req.headers = {};
|
||||
req.params = {};
|
||||
req.query = {};
|
||||
req.body = { message: 'test' };
|
||||
Object.defineProperty(req, 'ips', { value: [], configurable: true });
|
||||
Object.defineProperty(req, 'ip', { value: '127.0.0.1', configurable: true });
|
||||
res.writeHead.mockImplementation(() => res);
|
||||
res.flushHeaders.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('should enable streaming when responseMode is "streaming"', async () => {
|
||||
const result = await node.webhook(context);
|
||||
|
||||
// Verify streaming headers are set
|
||||
expect(res.writeHead).toHaveBeenCalledWith(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
expect(res.flushHeaders).toHaveBeenCalled();
|
||||
|
||||
// Verify response structure for streaming
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
workflowData: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not enable streaming when responseMode is not "streaming"', async () => {
|
||||
context.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'responseMode') return 'onReceived';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
// Verify streaming headers are NOT set
|
||||
expect(res.writeHead).not.toHaveBeenCalled();
|
||||
expect(res.flushHeaders).not.toHaveBeenCalled();
|
||||
|
||||
// Verify normal response structure
|
||||
expect(result).toEqual({
|
||||
webhookResponse: undefined,
|
||||
workflowData: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multipart form data with streaming enabled', async () => {
|
||||
req.contentType = 'multipart/form-data';
|
||||
req.body = {
|
||||
data: { message: 'Hello' },
|
||||
files: {},
|
||||
};
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
// For multipart form data, streaming is handled in handleFormData method
|
||||
// The current implementation returns normal workflowData for form data
|
||||
expect(result).toEqual({
|
||||
workflowData: expect.any(Array),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,822 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
ApplicationError,
|
||||
type IWebhookFunctions,
|
||||
type INodeExecutionData,
|
||||
type IDataObject,
|
||||
type MultiPartFormData,
|
||||
type INode,
|
||||
type ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { WebhookParameters } from '../utils';
|
||||
import {
|
||||
checkResponseModeConfiguration,
|
||||
configuredOutputs,
|
||||
generateBasicAuthToken,
|
||||
generateFormPostBasicAuthToken,
|
||||
getResponseCode,
|
||||
getResponseData,
|
||||
handleFormData,
|
||||
isIpAllowed,
|
||||
setupOutputConnection,
|
||||
validateWebhookAuthentication,
|
||||
} from '../utils';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Webhook Utils', () => {
|
||||
describe('getResponseCode', () => {
|
||||
it('should return the response code if it exists', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
responseCode: 404,
|
||||
httpMethod: '',
|
||||
responseMode: '',
|
||||
responseData: '',
|
||||
};
|
||||
const responseCode = getResponseCode(parameters);
|
||||
expect(responseCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should return the custom response code if it exists', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
options: {
|
||||
responseCode: {
|
||||
values: {
|
||||
responseCode: 200,
|
||||
customCode: 201,
|
||||
},
|
||||
},
|
||||
},
|
||||
httpMethod: '',
|
||||
responseMode: '',
|
||||
responseData: '',
|
||||
};
|
||||
const responseCode = getResponseCode(parameters);
|
||||
expect(responseCode).toBe(201);
|
||||
});
|
||||
|
||||
it('should return the default response code if no response code is provided', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
httpMethod: '',
|
||||
responseMode: '',
|
||||
responseData: '',
|
||||
};
|
||||
const responseCode = getResponseCode(parameters);
|
||||
expect(responseCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseData', () => {
|
||||
it('should return the response data if it exists', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
responseData: 'Hello World',
|
||||
httpMethod: '',
|
||||
responseMode: '',
|
||||
};
|
||||
const responseData = getResponseData(parameters);
|
||||
expect(responseData).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should return the options response data if response mode is "onReceived"', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
responseMode: 'onReceived',
|
||||
options: {
|
||||
responseData: 'Hello World',
|
||||
},
|
||||
httpMethod: '',
|
||||
responseData: '',
|
||||
};
|
||||
const responseData = getResponseData(parameters);
|
||||
expect(responseData).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should return "noData" if options noResponseBody is true', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
responseMode: 'onReceived',
|
||||
options: {
|
||||
noResponseBody: true,
|
||||
},
|
||||
httpMethod: '',
|
||||
responseData: '',
|
||||
};
|
||||
const responseData = getResponseData(parameters);
|
||||
expect(responseData).toBe('noData');
|
||||
});
|
||||
|
||||
it('should return undefined if no response data is provided', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
responseMode: 'onReceived',
|
||||
httpMethod: '',
|
||||
responseData: '',
|
||||
};
|
||||
const responseData = getResponseData(parameters);
|
||||
expect(responseData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuredOutputs', () => {
|
||||
it('should return an array with a single output if httpMethod is not an array', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
httpMethod: 'GET',
|
||||
responseMode: '',
|
||||
responseData: '',
|
||||
};
|
||||
const outputs = configuredOutputs(parameters);
|
||||
expect(outputs).toEqual([
|
||||
{
|
||||
type: 'main',
|
||||
displayName: 'GET',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an array of outputs if httpMethod is an array', () => {
|
||||
const parameters: WebhookParameters = {
|
||||
httpMethod: ['GET', 'POST'],
|
||||
responseMode: '',
|
||||
responseData: '',
|
||||
};
|
||||
const outputs = configuredOutputs(parameters);
|
||||
expect(outputs).toEqual([
|
||||
{
|
||||
type: 'main',
|
||||
displayName: 'GET',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
displayName: 'POST',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupOutputConnection', () => {
|
||||
it('should return a function that sets the webhookUrl and executionMode in the output data', () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('GET'),
|
||||
getNodeWebhookUrl: jest.fn().mockReturnValue('https://example.com/webhook/'),
|
||||
getMode: jest.fn().mockReturnValue('manual'),
|
||||
};
|
||||
const method = 'GET';
|
||||
const additionalData = {
|
||||
jwtPayload: {
|
||||
userId: '123',
|
||||
},
|
||||
};
|
||||
const outputData = {
|
||||
json: {},
|
||||
};
|
||||
const setupOutput = setupOutputConnection(ctx as IWebhookFunctions, method, additionalData);
|
||||
const result = setupOutput(outputData);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
webhookUrl: 'https://example.com/webhook-test/',
|
||||
executionMode: 'test',
|
||||
jwtPayload: { userId: '123' },
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return a function that sets the webhookUrl and executionMode in the output data for multiple methods', () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue(['GET', 'POST']),
|
||||
getNodeWebhookUrl: jest.fn().mockReturnValue('https://example.com/webhook/'),
|
||||
getMode: jest.fn().mockReturnValue('manual'),
|
||||
};
|
||||
const method = 'POST';
|
||||
const additionalData = {
|
||||
jwtPayload: {
|
||||
userId: '123',
|
||||
},
|
||||
};
|
||||
const outputData = {
|
||||
json: {},
|
||||
};
|
||||
const setupOutput = setupOutputConnection(ctx as IWebhookFunctions, method, additionalData);
|
||||
const result = setupOutput(outputData);
|
||||
expect(result).toEqual([
|
||||
[],
|
||||
[
|
||||
{
|
||||
json: {
|
||||
webhookUrl: 'https://example.com/webhook-test/',
|
||||
executionMode: 'test',
|
||||
jwtPayload: { userId: '123' },
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIpAllowed', () => {
|
||||
it('should return true if allowlist is undefined', () => {
|
||||
expect(isIpAllowed(undefined, ['192.168.1.1'], '192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if allowlist is an empty string', () => {
|
||||
expect(isIpAllowed('', ['192.168.1.1'], '192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if ip is in the allowlist', () => {
|
||||
expect(isIpAllowed('192.168.1.1', ['192.168.1.2'], '192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if any ip in ips is in the allowlist', () => {
|
||||
expect(isIpAllowed('192.168.1.1', ['192.168.1.1', '192.168.1.2'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if ip and ips are not in the allowlist', () => {
|
||||
expect(isIpAllowed('192.168.1.3', ['192.168.1.1', '192.168.1.2'], '192.168.1.4')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if any ip in ips matches any address in the allowlist array', () => {
|
||||
expect(isIpAllowed(['192.168.1.1', '192.168.1.2'], ['192.168.1.2', '192.168.1.3'])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true if ip matches any address in the allowlist array', () => {
|
||||
expect(isIpAllowed(['192.168.1.1', '192.168.1.2'], ['192.168.1.3'], '192.168.1.2')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if ip and ips do not match any address in the allowlist array', () => {
|
||||
expect(
|
||||
isIpAllowed(['192.168.1.4', '192.168.1.5'], ['192.168.1.1', '192.168.1.2'], '192.168.1.3'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('CAT-1846: should use CIDR matching to determine if ip is in the allowlist', () => {
|
||||
expect(isIpAllowed('192.168.1.3', [], '192.168.1.30')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle comma-separated allowlist string', () => {
|
||||
expect(isIpAllowed('192.168.1.1, 192.168.1.2', ['192.168.1.3'], '192.168.1.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should trim whitespace in comma-separated allowlist string', () => {
|
||||
expect(isIpAllowed(' 192.168.1.1 , 192.168.1.2 ', ['192.168.1.3'], '192.168.1.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support IPv4 CIDR notation', () => {
|
||||
expect(isIpAllowed('192.168.1.0/24', [], '192.168.1.50')).toBe(true);
|
||||
expect(isIpAllowed('192.168.1.0/24', [], '192.168.1.255')).toBe(true);
|
||||
expect(isIpAllowed('192.168.1.0/24', [], '192.168.2.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support IPv6 CIDR notation', () => {
|
||||
expect(isIpAllowed('2001:db8::/32', [], '2001:db8::1')).toBe(true);
|
||||
expect(isIpAllowed('2001:db8::/32', [], '2001:db9::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support mixed single IPs and CIDR ranges', () => {
|
||||
expect(isIpAllowed('127.0.0.1, 192.168.0.0/16', [], '192.168.100.50')).toBe(true);
|
||||
expect(isIpAllowed('127.0.0.1, 192.168.0.0/16', [], '10.0.0.1')).toBe(false);
|
||||
expect(isIpAllowed('127.0.0.1, 192.168.0.0/16', [], '127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid CIDR notation gracefully', () => {
|
||||
expect(isIpAllowed('192.168.1.0/abc', [], '192.168.1.1')).toBe(false);
|
||||
expect(isIpAllowed('192.168.1.0/99', [], '192.168.1.1')).toBe(false);
|
||||
expect(isIpAllowed('invalid/24', [], '192.168.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle /32 and /128 CIDR (single IP)', () => {
|
||||
expect(isIpAllowed('192.168.1.1/32', [], '192.168.1.1')).toBe(true);
|
||||
expect(isIpAllowed('192.168.1.1/32', [], '192.168.1.2')).toBe(false);
|
||||
expect(isIpAllowed('::1/128', [], '::1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkResponseModeConfiguration', () => {
|
||||
it('should throw an error if response mode is "responseNode" but no Respond to Webhook node is found', () => {
|
||||
const context: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('responseNode'),
|
||||
getChildNodes: jest.fn().mockReturnValue([]),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Webhook' }),
|
||||
};
|
||||
expect(() => {
|
||||
checkResponseModeConfiguration(context as IWebhookFunctions);
|
||||
}).toThrowError('No Respond to Webhook node found in the workflow');
|
||||
});
|
||||
|
||||
it('should throw an error if response mode is not "responseNode" but a Respond to Webhook node is found', () => {
|
||||
const context: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('onReceived'),
|
||||
getChildNodes: jest.fn().mockReturnValue([{ type: 'n8n-nodes-base.respondToWebhook' }]),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Webhook' }),
|
||||
};
|
||||
expect(() => {
|
||||
checkResponseModeConfiguration(context as IWebhookFunctions);
|
||||
}).toThrowError('Unused Respond to Webhook node found in the workflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWebhookAuthentication', () => {
|
||||
it('should return early if authentication is "none"', async () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('none'),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
const result = await validateWebhookAuthentication(
|
||||
ctx as IWebhookFunctions,
|
||||
authPropertyName,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error if basicAuth is enabled but no authentication data is defined on the node', async () => {
|
||||
const headers = {
|
||||
authorization: 'Basic some-token',
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
|
||||
getCredentials: jest.fn().mockRejectedValue(new Error()),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('No authentication data defined on node!');
|
||||
});
|
||||
|
||||
it('should throw an error if basicAuth is enabled but the provided authentication data is wrong', async () => {
|
||||
const headers = {
|
||||
authorization: 'Basic some-token',
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
user: 'admin',
|
||||
password: 'password',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('Authorization is required!');
|
||||
});
|
||||
|
||||
it('should successfully pass if basicAuth is enabled and provided basic auth data is correct', async () => {
|
||||
const headers = {
|
||||
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
user: 'admin',
|
||||
password: 'password',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName);
|
||||
});
|
||||
|
||||
it('should successfully pass if basicAuth is enabled and provided auth token data is correct', async () => {
|
||||
const node = {
|
||||
id: 'node-789',
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode;
|
||||
const credentials = {
|
||||
user: (Math.random() * 10000).toString(),
|
||||
password: (Math.random() * 10000).toString(),
|
||||
};
|
||||
const headers = {
|
||||
'x-auth-token': generateBasicAuthToken(node, credentials),
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNode: jest.fn().mockReturnValue(node),
|
||||
getCredentials: jest.fn().mockResolvedValue(credentials),
|
||||
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName);
|
||||
});
|
||||
|
||||
it('should throw an error if headerAuth is enabled but no authentication data is defined on the node', async () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('headerAuth'),
|
||||
getCredentials: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('No authentication data defined on node!')),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers: {},
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('No authentication data defined on node!');
|
||||
});
|
||||
|
||||
it('should throw an error if headerAuth is enabled but the provided authentication data is wrong', async () => {
|
||||
const headers = {
|
||||
authorization: 'Bearer invalid-token',
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('headerAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
name: 'Authorization',
|
||||
value: 'Bearer token',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('Authorization data is wrong!');
|
||||
});
|
||||
|
||||
it('should throw an error if jwtAuth is enabled but no authentication data is defined on the node', async () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('jwtAuth'),
|
||||
getCredentials: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('No authentication data defined on node!')),
|
||||
getRequestObject: jest.fn().mockReturnValue({}),
|
||||
getHeaderData: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('No authentication data defined on node!');
|
||||
});
|
||||
|
||||
it('should throw an error if jwtAuth is enabled but no token is provided', async () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('jwtAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
keyType: 'passphrase',
|
||||
publicKey: '',
|
||||
secret: 'secret',
|
||||
algorithm: 'HS256',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers: {},
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('No token provided');
|
||||
});
|
||||
|
||||
it('should throw an error if jwtAuth is enabled but the provided token is invalid', async () => {
|
||||
const headers = {
|
||||
authorization: 'Bearer invalid-token',
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('jwtAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
keyType: 'passphrase',
|
||||
publicKey: '',
|
||||
secret: 'secret',
|
||||
algorithm: 'HS256',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
(jwt.verify as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new ApplicationError('jwt malformed');
|
||||
});
|
||||
const authPropertyName = 'authentication';
|
||||
await expect(
|
||||
validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName),
|
||||
).rejects.toThrowError('jwt malformed');
|
||||
});
|
||||
|
||||
it('should return the decoded JWT payload if jwtAuth is enabled and the token is valid', async () => {
|
||||
const decodedPayload = {
|
||||
sub: '1234567890',
|
||||
name: 'John Doe',
|
||||
iat: 1516239022,
|
||||
};
|
||||
(jwt.verify as jest.Mock).mockReturnValue(decodedPayload);
|
||||
const headers = {
|
||||
authorization:
|
||||
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('jwtAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
keyType: 'passphrase',
|
||||
publicKey: '',
|
||||
secret: 'secret',
|
||||
algorithm: 'HS256',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
|
||||
const result = await validateWebhookAuthentication(
|
||||
ctx as IWebhookFunctions,
|
||||
authPropertyName,
|
||||
);
|
||||
expect(result).toEqual(decodedPayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleFormData', () => {
|
||||
const mockCopyBinaryFile = jest.fn().mockResolvedValue({
|
||||
data: 'binary-data',
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
|
||||
const createMockContext = (options: IDataObject = {}): IWebhookFunctions =>
|
||||
({
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
contentType: 'multipart/form-data',
|
||||
headers: { 'content-type': 'multipart/form-data' },
|
||||
params: {},
|
||||
query: {},
|
||||
body: {
|
||||
data: { field1: 'value1' },
|
||||
files: {},
|
||||
},
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockReturnValue(options),
|
||||
nodeHelpers: {
|
||||
copyBinaryFile: mockCopyBinaryFile,
|
||||
},
|
||||
}) as any;
|
||||
|
||||
const mockPrepareOutput = jest.fn().mockImplementation((data: INodeExecutionData) => [[data]]);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should use default binary property name for empty filename', async () => {
|
||||
const context = createMockContext();
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
req.body.files = {
|
||||
'': {
|
||||
filepath: '/tmp/file1',
|
||||
originalFilename: '',
|
||||
newFilename: 'temp1',
|
||||
mimetype: 'text/plain',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleFormData(context, mockPrepareOutput);
|
||||
|
||||
expect(result.workflowData[0][0].binary).toEqual({
|
||||
data0: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default binary property name for whitespace-only filename', async () => {
|
||||
const context = createMockContext();
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
req.body.files = {
|
||||
' ': {
|
||||
filepath: '/tmp/file1',
|
||||
originalFilename: ' ',
|
||||
newFilename: 'temp1',
|
||||
mimetype: 'text/plain',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleFormData(context, mockPrepareOutput);
|
||||
|
||||
expect(result.workflowData[0][0].binary).toEqual({
|
||||
data0: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple files with empty/whitespace filenames', async () => {
|
||||
const context = createMockContext();
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
req.body.files = {
|
||||
'': [
|
||||
{
|
||||
filepath: '/tmp/file1',
|
||||
originalFilename: '',
|
||||
newFilename: 'temp1',
|
||||
mimetype: 'text/plain',
|
||||
},
|
||||
{
|
||||
filepath: '/tmp/file2',
|
||||
originalFilename: '',
|
||||
newFilename: 'temp2',
|
||||
mimetype: 'text/plain',
|
||||
},
|
||||
],
|
||||
' ': [
|
||||
{
|
||||
filepath: '/tmp/file3',
|
||||
originalFilename: ' ',
|
||||
newFilename: 'temp3',
|
||||
mimetype: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await handleFormData(context, mockPrepareOutput);
|
||||
|
||||
expect(result.workflowData[0][0].binary).toEqual({
|
||||
data0: expect.any(Object),
|
||||
data1: expect.any(Object),
|
||||
data2: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom binaryPropertyName with count for empty filenames', async () => {
|
||||
const context = createMockContext({ binaryPropertyName: 'myFile' });
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
req.body.files = {
|
||||
'': {
|
||||
filepath: '/tmp/file1',
|
||||
originalFilename: '',
|
||||
newFilename: 'temp1',
|
||||
mimetype: 'text/plain',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleFormData(context, mockPrepareOutput);
|
||||
|
||||
expect(result.workflowData[0][0].binary).toEqual({
|
||||
myFile0: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve valid filename without using default naming', async () => {
|
||||
const context = createMockContext();
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
req.body.files = {
|
||||
validFile: {
|
||||
filepath: '/tmp/file1',
|
||||
originalFilename: 'validFile.txt',
|
||||
newFilename: 'temp1',
|
||||
mimetype: 'text/plain',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleFormData(context, mockPrepareOutput);
|
||||
|
||||
expect(result.workflowData[0][0].binary).toEqual({
|
||||
validFile: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth token generation', () => {
|
||||
describe('generateFormPostBasicAuthToken', () => {
|
||||
let webhookFunctions: ReturnType<typeof mock<IWebhookFunctions>>;
|
||||
|
||||
beforeEach(() => {
|
||||
webhookFunctions = mock<IWebhookFunctions>();
|
||||
});
|
||||
|
||||
it('should use authentication property for Form Trigger nodes', async () => {
|
||||
webhookFunctions.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode);
|
||||
webhookFunctions.getNodeParameter.mockReturnValue('basicAuth');
|
||||
|
||||
await generateFormPostBasicAuthToken(webhookFunctions, 'authentication');
|
||||
|
||||
expect(webhookFunctions.getNodeParameter).toHaveBeenCalledWith('authentication');
|
||||
});
|
||||
|
||||
it('should use passed authentication key', async () => {
|
||||
webhookFunctions.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.wait',
|
||||
} as INode);
|
||||
webhookFunctions.getNodeParameter.mockReturnValue('basicAuth');
|
||||
|
||||
await generateFormPostBasicAuthToken(webhookFunctions, 'incomingAuthentication');
|
||||
|
||||
expect(webhookFunctions.getNodeParameter).toHaveBeenCalledWith('incomingAuthentication');
|
||||
});
|
||||
|
||||
it('should handle "none" authentication', async () => {
|
||||
webhookFunctions.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode);
|
||||
webhookFunctions.getNodeParameter.mockReturnValue('none');
|
||||
|
||||
const result = await generateFormPostBasicAuthToken(webhookFunctions, 'authentication');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBasicAuthToken', () => {
|
||||
let testNode: INode;
|
||||
let randomCredentials: ICredentialDataDecryptedObject & {
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
testNode = {
|
||||
id: new Date().getMilliseconds().toString(),
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode;
|
||||
|
||||
randomCredentials = {
|
||||
user: (Math.random() * 100000).toString(),
|
||||
password: (Math.random() * 100000).toString(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should return undefined when credentials are empty', () => {
|
||||
const result = generateBasicAuthToken(testNode, undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate valid HMAC token using credentials', () => {
|
||||
const result = generateBasicAuthToken(testNode, randomCredentials);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result?.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should generate deterministic token for same inputs', () => {
|
||||
const token1 = generateBasicAuthToken(testNode, randomCredentials);
|
||||
const token2 = generateBasicAuthToken(testNode, randomCredentials);
|
||||
|
||||
expect(token1).toBe(token2);
|
||||
});
|
||||
|
||||
it('should generate different tokens for different credentials', () => {
|
||||
const token1 = generateBasicAuthToken(testNode, { user: 'user1', password: 'password' });
|
||||
const token2 = generateBasicAuthToken(testNode, { user: 'user2', password: 'password' });
|
||||
const token3 = generateBasicAuthToken(testNode, { user: 'user1', password: 'passwOrd' });
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token1).not.toBe(token3);
|
||||
});
|
||||
|
||||
it('should generate different tokens for different node IDs', () => {
|
||||
const token1 = generateBasicAuthToken(
|
||||
{
|
||||
id: 'node-789',
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode,
|
||||
randomCredentials,
|
||||
);
|
||||
const token2 = generateBasicAuthToken(
|
||||
{
|
||||
id: 'node-678',
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode,
|
||||
randomCredentials,
|
||||
);
|
||||
const token3 = generateBasicAuthToken(
|
||||
{
|
||||
id: 'node-789',
|
||||
webhookId: 'webhook-459',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode,
|
||||
randomCredentials,
|
||||
);
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token1).not.toBe(token3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import basicAuth from 'basic-auth';
|
||||
import { rm } from 'fs/promises';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { WorkflowConfigurationError } from 'n8n-workflow';
|
||||
import type {
|
||||
IWebhookFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
ICredentialDataDecryptedObject,
|
||||
MultiPartFormData,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { BlockList, isIPv6 } from 'node:net';
|
||||
|
||||
import { WebhookAuthorizationError } from './error';
|
||||
import { formatPrivateKey } from '../../utils/utilities';
|
||||
|
||||
export type WebhookParameters = {
|
||||
httpMethod: string | string[];
|
||||
responseMode: string;
|
||||
responseData: string;
|
||||
responseCode?: number; //typeVersion <= 1.1
|
||||
options?: {
|
||||
responseData?: string;
|
||||
responseCode?: {
|
||||
values?: {
|
||||
responseCode: number;
|
||||
customCode?: number;
|
||||
};
|
||||
};
|
||||
noResponseBody?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const getResponseCode = (parameters: WebhookParameters) => {
|
||||
if (parameters.responseCode) {
|
||||
return parameters.responseCode;
|
||||
}
|
||||
const responseCodeOptions = parameters.options;
|
||||
if (responseCodeOptions?.responseCode?.values) {
|
||||
const { responseCode, customCode } = responseCodeOptions.responseCode.values;
|
||||
|
||||
if (customCode) {
|
||||
return customCode;
|
||||
}
|
||||
|
||||
return responseCode;
|
||||
}
|
||||
return 200;
|
||||
};
|
||||
|
||||
export const getResponseData = (parameters: WebhookParameters) => {
|
||||
const { responseData, responseMode, options } = parameters;
|
||||
if (responseData) return responseData;
|
||||
|
||||
if (responseMode === 'onReceived') {
|
||||
const data = options?.responseData;
|
||||
if (data) return data;
|
||||
}
|
||||
|
||||
if (options?.noResponseBody) return 'noData';
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const configuredOutputs = (parameters: WebhookParameters) => {
|
||||
const httpMethod = parameters.httpMethod;
|
||||
|
||||
if (!Array.isArray(httpMethod))
|
||||
return [
|
||||
{
|
||||
type: 'main',
|
||||
displayName: httpMethod,
|
||||
},
|
||||
];
|
||||
|
||||
const outputs = httpMethod.map((method) => {
|
||||
return {
|
||||
type: 'main',
|
||||
displayName: method,
|
||||
};
|
||||
});
|
||||
|
||||
return outputs;
|
||||
};
|
||||
|
||||
export const setupOutputConnection = (
|
||||
ctx: IWebhookFunctions,
|
||||
method: string,
|
||||
additionalData: {
|
||||
jwtPayload?: IDataObject;
|
||||
},
|
||||
) => {
|
||||
const httpMethod = ctx.getNodeParameter('httpMethod', []) as string[] | string;
|
||||
let webhookUrl = ctx.getNodeWebhookUrl('default') as string;
|
||||
const executionMode = ctx.getMode() === 'manual' ? 'test' : 'production';
|
||||
|
||||
if (executionMode === 'test') {
|
||||
webhookUrl = webhookUrl.replace('/webhook/', '/webhook-test/');
|
||||
}
|
||||
|
||||
// multi methods could be set in settings of node, so we need to check if it's an array
|
||||
if (!Array.isArray(httpMethod)) {
|
||||
return (outputData: INodeExecutionData): INodeExecutionData[][] => {
|
||||
outputData.json.webhookUrl = webhookUrl;
|
||||
outputData.json.executionMode = executionMode;
|
||||
if (additionalData?.jwtPayload) {
|
||||
outputData.json.jwtPayload = additionalData.jwtPayload;
|
||||
}
|
||||
return [[outputData]];
|
||||
};
|
||||
}
|
||||
|
||||
const outputIndex = httpMethod.indexOf(method.toUpperCase());
|
||||
const outputs: INodeExecutionData[][] = httpMethod.map(() => []);
|
||||
|
||||
return (outputData: INodeExecutionData): INodeExecutionData[][] => {
|
||||
outputData.json.webhookUrl = webhookUrl;
|
||||
outputData.json.executionMode = executionMode;
|
||||
if (additionalData?.jwtPayload) {
|
||||
outputData.json.jwtPayload = additionalData.jwtPayload;
|
||||
}
|
||||
outputs[outputIndex] = [outputData];
|
||||
return outputs;
|
||||
};
|
||||
};
|
||||
|
||||
export const isIpAllowed = (
|
||||
allowlist: string | string[] | undefined,
|
||||
ips: string[],
|
||||
ip?: string,
|
||||
) => {
|
||||
if (allowlist === undefined || allowlist === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowlist)) {
|
||||
allowlist = allowlist.split(',').map((entry) => entry.trim());
|
||||
}
|
||||
|
||||
const allowList = getAllowList(allowlist);
|
||||
|
||||
// Check the primary IP address with proper family detection
|
||||
if (ip) {
|
||||
const ipFamily = isIPv6(ip) ? 'ipv6' : 'ipv4';
|
||||
if (allowList.check(ip, ipFamily)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check proxy IPs with proper family detection
|
||||
if (
|
||||
ips.some((ipEntry) => {
|
||||
const ipFamily = isIPv6(ipEntry) ? 'ipv6' : 'ipv4';
|
||||
return allowList.check(ipEntry, ipFamily);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getAllowList = (allowlist: string[]) => {
|
||||
const allowList = new BlockList();
|
||||
|
||||
for (const entry of allowlist) {
|
||||
try {
|
||||
// Check if entry is in CIDR notation (contains /)
|
||||
if (entry.includes('/')) {
|
||||
const [network, prefixStr] = entry.split('/');
|
||||
const prefix = parseInt(prefixStr, 10);
|
||||
|
||||
// Validate prefix is a number
|
||||
if (isNaN(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect IP type (IPv4 vs IPv6)
|
||||
const type = network.includes(':') ? 'ipv6' : 'ipv4';
|
||||
|
||||
// Validate prefix range
|
||||
const maxPrefix = type === 'ipv4' ? 32 : 128;
|
||||
if (prefix < 0 || prefix > maxPrefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allowList.addSubnet(network, prefix, type);
|
||||
} else {
|
||||
// Single IP address
|
||||
const type = entry.includes(':') ? 'ipv6' : 'ipv4';
|
||||
allowList.addAddress(entry, type);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid entries
|
||||
}
|
||||
}
|
||||
|
||||
return allowList;
|
||||
};
|
||||
|
||||
export const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
|
||||
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
|
||||
const connectedNodes = context.getChildNodes(context.getNode().name);
|
||||
|
||||
const isRespondToWebhookConnected = connectedNodes.some(
|
||||
(node) => node.type === 'n8n-nodes-base.respondToWebhook',
|
||||
);
|
||||
|
||||
if (!isRespondToWebhookConnected && responseMode === 'responseNode') {
|
||||
throw new WorkflowConfigurationError(
|
||||
context.getNode(),
|
||||
new Error('No Respond to Webhook node found in the workflow'),
|
||||
{
|
||||
description:
|
||||
'Insert a Respond to Webhook node to your workflow to respond to the webhook or choose another option for the “Respond” parameter',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isRespondToWebhookConnected && !['responseNode', 'streaming'].includes(responseMode)) {
|
||||
throw new WorkflowConfigurationError(
|
||||
context.getNode(),
|
||||
new Error('Unused Respond to Webhook node found in the workflow'),
|
||||
{
|
||||
description:
|
||||
'Set the “Respond” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export async function validateWebhookAuthentication(
|
||||
ctx: IWebhookFunctions,
|
||||
authPropertyName: string,
|
||||
) {
|
||||
const authentication = ctx.getNodeParameter(authPropertyName) as string;
|
||||
if (authentication === 'none') return;
|
||||
|
||||
const req = ctx.getRequestObject();
|
||||
const headers = ctx.getHeaderData();
|
||||
|
||||
if (authentication === 'basicAuth') {
|
||||
// Basic authorization is needed to call webhook
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
|
||||
} catch {}
|
||||
|
||||
if (expectedAuth === undefined || !expectedAuth.user || !expectedAuth.password) {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
|
||||
const providedAuth = basicAuth(req);
|
||||
// Authorization data is missing
|
||||
if (!providedAuth) {
|
||||
const authToken = headers['x-auth-token'];
|
||||
if (!authToken) {
|
||||
throw new WebhookAuthorizationError(401);
|
||||
}
|
||||
|
||||
const expectedAuthToken = generateBasicAuthToken(ctx.getNode(), expectedAuth);
|
||||
if (
|
||||
!expectedAuthToken ||
|
||||
typeof authToken !== 'string' ||
|
||||
expectedAuthToken.length !== authToken.length ||
|
||||
!timingSafeEqual(Buffer.from(expectedAuthToken), Buffer.from(authToken))
|
||||
) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (
|
||||
providedAuth.name !== expectedAuth.user ||
|
||||
providedAuth.pass !== expectedAuth.password
|
||||
) {
|
||||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (authentication === 'bearerAuth') {
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<ICredentialDataDecryptedObject>('httpBearerAuth');
|
||||
} catch {}
|
||||
|
||||
const expectedToken = expectedAuth?.token as string;
|
||||
if (!expectedToken) {
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
|
||||
if (headers.authorization !== `Bearer ${expectedToken}`) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (authentication === 'headerAuth') {
|
||||
// Special header with value is needed to call webhook
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<ICredentialDataDecryptedObject>('httpHeaderAuth');
|
||||
} catch {}
|
||||
|
||||
if (expectedAuth === undefined || !expectedAuth.name || !expectedAuth.value) {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
const headerName = (expectedAuth.name as string).toLowerCase();
|
||||
const expectedValue = expectedAuth.value as string;
|
||||
|
||||
if (
|
||||
!headers.hasOwnProperty(headerName) ||
|
||||
(headers as IDataObject)[headerName] !== expectedValue
|
||||
) {
|
||||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (authentication === 'jwtAuth') {
|
||||
let expectedAuth;
|
||||
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<{
|
||||
keyType: 'passphrase' | 'pemKey';
|
||||
publicKey: string;
|
||||
secret: string;
|
||||
algorithm: jwt.Algorithm;
|
||||
}>('jwtAuth');
|
||||
} catch {}
|
||||
|
||||
if (expectedAuth === undefined) {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
throw new WebhookAuthorizationError(401, 'No token provided');
|
||||
}
|
||||
|
||||
let secretOrPublicKey;
|
||||
|
||||
if (expectedAuth.keyType === 'passphrase') {
|
||||
secretOrPublicKey = expectedAuth.secret;
|
||||
} else {
|
||||
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
|
||||
}
|
||||
|
||||
try {
|
||||
return jwt.verify(token, secretOrPublicKey, {
|
||||
algorithms: [expectedAuth.algorithm],
|
||||
}) as IDataObject;
|
||||
} catch (error) {
|
||||
throw new WebhookAuthorizationError(403, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFormData(
|
||||
context: IWebhookFunctions,
|
||||
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
|
||||
) {
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
|
||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
||||
const { data, files } = req.body;
|
||||
|
||||
const returnItem: INodeExecutionData = {
|
||||
json: {
|
||||
headers: req.headers,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: data,
|
||||
},
|
||||
};
|
||||
|
||||
if (files && Object.keys(files).length) {
|
||||
returnItem.binary = {};
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const key of Object.keys(files)) {
|
||||
const processFiles: MultiPartFormData.File[] = [];
|
||||
let multiFile = false;
|
||||
if (Array.isArray(files[key])) {
|
||||
processFiles.push.apply(processFiles, files[key]);
|
||||
multiFile = true;
|
||||
} else {
|
||||
processFiles.push(files[key]);
|
||||
}
|
||||
|
||||
let fileCount = 0;
|
||||
for (const file of processFiles) {
|
||||
let binaryPropertyName = key;
|
||||
if (binaryPropertyName.endsWith('[]')) {
|
||||
binaryPropertyName = binaryPropertyName.slice(0, -2);
|
||||
}
|
||||
if (!binaryPropertyName.trim().length) {
|
||||
binaryPropertyName = `data${count}`;
|
||||
} else if (multiFile) {
|
||||
binaryPropertyName += fileCount++;
|
||||
}
|
||||
if (options.binaryPropertyName) {
|
||||
binaryPropertyName = `${options.binaryPropertyName}${count}`;
|
||||
}
|
||||
|
||||
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
|
||||
file.filepath,
|
||||
file.originalFilename ?? file.newFilename,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
// Delete original file to prevent tmp directory from growing too large
|
||||
await rm(file.filepath, { force: true });
|
||||
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { workflowData: prepareOutput(returnItem) };
|
||||
}
|
||||
|
||||
export async function generateFormPostBasicAuthToken(
|
||||
context: IWebhookFunctions,
|
||||
authPropertyName: string,
|
||||
) {
|
||||
const node = context.getNode();
|
||||
|
||||
const authentication = context.getNodeParameter(authPropertyName);
|
||||
if (authentication === 'none') return;
|
||||
|
||||
let credentials: ICredentialDataDecryptedObject | undefined;
|
||||
|
||||
try {
|
||||
credentials = await context.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
|
||||
} catch {}
|
||||
|
||||
return generateBasicAuthToken(node, credentials);
|
||||
}
|
||||
|
||||
export function generateBasicAuthToken(
|
||||
node: INode,
|
||||
credentials: ICredentialDataDecryptedObject | undefined,
|
||||
) {
|
||||
if (!credentials || !credentials.user || !credentials.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = createHmac('sha256', `${credentials.user}:${credentials.password}`)
|
||||
.update(`${node.id}-${node.webhookId}`)
|
||||
.digest('hex');
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.7459 16.6002C17.0839 19.3911 15.4846 22.1036 13.8539 24.8005C13.4306 25.4904 13.2268 26.0548 13.556 26.9329C14.4811 29.3632 13.1797 31.7151 10.7337 32.3579C8.42888 32.9694 6.18674 31.4485 5.73204 28.9869C5.32437 26.8074 7.01774 24.6594 9.43236 24.3144C9.63619 24.2831 9.84002 24.2831 10.185 24.2517C11.3609 22.2761 12.5682 20.2535 13.8539 18.1054C11.5491 15.8162 10.185 13.135 10.4829 9.8267C10.7024 7.47479 11.6118 5.45216 13.3052 3.79015C16.5351 0.607237 21.4584 0.0898187 25.2528 2.5358C28.9061 4.8877 30.5681 9.45039 29.157 13.3545C28.0751 13.0566 26.9932 12.7744 25.8173 12.4451C26.272 10.2657 25.927 8.32148 24.4689 6.64379C23.4967 5.54623 22.2581 4.9661 20.8469 4.74659C18.009 4.32324 15.2337 6.14205 14.4027 8.91729C13.462 12.0845 14.8731 14.6559 18.7459 16.6002Z" fill="#E7EBF3"/>
|
||||
<path d="M23.4967 13.2919C24.6726 15.3615 25.8642 17.4626 27.0402 19.5322C32.9984 17.6821 37.4983 20.9904 39.1133 24.5183C41.0575 28.7831 39.7248 33.8475 35.899 36.4659C31.9792 39.1628 27.0088 38.7081 23.528 35.2273C24.4217 34.4903 25.2998 33.7377 26.2562 32.9381C29.69 35.1646 32.7004 35.0548 34.9269 32.4207C36.8241 30.1785 36.7928 26.8231 34.8328 24.6123C32.575 22.0723 29.5489 21.9939 25.8956 24.4399C24.3747 21.743 22.8381 19.0775 21.3643 16.365C20.8625 15.4556 20.3294 14.9225 19.2005 14.7344C17.3347 14.4051 16.1274 12.8058 16.0647 11.0183C15.9863 9.24658 17.0368 7.64729 18.6674 7.02011C20.2824 6.40862 22.1796 6.89468 23.2772 8.27446C24.1709 9.40338 24.4531 10.6577 23.9827 12.0532C23.8573 12.4608 23.6848 12.8372 23.4967 13.2919Z" fill="#E7EBF3"/>
|
||||
<path d="M26.3032 30.1942H19.122C18.4322 33.0165 16.9426 35.3057 14.3869 36.7638C12.3956 37.8928 10.2475 38.2691 7.97403 37.9084C3.77196 37.2342 0.338184 33.4398 0.024597 29.1907C-0.320349 24.3615 3.00367 20.081 7.42525 19.1089C7.72316 20.2221 8.03675 21.3354 8.35033 22.4486C4.28938 24.5183 2.89392 27.121 4.02283 30.3823C5.02631 33.2517 7.86427 34.8196 10.9374 34.2238C14.089 33.5966 15.6726 30.9938 15.4844 26.7918C18.4635 26.7918 21.4583 26.7604 24.4373 26.8074C25.5976 26.8231 26.507 26.6977 27.3851 25.6785C28.8276 23.9852 31.4931 24.142 33.0453 25.7413C34.6289 27.3719 34.5662 30.006 32.8728 31.5583C31.2422 33.0635 28.6865 32.9851 27.1656 31.3545C26.852 31.0095 26.6168 30.6019 26.3032 30.1942Z" fill="#E7EBF3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="#37474f" d="M35 37c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4"/><path fill="#37474f" d="M35 43c-3 0-5.9-1.4-7.8-3.7l3.1-2.5c1.1 1.4 2.9 2.3 4.7 2.3 3.3 0 6-2.7 6-6s-2.7-6-6-6c-1 0-2 .3-2.9.7l-1.7 1L23.3 16l3.5-1.9 5.3 9.4c1-.3 2-.5 3-.5 5.5 0 10 4.5 10 10S40.5 43 35 43"/><path fill="#37474f" d="M14 43C8.5 43 4 38.5 4 33c0-4.6 3.1-8.5 7.5-9.7l1 3.9C9.9 27.9 8 30.3 8 33c0 3.3 2.7 6 6 6s6-2.7 6-6v-2h15v4H23.8c-.9 4.6-5 8-9.8 8"/><path fill="#e91e63" d="M14 37c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4"/><path fill="#37474f" d="M25 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4"/><path fill="#e91e63" d="m15.7 34-3.4-2 5.9-9.7c-2-1.9-3.2-4.5-3.2-7.3 0-5.5 4.5-10 10-10s10 4.5 10 10c0 .9-.1 1.7-.3 2.5l-3.9-1c.1-.5.2-1 .2-1.5 0-3.3-2.7-6-6-6s-6 2.7-6 6c0 2.1 1.1 4 2.9 5.1l1.7 1z"/></svg>
|
||||
|
After Width: | Height: | Size: 876 B |
Reference in New Issue
Block a user