From 4c854f4f23c9fada6caa499681b98ca86ef125a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 4 Jul 2023 16:17:50 +0200 Subject: [PATCH] refactor: Setup node context API, and consolidate code between Webhook and Wait nodes (no-changelog) (#6464) --- packages/cli/src/WaitingWebhooks.ts | 4 +- packages/editor-ui/src/constants.ts | 6 +- packages/nodes-base/nodes/Wait/Wait.node.ts | 666 +++------------- .../nodes-base/nodes/Webhook/Webhook.node.ts | 720 +++++------------- .../nodes-base/nodes/Webhook/description.ts | 341 +++++++++ packages/nodes-base/nodes/Webhook/error.ts | 13 + packages/workflow/src/Interfaces.ts | 10 + packages/workflow/src/Workflow.ts | 13 +- 8 files changed, 679 insertions(+), 1094 deletions(-) create mode 100644 packages/nodes-base/nodes/Webhook/description.ts create mode 100644 packages/nodes-base/nodes/Webhook/error.ts diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index bd2a6d5388..50ed41c56c 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -106,13 +106,13 @@ export class WaitingWebhooks { workflow, workflow.getNode(lastNodeExecuted) as INode, additionalData, - ).filter((webhook) => { + ).find((webhook) => { return ( webhook.httpMethod === httpMethod && webhook.path === path && webhook.webhookDescription.restartWebhook === true ); - })[0]; + }); if (webhookData === undefined) { // If no data got found it means that the execution can not be started via a webhook. diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 9086e185f3..d7b5765578 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -518,7 +518,11 @@ export const N8N_CONTACT_EMAIL = 'contact@n8n.io'; export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms -export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE]; +export const KEEP_AUTH_IN_NDV_FOR_NODES = [ + HTTP_REQUEST_NODE_TYPE, + WEBHOOK_NODE_TYPE, + WAIT_NODE_TYPE, +]; export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const NODE_RESOURCE_FIELD_NAME = 'resource'; diff --git a/packages/nodes-base/nodes/Wait/Wait.node.ts b/packages/nodes-base/nodes/Wait/Wait.node.ts index a6eff82ad7..85604f5b8c 100644 --- a/packages/nodes-base/nodes/Wait/Wait.node.ts +++ b/packages/nodes-base/nodes/Wait/Wait.node.ts @@ -1,44 +1,34 @@ import type { IExecuteFunctions, - ICredentialDataDecryptedObject, - IDataObject, INodeExecutionData, - INodeType, INodeTypeDescription, - IWebhookFunctions, - IWebhookResponseData, + INodeProperties, + IDisplayOptions, } from 'n8n-workflow'; -import { BINARY_ENCODING, WAIT_TIME_UNLIMITED, NodeOperationError } from 'n8n-workflow'; +import { WAIT_TIME_UNLIMITED } from 'n8n-workflow'; -import fs from 'fs'; -import stream from 'stream'; -import { promisify } from 'util'; -import basicAuth from 'basic-auth'; -import type { Response } from 'express'; -import formidable from 'formidable'; -import isbot from 'isbot'; -import { file as tmpFile } from 'tmp-promise'; +import { + authenticationProperty, + credentialsProperty, + defaultWebhookDescription, + httpMethodsProperty, + optionsProperty, + responseBinaryPropertyNameProperty, + responseCodeProperty, + responseDataProperty, + responseModeProperty, +} from '../Webhook/description'; +import { Webhook } from '../Webhook/Webhook.node'; -const pipeline = promisify(stream.pipeline); +const displayOnWebhook: IDisplayOptions = { + show: { + resume: ['webhook'], + }, +}; -function authorizationError(resp: Response, realm: string, 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!'; - } - } +export class Wait extends Webhook { + authPropertyName = 'incomingAuthentication'; - resp.writeHead(responseCode, { 'WWW-Authenticate': `Basic realm="${realm}"` }); - resp.end(message); - return { - noWebhookResponse: true, - }; -} - -export class Wait implements INodeType { description: INodeTypeDescription = { displayName: 'Wait', name: 'wait', @@ -52,70 +42,16 @@ export class Wait implements INodeType { }, inputs: ['main'], outputs: ['main'], - credentials: [ - { - name: 'httpBasicAuth', - required: true, - displayOptions: { - show: { - incomingAuthentication: ['basicAuth'], - }, - }, - }, - { - name: 'httpHeaderAuth', - required: true, - displayOptions: { - show: { - incomingAuthentication: ['headerAuth'], - }, - }, - }, - ], + credentials: credentialsProperty(this.authPropertyName), webhooks: [ { - name: 'default', - httpMethod: '={{$parameter["httpMethod"]}}', - isFullPath: true, - responseCode: '={{$parameter["responseCode"]}}', - responseMode: '={{$parameter["responseMode"]}}', + ...defaultWebhookDescription, responseData: '={{$parameter["responseData"]}}', - responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', - responseContentType: '={{$parameter["options"]["responseContentType"]}}', - responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', - responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', path: '={{$parameter["options"]["webhookSuffix"] || ""}}', restartWebhook: true, }, ], properties: [ - { - displayName: 'Webhook Authentication', - name: 'incomingAuthentication', - type: 'options', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - options: [ - { - name: 'Basic Auth', - value: 'basicAuth', - }, - { - name: 'Header Auth', - value: 'headerAuth', - }, - { - name: 'None', - value: 'none', - }, - ], - default: 'none', - description: - 'If and how incoming resume-webhook-requests to $execution.resumeUrl should be authenticated for additional security', - }, { displayName: 'Resume', name: 'resume', @@ -140,6 +76,12 @@ export class Wait implements INodeType { default: 'timeInterval', description: 'Determines the waiting mode to use before the workflow continues', }, + { + ...authenticationProperty(this.authPropertyName), + description: + 'If and how incoming resume-webhook-requests to $execution.resumeUrl should be authenticated for additional security', + displayOptions: displayOnWebhook, + }, // ---------------------------------- // resume:specificTime @@ -215,142 +157,39 @@ export class Wait implements INodeType { 'The webhook URL will be generated at run time. It can be referenced with the $execution.resumeUrl variable. Send it somewhere before getting to this node. More info', name: 'webhookNotice', type: 'notice', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, + displayOptions: displayOnWebhook, default: '', }, { - displayName: 'HTTP Method', - name: 'httpMethod', - type: 'options', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - 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', + ...httpMethodsProperty, + displayOptions: displayOnWebhook, description: 'The HTTP method of the Webhook call', }, { - displayName: 'Response Code', - name: 'responseCode', - type: 'number', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - typeOptions: { - minValue: 100, - maxValue: 599, - }, - default: 200, - description: 'The HTTP Response code to return', + ...responseCodeProperty, + displayOptions: displayOnWebhook, }, { - displayName: 'Respond', - name: 'responseMode', - type: 'options', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - options: [ - { - 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', - }, - ], - default: 'onReceived', - description: 'When and how to respond to the webhook', + ...responseModeProperty, + displayOptions: displayOnWebhook, }, { - displayName: 'Response Data', - name: 'responseData', - type: 'options', + ...responseDataProperty, displayOptions: { show: { - resume: ['webhook'], - responseMode: ['lastNode'], + ...responseDataProperty.displayOptions?.show, + ...displayOnWebhook.show, }, }, - 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.', - }, - ], - default: 'firstEntryJson', - description: - 'What data should be returned. If it should return all the items as array or only the first item as object.', }, { - displayName: 'Property Name', - name: 'responseBinaryPropertyName', - type: 'string', - required: true, - default: 'data', + ...responseBinaryPropertyNameProperty, displayOptions: { show: { - resume: ['webhook'], - responseData: ['firstEntryBinary'], + ...responseBinaryPropertyNameProperty.displayOptions?.show, + ...displayOnWebhook.show, }, }, - description: 'Name of the binary property to return', }, { displayName: 'Limit Wait Time', @@ -360,11 +199,7 @@ export class Wait implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether description: 'If no webhook call is received, the workflow will automatically resume execution after the specified limit type', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, + displayOptions: displayOnWebhook, }, { displayName: 'Limit Type', @@ -376,7 +211,7 @@ export class Wait implements INodeType { displayOptions: { show: { limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, options: [ @@ -400,7 +235,7 @@ export class Wait implements INodeType { show: { limitType: ['afterTimeInterval'], limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, typeOptions: { @@ -418,7 +253,7 @@ export class Wait implements INodeType { show: { limitType: ['afterTimeInterval'], limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, options: [ @@ -450,132 +285,17 @@ export class Wait implements INodeType { show: { limitType: ['atSpecifiedTime'], limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, default: '', description: 'Continue execution after the specified date and time', }, { - displayName: 'Options', - name: 'options', - type: 'collection', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - placeholder: 'Add Option', - default: {}, + ...optionsProperty, + displayOptions: displayOnWebhook, options: [ - { - displayName: 'Binary Data', - name: 'binaryData', - type: 'boolean', - displayOptions: { - show: { - '/httpMethod': ['PATCH', 'PUT', 'POST'], - }, - }, - default: false, - description: 'Whether the webhook will receive binary data', - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - displayOptions: { - show: { - binaryData: [true], - }, - }, - description: - 'Name of the binary property to which to write the data of the received file. 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: 'Ignore Bots', - name: 'ignoreBots', - type: 'boolean', - default: false, - description: - 'Whether to ignore requests from bots like link previewers and web crawlers', - }, - { - displayName: 'Response Data', - name: 'responseData', - type: 'string', - displayOptions: { - show: { - '/responseMode': ['onReceived'], - }, - }, - 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', - }, + ...(optionsProperty.options as INodeProperties[]), { displayName: 'Webhook Suffix', name: 'webhookSuffix', @@ -585,253 +305,23 @@ export class Wait implements INodeType { description: 'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes. Note: Does not support expressions.', }, - // { - // displayName: 'Raw Body', - // name: 'rawBody', - // type: 'boolean', - // displayOptions: { - // hide: { - // binaryData: [ - // true, - // ], - // }, - // }, - // default: false, - // description: 'Raw body (binary)', - // }, ], }, ], }; - async webhook(this: IWebhookFunctions): Promise { - // INFO: Currently (20.06.2021) 100% identical with Webhook-Node - const incomingAuthentication = this.getNodeParameter('incomingAuthentication') as string; - const options = this.getNodeParameter('options', {}) as IDataObject; - const req = this.getRequestObject(); - const resp = this.getResponseObject(); - const headers = this.getHeaderData(); - const realm = 'Webhook'; - - const ignoreBots = options.ignoreBots as boolean; - if (ignoreBots && isbot((headers as IDataObject)['user-agent'] as string)) { - return authorizationError(resp, realm, 403); - } - - if (incomingAuthentication === 'basicAuth') { - // Basic authorization is needed to call webhook - let httpBasicAuth: ICredentialDataDecryptedObject | undefined; - - try { - httpBasicAuth = await this.getCredentials('httpBasicAuth'); - } catch (error) { - // Do nothing - } - - if (httpBasicAuth === undefined || !httpBasicAuth.user || !httpBasicAuth.password) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - - const basicAuthData = basicAuth(req); - - if (basicAuthData === undefined) { - // Authorization data is missing - return authorizationError(resp, realm, 401); - } - - if ( - basicAuthData.name !== httpBasicAuth.user || - basicAuthData.pass !== httpBasicAuth.password - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } else if (incomingAuthentication === 'headerAuth') { - // Special header with value is needed to call webhook - let httpHeaderAuth: ICredentialDataDecryptedObject | undefined; - - try { - httpHeaderAuth = await this.getCredentials('httpHeaderAuth'); - } catch (error) { - // Do nothing - } - - if (httpHeaderAuth === undefined || !httpHeaderAuth.name || !httpHeaderAuth.value) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - const headerName = (httpHeaderAuth.name as string).toLowerCase(); - const headerValue = httpHeaderAuth.value as string; - - if ( - !headers.hasOwnProperty(headerName) || - (headers as IDataObject)[headerName] !== headerValue - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } - - const mimeType = headers['content-type'] ?? 'application/json'; - if (mimeType.includes('multipart/form-data')) { - const form = new formidable.IncomingForm({ multiples: true }); - - return new Promise((resolve, _reject) => { - form.parse(req, async (err, data, files) => { - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: data, - }, - }; - - let count = 0; - for (const xfile of Object.keys(files)) { - const processFiles: formidable.File[] = []; - let multiFile = false; - if (Array.isArray(files[xfile])) { - processFiles.push(...(files[xfile] as formidable.File[])); - multiFile = true; - } else { - processFiles.push(files[xfile] as formidable.File); - } - - let fileCount = 0; - for (const file of processFiles) { - let binaryPropertyName = xfile; - if (binaryPropertyName.endsWith('[]')) { - binaryPropertyName = binaryPropertyName.slice(0, -2); - } - if (multiFile) { - binaryPropertyName += fileCount++; - } - if (options.binaryPropertyName) { - binaryPropertyName = `${options.binaryPropertyName}${count}`; - } - - const fileJson = file.toJSON(); - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - file.path, - fileJson.name || fileJson.filename, - fileJson.type as string, - ); - - count += 1; - } - } - resolve({ - workflowData: [[returnItem]], - }); - }); - }); - } - - if (options.binaryData === true) { - const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' }); - - try { - await pipeline(req, fs.createWriteStream(binaryFile.path)); - - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), - }, - }; - - const binaryPropertyName = (options.binaryPropertyName || 'data') as string; - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - binaryFile.path, - mimeType, - ); - - return { - workflowData: [[returnItem]], - }; - } catch (error) { - throw new NodeOperationError(this.getNode(), error as Error); - } finally { - await binaryFile.cleanup(); - } - } - - const response: INodeExecutionData = { - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), - }, - }; - - if (options.rawBody) { - response.binary = { - data: { - data: req.rawBody.toString(BINARY_ENCODING), - mimeType, - }, - }; - } - - let webhookResponse: string | undefined; - if (options.responseData) { - webhookResponse = options.responseData as string; - } - - return { - webhookResponse, - workflowData: [[response]], - }; - } - - async execute(this: IExecuteFunctions): Promise { - const resume = this.getNodeParameter('resume', 0) as string; + async execute(context: IExecuteFunctions): Promise { + const resume = context.getNodeParameter('resume', 0) as string; if (resume === 'webhook') { - let waitTill = new Date(WAIT_TIME_UNLIMITED); - - const limitWaitTime = this.getNodeParameter('limitWaitTime', 0); - - if (limitWaitTime === true) { - const limitType = this.getNodeParameter('limitType', 0); - if (limitType === 'afterTimeInterval') { - let waitAmount = this.getNodeParameter('resumeAmount', 0) as number; - const resumeUnit = this.getNodeParameter('resumeUnit', 0); - if (resumeUnit === 'minutes') { - waitAmount *= 60; - } - if (resumeUnit === 'hours') { - waitAmount *= 60 * 60; - } - if (resumeUnit === 'days') { - waitAmount *= 60 * 60 * 24; - } - - waitAmount *= 1000; - - waitTill = new Date(new Date().getTime() + waitAmount); - } else { - waitTill = new Date(this.getNodeParameter('maxDateAndTime', 0) as string); - } - } - - await this.putExecutionToWait(waitTill); - - return [this.getInputData()]; + return this.handleWebhookResume(context); } let waitTill: Date; if (resume === 'timeInterval') { - const unit = this.getNodeParameter('unit', 0) as string; + const unit = context.getNodeParameter('unit', 0) as string; - let waitAmount = this.getNodeParameter('amount', 0) as number; + let waitAmount = context.getNodeParameter('amount', 0) as number; if (unit === 'minutes') { waitAmount *= 60; } @@ -847,7 +337,7 @@ export class Wait implements INodeType { waitTill = new Date(new Date().getTime() + waitAmount); } else { // resume: dateTime - const dateTime = this.getNodeParameter('dateTime', 0) as string; + const dateTime = context.getNodeParameter('dateTime', 0) as string; waitTill = new Date(dateTime); } @@ -859,14 +349,48 @@ export class Wait implements INodeType { // we just check the database every 60 seconds. return new Promise((resolve, _reject) => { setTimeout(() => { - resolve([this.getInputData()]); + resolve([context.getInputData()]); }, waitValue); }); } - // If longer than 60 seconds put execution to wait - await this.putExecutionToWait(waitTill); + // If longer than 65 seconds put execution to wait + return this.putToWait(context, waitTill); + } - return [this.getInputData()]; + private async handleWebhookResume(context: IExecuteFunctions) { + let waitTill = new Date(WAIT_TIME_UNLIMITED); + + const limitWaitTime = context.getNodeParameter('limitWaitTime', 0); + + if (limitWaitTime === true) { + const limitType = context.getNodeParameter('limitType', 0); + if (limitType === 'afterTimeInterval') { + let waitAmount = context.getNodeParameter('resumeAmount', 0) as number; + const resumeUnit = context.getNodeParameter('resumeUnit', 0); + if (resumeUnit === 'minutes') { + waitAmount *= 60; + } + if (resumeUnit === 'hours') { + waitAmount *= 60 * 60; + } + if (resumeUnit === 'days') { + waitAmount *= 60 * 60 * 24; + } + + waitAmount *= 1000; + + waitTill = new Date(new Date().getTime() + waitAmount); + } else { + waitTill = new Date(context.getNodeParameter('maxDateAndTime', 0) as string); + } + } + + return this.putToWait(context, waitTill); + } + + private async putToWait(context: IExecuteFunctions, waitTill: Date) { + await context.putExecutionToWait(waitTill); + return [context.getInputData()]; } } diff --git a/packages/nodes-base/nodes/Webhook/Webhook.node.ts b/packages/nodes-base/nodes/Webhook/Webhook.node.ts index f2fa74f7b6..2e8546bac5 100644 --- a/packages/nodes-base/nodes/Webhook/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook/Webhook.node.ts @@ -1,43 +1,40 @@ +/* eslint-disable n8n-nodes-base/node-execute-block-wrong-error-thrown */ import type { IWebhookFunctions, ICredentialDataDecryptedObject, IDataObject, INodeExecutionData, - INodeType, INodeTypeDescription, IWebhookResponseData, } from 'n8n-workflow'; -import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow'; +import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow'; import fs from 'fs'; import stream from 'stream'; import { promisify } from 'util'; import basicAuth from 'basic-auth'; -import type { Response } from 'express'; import formidable from 'formidable'; import isbot from 'isbot'; import { file as tmpFile } from 'tmp-promise'; +import { + authenticationProperty, + credentialsProperty, + defaultWebhookDescription, + httpMethodsProperty, + optionsProperty, + responseBinaryPropertyNameProperty, + responseCodeProperty, + responseDataProperty, + responseModeProperty, +} from './description'; +import { WebhookAuthorizationError } from './error'; + const pipeline = promisify(stream.pipeline); -function authorizationError(resp: Response, realm: string, 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!'; - } - } +export class Webhook extends Node { + authPropertyName = 'authentication'; - resp.writeHead(responseCode, { 'WWW-Authenticate': `Basic realm="${realm}"` }); - resp.end(message); - return { - noWebhookResponse: true, - }; -} - -export class Webhook implements INodeType { description: INodeTypeDescription = { displayName: 'Webhook', icon: 'file:webhook.svg', @@ -64,97 +61,11 @@ export class Webhook implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], outputs: ['main'], - credentials: [ - { - name: 'httpBasicAuth', - required: true, - displayOptions: { - show: { - authentication: ['basicAuth'], - }, - }, - }, - { - name: 'httpHeaderAuth', - required: true, - displayOptions: { - show: { - authentication: ['headerAuth'], - }, - }, - }, - ], - webhooks: [ - { - name: 'default', - httpMethod: '={{$parameter["httpMethod"]}}', - isFullPath: true, - responseCode: '={{$parameter["responseCode"]}}', - responseMode: '={{$parameter["responseMode"]}}', - responseData: - '={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}', - responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', - responseContentType: '={{$parameter["options"]["responseContentType"]}}', - responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', - responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', - path: '={{$parameter["path"]}}', - }, - ], + credentials: credentialsProperty(this.authPropertyName), + webhooks: [defaultWebhookDescription], properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Basic Auth', - value: 'basicAuth', - }, - { - name: 'Header Auth', - value: 'headerAuth', - }, - { - name: 'None', - value: 'none', - }, - ], - default: 'none', - description: 'The way to authenticate', - }, - { - 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', - }, + authenticationProperty(this.authPropertyName), + httpMethodsProperty, { displayName: 'Path', name: 'path', @@ -164,30 +75,7 @@ export class Webhook implements INodeType { required: true, description: 'The path to listen to', }, - { - displayName: 'Respond', - name: 'responseMode', - type: 'options', - options: [ - { - 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', - }, - ], - default: 'onReceived', - description: 'When and how to respond to the webhook', - }, + responseModeProperty, { displayName: 'Insert a \'Respond to Webhook\' node to control when and how you respond. More details', @@ -200,406 +88,206 @@ export class Webhook implements INodeType { }, default: '', }, - { - 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', - }, - { - 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.', - }, - { - displayName: 'Property Name', - name: 'responseBinaryPropertyName', - type: 'string', - required: true, - default: 'data', - displayOptions: { - show: { - responseData: ['firstEntryBinary'], - }, - }, - description: 'Name of the binary property to return', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - options: [ - { - displayName: 'Binary Data', - name: 'binaryData', - type: 'boolean', - displayOptions: { - show: { - '/httpMethod': ['PATCH', 'PUT', 'POST'], - }, - }, - default: false, - description: 'Whether the webhook will receive binary data', - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - required: true, - displayOptions: { - show: { - binaryData: [true], - }, - }, - description: - 'Name of the binary property to write the data of the received file to. 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: 'Ignore Bots', - name: 'ignoreBots', - type: 'boolean', - default: false, - description: - 'Whether to ignore requests from bots like link previewers and web crawlers', - }, - { - 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: { - 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: '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', - }, - ], - }, + responseCodeProperty, + responseDataProperty, + responseBinaryPropertyNameProperty, + optionsProperty, ], }; - async webhook(this: IWebhookFunctions): Promise { - const authentication = this.getNodeParameter('authentication') as string; - const options = this.getNodeParameter('options', {}) as IDataObject; - const req = this.getRequestObject(); - const resp = this.getResponseObject(); - const headers = this.getHeaderData(); - const realm = 'Webhook'; + async webhook(context: IWebhookFunctions): Promise { + const options = context.getNodeParameter('options', {}) as { + binaryData: boolean; + ignoreBots: boolean; + rawBody: Buffer; + responseData?: string; + }; + const req = context.getRequestObject(); + const resp = context.getResponseObject(); - const ignoreBots = options.ignoreBots as boolean; - if (ignoreBots && isbot((headers as IDataObject)['user-agent'] as string)) { - return authorizationError(resp, realm, 403); + try { + if (options.ignoreBots && isbot(req.headers['user-agent'])) + throw new WebhookAuthorizationError(403); + 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; } - if (authentication === 'basicAuth') { - // Basic authorization is needed to call webhook - let httpBasicAuth: ICredentialDataDecryptedObject | undefined; - try { - httpBasicAuth = await this.getCredentials('httpBasicAuth'); - } catch (error) { - // Do nothing - } - - if (httpBasicAuth === undefined || !httpBasicAuth.user || !httpBasicAuth.password) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - - const basicAuthData = basicAuth(req); - - if (basicAuthData === undefined) { - // Authorization data is missing - return authorizationError(resp, realm, 401); - } - - if ( - basicAuthData.name !== httpBasicAuth.user || - basicAuthData.pass !== httpBasicAuth.password - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } else if (authentication === 'headerAuth') { - // Special header with value is needed to call webhook - let httpHeaderAuth: ICredentialDataDecryptedObject | undefined; - try { - httpHeaderAuth = await this.getCredentials('httpHeaderAuth'); - } catch (error) { - // Do nothing - } - - if (httpHeaderAuth === undefined || !httpHeaderAuth.name || !httpHeaderAuth.value) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - const headerName = (httpHeaderAuth.name as string).toLowerCase(); - const headerValue = httpHeaderAuth.value as string; - - if ( - !headers.hasOwnProperty(headerName) || - (headers as IDataObject)[headerName] !== headerValue - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } - - const mimeType = headers['content-type'] ?? 'application/json'; + const mimeType = req.headers['content-type'] ?? 'application/json'; if (mimeType.includes('multipart/form-data')) { - const form = new formidable.IncomingForm({ multiples: true }); - - return new Promise((resolve, _reject) => { - form.parse(req, async (err, data, files) => { - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: data, - }, - }; - - let count = 0; - for (const xfile of Object.keys(files)) { - const processFiles: formidable.File[] = []; - let multiFile = false; - if (Array.isArray(files[xfile])) { - processFiles.push(...(files[xfile] as formidable.File[])); - multiFile = true; - } else { - processFiles.push(files[xfile] as formidable.File); - } - - let fileCount = 0; - for (const file of processFiles) { - let binaryPropertyName = xfile; - if (binaryPropertyName.endsWith('[]')) { - binaryPropertyName = binaryPropertyName.slice(0, -2); - } - if (multiFile) { - binaryPropertyName += fileCount++; - } - if (options.binaryPropertyName) { - binaryPropertyName = `${options.binaryPropertyName}${count}`; - } - - const fileJson = file.toJSON(); - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - file.path, - fileJson.name || fileJson.filename, - fileJson.type as string, - ); - - count += 1; - } - } - resolve({ - workflowData: [[returnItem]], - }); - }); - }); + return this.handleFormData(context); } - if (options.binaryData === true) { - const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' }); - - try { - await pipeline(req, fs.createWriteStream(binaryFile.path)); - - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), - }, - }; - - const binaryPropertyName = (options.binaryPropertyName || 'data') as string; - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - binaryFile.path, - mimeType, - ); - - return { - workflowData: [[returnItem]], - }; - } catch (error) { - throw new NodeOperationError(this.getNode(), error as Error); - } finally { - await binaryFile.cleanup(); - } + if (options.binaryData) { + return this.handleBinaryData(context); } const response: INodeExecutionData = { json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), + headers: req.headers, + params: req.params, + query: req.query, + body: req.body, }, + binary: options.rawBody + ? { + data: { + data: req.rawBody.toString(BINARY_ENCODING), + mimeType, + }, + } + : undefined, }; - if (options.rawBody) { - response.binary = { - data: { - data: req.rawBody.toString(BINARY_ENCODING), - mimeType, - }, - }; - } - - let webhookResponse: string | undefined; - if (options.responseData) { - webhookResponse = options.responseData as string; - } - return { - webhookResponse, + webhookResponse: options.responseData, workflowData: [[response]], }; } + + private async validateAuth(context: IWebhookFunctions) { + const authentication = context.getNodeParameter(this.authPropertyName) as string; + if (authentication === 'none') return; + + const req = context.getRequestObject(); + const headers = context.getHeaderData(); + + if (authentication === 'basicAuth') { + // Basic authorization is needed to call webhook + let expectedAuth: ICredentialDataDecryptedObject | undefined; + try { + expectedAuth = await context.getCredentials('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) throw new WebhookAuthorizationError(401); + + if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) { + // Provided authentication data is wrong + throw new WebhookAuthorizationError(403); + } + } else if (authentication === 'headerAuth') { + // Special header with value is needed to call webhook + let expectedAuth: ICredentialDataDecryptedObject | undefined; + try { + expectedAuth = await context.getCredentials('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); + } + } + } + + private async handleFormData(context: IWebhookFunctions) { + const req = context.getRequestObject(); + const options = context.getNodeParameter('options', {}) as IDataObject; + + const form = new formidable.IncomingForm({ multiples: true }); + + return new Promise((resolve, _reject) => { + form.parse(req, async (err, data, files) => { + const returnItem: INodeExecutionData = { + binary: {}, + json: { + headers: req.headers, + params: req.params, + query: req.query, + body: data, + }, + }; + + let count = 0; + for (const xfile of Object.keys(files)) { + const processFiles: formidable.File[] = []; + let multiFile = false; + if (Array.isArray(files[xfile])) { + processFiles.push(...(files[xfile] as formidable.File[])); + multiFile = true; + } else { + processFiles.push(files[xfile] as formidable.File); + } + + let fileCount = 0; + for (const file of processFiles) { + let binaryPropertyName = xfile; + if (binaryPropertyName.endsWith('[]')) { + binaryPropertyName = binaryPropertyName.slice(0, -2); + } + if (multiFile) { + binaryPropertyName += fileCount++; + } + if (options.binaryPropertyName) { + binaryPropertyName = `${options.binaryPropertyName}${count}`; + } + + const fileJson = file.toJSON(); + returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile( + file.path, + fileJson.name || fileJson.filename, + fileJson.type as string, + ); + + count += 1; + } + } + resolve({ workflowData: [[returnItem]] }); + }); + }); + } + + private async handleBinaryData(context: IWebhookFunctions): Promise { + const req = context.getRequestObject(); + const options = context.getNodeParameter('options', {}) as IDataObject; + + const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' }); + + try { + await pipeline(req, fs.createWriteStream(binaryFile.path)); + + const returnItem: INodeExecutionData = { + binary: {}, + json: { + headers: req.headers, + params: req.params, + query: req.query, + body: req.body, + }, + }; + + const binaryPropertyName = (options.binaryPropertyName || 'data') as string; + returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile( + binaryFile.path, + req.headers['content-type'] ?? 'application/octet-stream', + ); + + return { workflowData: [[returnItem]] }; + } catch (error) { + throw new NodeOperationError(context.getNode(), error as Error); + } finally { + await binaryFile.cleanup(); + } + } } diff --git a/packages/nodes-base/nodes/Webhook/description.ts b/packages/nodes-base/nodes/Webhook/description.ts new file mode 100644 index 0000000000..4fd3a5bb17 --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/description.ts @@ -0,0 +1,341 @@ +import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow'; + +export const defaultWebhookDescription: IWebhookDescription = { + name: 'default', + httpMethod: '={{$parameter["httpMethod"]}}', + isFullPath: true, + responseCode: '={{$parameter["responseCode"]}}', + responseMode: '={{$parameter["responseMode"]}}', + responseData: + '={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}', + responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', + responseContentType: '={{$parameter["options"]["responseContentType"]}}', + responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', + responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', + path: '={{$parameter["path"]}}', +}; + +export const credentialsProperty = ( + propertyName: string = 'authentication', +): INodeTypeDescription['credentials'] => [ + { + name: 'httpBasicAuth', + required: true, + displayOptions: { + show: { + [propertyName]: ['basicAuth'], + }, + }, + }, + { + name: 'httpHeaderAuth', + required: true, + displayOptions: { + show: { + [propertyName]: ['headerAuth'], + }, + }, + }, +]; + +export const authenticationProperty = ( + propertyName: string = 'authentication', +): INodeProperties => ({ + displayName: 'Authentication', + name: propertyName, + type: 'options', + options: [ + { + name: 'Basic Auth', + value: 'basicAuth', + }, + { + name: 'Header Auth', + value: 'headerAuth', + }, + { + 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', +}; + +export const responseModeProperty: INodeProperties = { + displayName: 'Respond', + name: 'responseMode', + type: 'options', + options: [ + { + 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', + }, + ], + default: 'onReceived', + description: 'When and how to respond to the webhook', +}; + +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 Data', + name: 'binaryData', + type: 'boolean', + displayOptions: { + show: { + '/httpMethod': ['PATCH', 'PUT', 'POST'], + }, + }, + default: false, + description: 'Whether the webhook will receive binary data', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + binaryData: [true], + }, + }, + description: + 'Name of the binary property to write the data of the received file to. 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: 'Ignore Bots', + name: 'ignoreBots', + type: 'boolean', + default: false, + description: 'Whether to ignore requests from bots like link previewers and web crawlers', + }, + { + 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: { + 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: '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', + }, + ], +}; diff --git a/packages/nodes-base/nodes/Webhook/error.ts b/packages/nodes-base/nodes/Webhook/error.ts new file mode 100644 index 0000000000..d9397a93e0 --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/error.ts @@ -0,0 +1,13 @@ +export class WebhookAuthorizationError extends Error { + 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); + } +} diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index e9d4ed1f61..f6c3ac92a6 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1258,6 +1258,16 @@ export interface INodeType { }; } +/** + * This class serves as the base for all nodes using the new context API + * having this as a class enables us to identify these instances at runtime + */ +export abstract class Node { + abstract description: INodeTypeDescription; + execute?(context: IExecuteFunctions): Promise; + webhook?(context: IWebhookFunctions): Promise; +} + export interface IVersionedNodeType { nodeVersions: { [key: number]: INodeType; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 18092ce3e7..4262006fe6 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -49,6 +49,7 @@ import type { IRunNodeResponse, NodeParameterValueType, } from './Interfaces'; +import { Node } from './Interfaces'; import type { IDeferredPromise } from './DeferredPromise'; import * as NodeHelpers from './NodeHelpers'; @@ -1137,14 +1138,14 @@ export class Workflow { throw new Error(`The node "${node.name}" does not have any webhooks defined.`); } - const thisArgs = nodeExecuteFunctions.getExecuteWebhookFunctions( + const context = nodeExecuteFunctions.getExecuteWebhookFunctions( this, node, additionalData, mode, webhookData, ); - return nodeType.webhook.call(thisArgs); + return nodeType instanceof Node ? nodeType.webhook(context) : nodeType.webhook.call(context); } /** @@ -1255,7 +1256,7 @@ export class Workflow { return { data: [promiseResults] }; } } else if (nodeType.execute) { - const thisArgs = nodeExecuteFunctions.getExecuteFunctions( + const context = nodeExecuteFunctions.getExecuteFunctions( this, runExecutionData, runIndex, @@ -1266,7 +1267,11 @@ export class Workflow { executionData, mode, ); - return { data: await nodeType.execute.call(thisArgs) }; + const data = + nodeType instanceof Node + ? await nodeType.execute(context) + : await nodeType.execute.call(context); + return { data }; } else if (nodeType.poll) { if (mode === 'manual') { // In manual mode run the poll function