mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat(Webhook Node): Overhaul (#8889)
Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
parent
519f945547
commit
e84c27c0ce
|
@ -48,11 +48,10 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseCode) {
|
if (responseCode) {
|
||||||
cy.getByTestId('parameter-input-responseCode')
|
cy.get('.param-options').click();
|
||||||
.find('.parameter-input')
|
getVisibleSelect().contains('Response Code').click();
|
||||||
.find('input')
|
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
|
||||||
.clear()
|
getVisibleSelect().contains('201').click();
|
||||||
.type(responseCode.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (respondWith) {
|
if (respondWith) {
|
||||||
|
|
|
@ -115,12 +115,12 @@ export abstract class AbstractServer {
|
||||||
|
|
||||||
private async setupHealthCheck() {
|
private async setupHealthCheck() {
|
||||||
// health check should not care about DB connections
|
// health check should not care about DB connections
|
||||||
this.app.get('/healthz', async (req, res) => {
|
this.app.get('/healthz', async (_req, res) => {
|
||||||
res.send({ status: 'ok' });
|
res.send({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const { connectionState } = Db;
|
const { connectionState } = Db;
|
||||||
this.app.use((req, res, next) => {
|
this.app.use((_req, res, next) => {
|
||||||
if (connectionState.connected) {
|
if (connectionState.connected) {
|
||||||
if (connectionState.migrated) next();
|
if (connectionState.migrated) next();
|
||||||
else res.send('n8n is starting up. Please wait');
|
else res.send('n8n is starting up. Please wait');
|
||||||
|
|
|
@ -106,10 +106,22 @@ export const webhookRequestHandler =
|
||||||
const options = await webhookManager.findAccessControlOptions(path, requestedMethod);
|
const options = await webhookManager.findAccessControlOptions(path, requestedMethod);
|
||||||
const { allowedOrigins } = options ?? {};
|
const { allowedOrigins } = options ?? {};
|
||||||
|
|
||||||
res.header(
|
if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) {
|
||||||
'Access-Control-Allow-Origin',
|
const originsList = allowedOrigins.split(',');
|
||||||
!allowedOrigins || allowedOrigins === '*' ? req.headers.origin : allowedOrigins,
|
const defaultOrigin = originsList[0];
|
||||||
);
|
|
||||||
|
if (originsList.length === 1) {
|
||||||
|
res.header('Access-Control-Allow-Origin', defaultOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originsList.includes(req.headers.origin as string)) {
|
||||||
|
res.header('Access-Control-Allow-Origin', req.headers.origin);
|
||||||
|
} else {
|
||||||
|
res.header('Access-Control-Allow-Origin', defaultOrigin);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.header('Access-Control-Allow-Origin', req.headers.origin);
|
||||||
|
}
|
||||||
|
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
res.header('Access-Control-Max-Age', '300');
|
res.header('Access-Control-Max-Age', '300');
|
||||||
|
@ -262,14 +274,14 @@ export async function executeWebhook(
|
||||||
);
|
);
|
||||||
const responseCode = workflow.expression.getSimpleParameterValue(
|
const responseCode = workflow.expression.getSimpleParameterValue(
|
||||||
workflowStartNode,
|
workflowStartNode,
|
||||||
webhookData.webhookDescription.responseCode,
|
webhookData.webhookDescription.responseCode as string,
|
||||||
executionMode,
|
executionMode,
|
||||||
additionalKeys,
|
additionalKeys,
|
||||||
undefined,
|
undefined,
|
||||||
200,
|
200,
|
||||||
) as number;
|
) as number;
|
||||||
|
|
||||||
const responseData = workflow.expression.getSimpleParameterValue(
|
const responseData = workflow.expression.getComplexParameterValue(
|
||||||
workflowStartNode,
|
workflowStartNode,
|
||||||
webhookData.webhookDescription.responseData,
|
webhookData.webhookDescription.responseData,
|
||||||
executionMode,
|
executionMode,
|
||||||
|
@ -324,7 +336,7 @@ export async function executeWebhook(
|
||||||
// TODO: pass a custom `fileWriteStreamHandler` to create binary data files directly
|
// TODO: pass a custom `fileWriteStreamHandler` to create binary data files directly
|
||||||
});
|
});
|
||||||
req.body = await new Promise((resolve) => {
|
req.body = await new Promise((resolve) => {
|
||||||
form.parse(req, async (err, data, files) => {
|
form.parse(req, async (_err, data, files) => {
|
||||||
normalizeFormData(data);
|
normalizeFormData(data);
|
||||||
normalizeFormData(files);
|
normalizeFormData(files);
|
||||||
resolve({ data, files });
|
resolve({ data, files });
|
||||||
|
@ -455,6 +467,12 @@ export async function executeWebhook(
|
||||||
responseCallback(null, {
|
responseCallback(null, {
|
||||||
responseCode,
|
responseCode,
|
||||||
});
|
});
|
||||||
|
} else if (responseData) {
|
||||||
|
// Return the data specified in the response data option
|
||||||
|
responseCallback(null, {
|
||||||
|
data: responseData as IDataObject,
|
||||||
|
responseCode,
|
||||||
|
});
|
||||||
} else if (webhookResultData.webhookResponse !== undefined) {
|
} else if (webhookResultData.webhookResponse !== undefined) {
|
||||||
// Data to respond with is given
|
// Data to respond with is given
|
||||||
responseCallback(null, {
|
responseCallback(null, {
|
||||||
|
|
|
@ -49,7 +49,7 @@ export class WebhookNotFoundError extends NotFoundError {
|
||||||
|
|
||||||
const hintMsg =
|
const hintMsg =
|
||||||
hint === 'default'
|
hint === 'default'
|
||||||
? "Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)"
|
? "Click the 'Test workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)"
|
||||||
: "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)";
|
: "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)";
|
||||||
|
|
||||||
super(errorMsg, hintMsg);
|
super(errorMsg, hintMsg);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import { CacheService } from '@/services/cache/cache.service';
|
import { CacheService } from '@/services/cache/cache.service';
|
||||||
import { type IWebhookData } from 'n8n-workflow';
|
import type { IWebhookData } from 'n8n-workflow';
|
||||||
import type { IWorkflowDb } from '@/Interfaces';
|
import type { IWorkflowDb } from '@/Interfaces';
|
||||||
import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants';
|
import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants';
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,7 @@ import type {
|
||||||
NodeExecutionWithMetadata,
|
NodeExecutionWithMetadata,
|
||||||
NodeHelperFunctions,
|
NodeHelperFunctions,
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
|
NodeTypeAndVersion,
|
||||||
PaginationOptions,
|
PaginationOptions,
|
||||||
RequestHelperFunctions,
|
RequestHelperFunctions,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
@ -2798,7 +2799,34 @@ const getCommonWorkflowFunctions = (
|
||||||
active: workflow.active,
|
active: workflow.active,
|
||||||
}),
|
}),
|
||||||
getWorkflowStaticData: (type) => workflow.getStaticData(type, node),
|
getWorkflowStaticData: (type) => workflow.getStaticData(type, node),
|
||||||
|
getChildNodes: (nodeName: string) => {
|
||||||
|
const output: NodeTypeAndVersion[] = [];
|
||||||
|
const nodes = workflow.getChildNodes(nodeName);
|
||||||
|
|
||||||
|
for (const nodeName of nodes) {
|
||||||
|
const node = workflow.nodes[nodeName];
|
||||||
|
output.push({
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
typeVersion: node.typeVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
getParentNodes: (nodeName: string) => {
|
||||||
|
const output: NodeTypeAndVersion[] = [];
|
||||||
|
const nodes = workflow.getParentNodes(nodeName);
|
||||||
|
|
||||||
|
for (const nodeName of nodes) {
|
||||||
|
const node = workflow.nodes[nodeName];
|
||||||
|
output.push({
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
typeVersion: node.typeVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
},
|
||||||
getRestApiUrl: () => additionalData.restApiUrl,
|
getRestApiUrl: () => additionalData.restApiUrl,
|
||||||
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
|
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
|
||||||
getInstanceId: () => Container.get(InstanceSettings).instanceId,
|
getInstanceId: () => Container.get(InstanceSettings).instanceId,
|
||||||
|
|
112
packages/nodes-base/credentials/icons/jwt.svg
Normal file
112
packages/nodes-base/credentials/icons/jwt.svg
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="200px"
|
||||||
|
height="200px"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
version="1.1"
|
||||||
|
id="svg10"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title
|
||||||
|
id="title1">Group</title>
|
||||||
|
<desc
|
||||||
|
id="desc1">Created with Sketch.</desc>
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter11"
|
||||||
|
x="-0.18346753"
|
||||||
|
y="-0.076523014"
|
||||||
|
width="1.3669351"
|
||||||
|
height="1.153046">
|
||||||
|
<feGaussianBlur
|
||||||
|
stdDeviation="1.2230926"
|
||||||
|
id="feGaussianBlur11" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter12"
|
||||||
|
x="-0.18346753"
|
||||||
|
y="-0.076523014"
|
||||||
|
width="1.3669351"
|
||||||
|
height="1.153046">
|
||||||
|
<feGaussianBlur
|
||||||
|
stdDeviation="1.2230926"
|
||||||
|
id="feGaussianBlur12" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="layer1" />
|
||||||
|
<g
|
||||||
|
transform="translate(57.626456,48.948311)"
|
||||||
|
id="Shape-6">
|
||||||
|
<g
|
||||||
|
id="g12"
|
||||||
|
transform="matrix(1.8791546,0,0,1.8791546,-51.584186,-42.862227)">
|
||||||
|
<path
|
||||||
|
d="M 42.223544,27.435493 V -0.303311 H 58.223226 V 27.435493 L 50.223385,38.05668 Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path11"
|
||||||
|
style="fill:#ececec;stroke-width:1.04876;filter:url(#filter12)" />
|
||||||
|
<path
|
||||||
|
d="M 57.5,26.9 V 0 h -15 V 26.9 L 50,37.2 Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path1-7" />
|
||||||
|
<path
|
||||||
|
d="M 42.223544,72.517876 V 100.25668 H 58.223226 V 72.517876 L 50.223385,61.896689 Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path10"
|
||||||
|
style="fill:#ececec;stroke-width:1.04876;filter:url(#filter11)" />
|
||||||
|
<path
|
||||||
|
d="m 42.623319,73.156936 v 26.900004 h 15 V 73.156936 l -7.5,-10.3 z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path2-5" />
|
||||||
|
<path
|
||||||
|
d="M 57.5,73.1 73.3,94.9 85.5,86 69.6,64.3 57.5,60.3 Z"
|
||||||
|
fill="#00f2e6"
|
||||||
|
id="path3-3" />
|
||||||
|
<path
|
||||||
|
d="M 42.5,26.9 26.7,5.1 14.5,14 l 15.9,21.7 12.1,4 z"
|
||||||
|
fill="#00f2e6"
|
||||||
|
id="path4-5" />
|
||||||
|
<path
|
||||||
|
d="M 30.4,35.7 4.8,27.4 0.1,41.7 25.7,50 37.9,46.1 Z"
|
||||||
|
fill="#00b9f1"
|
||||||
|
id="path5-6" />
|
||||||
|
<path
|
||||||
|
d="M 62.1,53.9 69.6,64.3 95.2,72.6 99.9,58.3 74.3,50 Z"
|
||||||
|
fill="#00b9f1"
|
||||||
|
id="path6-2" />
|
||||||
|
<path
|
||||||
|
d="M 74.3,50 99.9,41.7 95.2,27.4 69.6,35.7 62.1,46.1 Z"
|
||||||
|
fill="#d63aff"
|
||||||
|
id="path7-9" />
|
||||||
|
<path
|
||||||
|
d="M 25.7,50 0.1,58.3 4.8,72.6 30.4,64.3 37.9,53.9 Z"
|
||||||
|
fill="#d63aff"
|
||||||
|
id="path8-1" />
|
||||||
|
<path
|
||||||
|
d="M 30.4,64.3 14.5,86 26.7,94.9 42.5,73.1 V 60.3 Z"
|
||||||
|
fill="#fb015b"
|
||||||
|
id="path9-2" />
|
||||||
|
<path
|
||||||
|
d="M 69.6,35.7 85.5,14 73.3,5.1 57.5,26.9 v 12.8 z"
|
||||||
|
fill="#fb015b"
|
||||||
|
id="path10-7" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<metadata
|
||||||
|
id="metadata12">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:title>Group</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.2 KiB |
138
packages/nodes-base/credentials/jwtAuth.credentials.ts
Normal file
138
packages/nodes-base/credentials/jwtAuth.credentials.ts
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import type { ICredentialType, INodeProperties, INodePropertyOptions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
const algorithms: INodePropertyOptions[] = [
|
||||||
|
{
|
||||||
|
name: 'HS256',
|
||||||
|
value: 'HS256',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HS384',
|
||||||
|
value: 'HS384',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HS512',
|
||||||
|
value: 'HS512',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'RS256',
|
||||||
|
value: 'RS256',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'RS384',
|
||||||
|
value: 'RS384',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'RS512',
|
||||||
|
value: 'RS512',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ES256',
|
||||||
|
value: 'ES256',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ES384',
|
||||||
|
value: 'ES384',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ES512',
|
||||||
|
value: 'ES512',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PS256',
|
||||||
|
value: 'PS256',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PS384',
|
||||||
|
value: 'PS384',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PS512',
|
||||||
|
value: 'PS512',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'none',
|
||||||
|
value: 'none',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/cred-class-name-unsuffixed
|
||||||
|
export class jwtAuth implements ICredentialType {
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/cred-class-field-name-unsuffixed
|
||||||
|
name = 'jwtAuth';
|
||||||
|
|
||||||
|
displayName = 'JWT Auth';
|
||||||
|
|
||||||
|
documentationUrl = 'jwtAuth';
|
||||||
|
|
||||||
|
icon = 'file:icons/jwt.svg';
|
||||||
|
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Key Type',
|
||||||
|
name: 'keyType',
|
||||||
|
type: 'options',
|
||||||
|
description:
|
||||||
|
'Choose either the secret passphrase for PEM encoded public keys for RSA and ECDSA',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Passphrase',
|
||||||
|
value: 'passphrase',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PEM Key',
|
||||||
|
value: 'pemKey',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'passphrase',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Secret',
|
||||||
|
name: 'secret',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
keyType: ['passphrase'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Private Key',
|
||||||
|
name: 'privateKey',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
keyType: ['pemKey'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Public Key',
|
||||||
|
name: 'publicKey',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
keyType: ['pemKey'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Algorithm',
|
||||||
|
name: 'algorithm',
|
||||||
|
type: 'options',
|
||||||
|
default: 'HS256',
|
||||||
|
options: algorithms,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
|
@ -10,6 +10,8 @@ import type {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { jsonParse, BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
|
import { jsonParse, BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
|
||||||
import set from 'lodash/set';
|
import set from 'lodash/set';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { formatPrivateKey } from '../../utils/utilities';
|
||||||
|
|
||||||
export class RespondToWebhook implements INodeType {
|
export class RespondToWebhook implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -17,14 +19,24 @@ export class RespondToWebhook implements INodeType {
|
||||||
icon: 'file:webhook.svg',
|
icon: 'file:webhook.svg',
|
||||||
name: 'respondToWebhook',
|
name: 'respondToWebhook',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: 1,
|
version: [1, 1.1],
|
||||||
description: 'Returns data for Webhook',
|
description: 'Returns data for Webhook',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Respond to Webhook',
|
name: 'Respond to Webhook',
|
||||||
},
|
},
|
||||||
inputs: ['main'],
|
inputs: ['main'],
|
||||||
outputs: ['main'],
|
outputs: ['main'],
|
||||||
credentials: [],
|
credentials: [
|
||||||
|
{
|
||||||
|
name: 'jwtAuth',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
respondWith: ['jwt'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
displayName:
|
displayName:
|
||||||
|
@ -58,6 +70,11 @@ export class RespondToWebhook implements INodeType {
|
||||||
value: 'json',
|
value: 'json',
|
||||||
description: 'Respond with a custom JSON body',
|
description: 'Respond with a custom JSON body',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'JWT Token',
|
||||||
|
value: 'jwt',
|
||||||
|
description: 'Respond with a JWT token',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'No Data',
|
name: 'No Data',
|
||||||
value: 'noData',
|
value: 'noData',
|
||||||
|
@ -77,6 +94,17 @@ export class RespondToWebhook implements INodeType {
|
||||||
default: 'firstIncomingItem',
|
default: 'firstIncomingItem',
|
||||||
description: 'The data that should be returned',
|
description: 'The data that should be returned',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Credentials',
|
||||||
|
name: 'credentials',
|
||||||
|
type: 'credentials',
|
||||||
|
default: '',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
respondWith: ['jwt'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName:
|
displayName:
|
||||||
'When using expressions, note that this node will only run for the first item in the input data',
|
'When using expressions, note that this node will only run for the first item in the input data',
|
||||||
|
@ -84,7 +112,7 @@ export class RespondToWebhook implements INodeType {
|
||||||
type: 'notice',
|
type: 'notice',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
respondWith: ['json', 'text'],
|
respondWith: ['json', 'text', 'jwt'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: '',
|
default: '',
|
||||||
|
@ -119,6 +147,22 @@ export class RespondToWebhook implements INodeType {
|
||||||
},
|
},
|
||||||
description: 'The HTTP response JSON data',
|
description: 'The HTTP response JSON data',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Payload',
|
||||||
|
name: 'payload',
|
||||||
|
type: 'json',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
respondWith: ['jwt'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '{\n "myField": "value"\n}',
|
||||||
|
typeOptions: {
|
||||||
|
rows: 4,
|
||||||
|
},
|
||||||
|
validateType: 'object',
|
||||||
|
description: 'The payload to include in the JWT token',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Response Body',
|
displayName: 'Response Body',
|
||||||
name: 'responseBody',
|
name: 'responseBody',
|
||||||
|
@ -243,6 +287,21 @@ export class RespondToWebhook implements INodeType {
|
||||||
};
|
};
|
||||||
|
|
||||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
|
const nodeVersion = this.getNode().typeVersion;
|
||||||
|
|
||||||
|
if (nodeVersion >= 1.1) {
|
||||||
|
const connectedNodes = this.getParentNodes(this.getNode().name);
|
||||||
|
if (!connectedNodes.some((node) => node.type === 'n8n-nodes-base.webhook')) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.getNode(),
|
||||||
|
new Error('No Webhook node found in the workflow'),
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Insert a Webhook node to your workflow and set the “Respond” parameter to “Using Respond to Webhook Node” ',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const items = this.getInputData();
|
const items = this.getInputData();
|
||||||
|
|
||||||
const respondWith = this.getNodeParameter('respondWith', 0) as string;
|
const respondWith = this.getNodeParameter('respondWith', 0) as string;
|
||||||
|
@ -277,6 +336,32 @@ export class RespondToWebhook implements INodeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (respondWith === 'jwt') {
|
||||||
|
try {
|
||||||
|
const { keyType, secret, algorithm, privateKey } = (await this.getCredentials(
|
||||||
|
'jwtAuth',
|
||||||
|
)) as {
|
||||||
|
keyType: 'passphrase' | 'pemKey';
|
||||||
|
privateKey: string;
|
||||||
|
secret: string;
|
||||||
|
algorithm: jwt.Algorithm;
|
||||||
|
};
|
||||||
|
|
||||||
|
let secretOrPrivateKey;
|
||||||
|
|
||||||
|
if (keyType === 'passphrase') {
|
||||||
|
secretOrPrivateKey = secret;
|
||||||
|
} else {
|
||||||
|
secretOrPrivateKey = formatPrivateKey(privateKey);
|
||||||
|
}
|
||||||
|
const payload = this.getNodeParameter('payload', 0, {}) as IDataObject;
|
||||||
|
const token = jwt.sign(payload, secretOrPrivateKey, { algorithm });
|
||||||
|
responseBody = { token };
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeOperationError(this.getNode(), error as Error, {
|
||||||
|
message: 'Error signing JWT token',
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (respondWith === 'allIncomingItems') {
|
} else if (respondWith === 'allIncomingItems') {
|
||||||
const respondItems = items.map((item) => item.json);
|
const respondItems = items.map((item) => item.json);
|
||||||
responseBody = options.responseKey
|
responseBody = options.responseKey
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
IWebhookResponseData,
|
IWebhookResponseData,
|
||||||
MultiPartFormData,
|
MultiPartFormData,
|
||||||
|
INodeProperties,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
|
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -17,6 +18,7 @@ import { v4 as uuid } from 'uuid';
|
||||||
import basicAuth from 'basic-auth';
|
import basicAuth from 'basic-auth';
|
||||||
import isbot from 'isbot';
|
import isbot from 'isbot';
|
||||||
import { file as tmpFile } from 'tmp-promise';
|
import { file as tmpFile } from 'tmp-promise';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authenticationProperty,
|
authenticationProperty,
|
||||||
|
@ -25,11 +27,19 @@ import {
|
||||||
httpMethodsProperty,
|
httpMethodsProperty,
|
||||||
optionsProperty,
|
optionsProperty,
|
||||||
responseBinaryPropertyNameProperty,
|
responseBinaryPropertyNameProperty,
|
||||||
|
responseCodeOption,
|
||||||
responseCodeProperty,
|
responseCodeProperty,
|
||||||
responseDataProperty,
|
responseDataProperty,
|
||||||
responseModeProperty,
|
responseModeProperty,
|
||||||
} from './description';
|
} from './description';
|
||||||
import { WebhookAuthorizationError } from './error';
|
import { WebhookAuthorizationError } from './error';
|
||||||
|
import {
|
||||||
|
checkResponseModeConfiguration,
|
||||||
|
configuredOutputs,
|
||||||
|
isIpWhitelisted,
|
||||||
|
setupOutputConnection,
|
||||||
|
} from './utils';
|
||||||
|
import { formatPrivateKey } from '../../utils/utilities';
|
||||||
|
|
||||||
export class Webhook extends Node {
|
export class Webhook extends Node {
|
||||||
authPropertyName = 'authentication';
|
authPropertyName = 'authentication';
|
||||||
|
@ -39,7 +49,7 @@ export class Webhook extends Node {
|
||||||
icon: 'file:webhook.svg',
|
icon: 'file:webhook.svg',
|
||||||
name: 'webhook',
|
name: 'webhook',
|
||||||
group: ['trigger'],
|
group: ['trigger'],
|
||||||
version: [1, 1.1],
|
version: [1, 1.1, 2],
|
||||||
description: 'Starts the workflow when a webhook is called',
|
description: 'Starts the workflow when a webhook is called',
|
||||||
eventTriggerDescription: 'Waiting for you to call the Test URL',
|
eventTriggerDescription: 'Waiting for you to call the Test URL',
|
||||||
activationMessage: 'You can now make calls to your production webhook URL.',
|
activationMessage: 'You can now make calls to your production webhook URL.',
|
||||||
|
@ -56,15 +66,14 @@ export class Webhook extends Node {
|
||||||
'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.',
|
'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:
|
activationHint:
|
||||||
'Once you’ve finished building your workflow, run it without having to click this button by using the production webhook URL.',
|
"Once you've finished building your workflow, run it without having to click this button by using the production webhook URL.",
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: ['main'],
|
outputs: `={{(${configuredOutputs})($parameter)}}`,
|
||||||
credentials: credentialsProperty(this.authPropertyName),
|
credentials: credentialsProperty(this.authPropertyName),
|
||||||
webhooks: [defaultWebhookDescription],
|
webhooks: [defaultWebhookDescription],
|
||||||
properties: [
|
properties: [
|
||||||
authenticationProperty(this.authPropertyName),
|
|
||||||
httpMethodsProperty,
|
httpMethodsProperty,
|
||||||
{
|
{
|
||||||
displayName: 'Path',
|
displayName: 'Path',
|
||||||
|
@ -73,8 +82,10 @@ export class Webhook extends Node {
|
||||||
default: '',
|
default: '',
|
||||||
placeholder: 'webhook',
|
placeholder: 'webhook',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'The path to listen to',
|
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,
|
responseModeProperty,
|
||||||
{
|
{
|
||||||
displayName:
|
displayName:
|
||||||
|
@ -88,27 +99,63 @@ export class Webhook extends Node {
|
||||||
},
|
},
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
responseCodeProperty,
|
{
|
||||||
|
...responseCodeProperty,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'@version': [1, 1.1],
|
||||||
|
},
|
||||||
|
hide: {
|
||||||
|
responseMode: ['responseNode'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
responseDataProperty,
|
responseDataProperty,
|
||||||
responseBinaryPropertyNameProperty,
|
responseBinaryPropertyNameProperty,
|
||||||
optionsProperty,
|
|
||||||
|
{
|
||||||
|
...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> {
|
async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||||
|
const { typeVersion: nodeVersion, type: nodeType } = context.getNode();
|
||||||
|
|
||||||
|
if (nodeVersion >= 2 && nodeType === 'n8n-nodes-base.webhook') {
|
||||||
|
checkResponseModeConfiguration(context);
|
||||||
|
}
|
||||||
|
|
||||||
const options = context.getNodeParameter('options', {}) as {
|
const options = context.getNodeParameter('options', {}) as {
|
||||||
binaryData: boolean;
|
binaryData: boolean;
|
||||||
ignoreBots: boolean;
|
ignoreBots: boolean;
|
||||||
rawBody: boolean;
|
rawBody: boolean;
|
||||||
responseData?: string;
|
responseData?: string;
|
||||||
|
ipWhitelist?: string;
|
||||||
};
|
};
|
||||||
const req = context.getRequestObject();
|
const req = context.getRequestObject();
|
||||||
const resp = context.getResponseObject();
|
const resp = context.getResponseObject();
|
||||||
|
|
||||||
|
if (!isIpWhitelisted(options.ipWhitelist, req.ips, req.ip)) {
|
||||||
|
resp.writeHead(403);
|
||||||
|
resp.end('IP is not whitelisted to access the webhook!');
|
||||||
|
return { noWebhookResponse: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let validationData: IDataObject | undefined;
|
||||||
try {
|
try {
|
||||||
if (options.ignoreBots && isbot(req.headers['user-agent']))
|
if (options.ignoreBots && isbot(req.headers['user-agent']))
|
||||||
throw new WebhookAuthorizationError(403);
|
throw new WebhookAuthorizationError(403);
|
||||||
await this.validateAuth(context);
|
validationData = await this.validateAuth(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WebhookAuthorizationError) {
|
if (error instanceof WebhookAuthorizationError) {
|
||||||
resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
|
resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
|
||||||
|
@ -118,18 +165,21 @@ export class Webhook extends Node {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prepareOutput = setupOutputConnection(context, {
|
||||||
|
jwtPayload: validationData,
|
||||||
|
});
|
||||||
|
|
||||||
if (options.binaryData) {
|
if (options.binaryData) {
|
||||||
return await this.handleBinaryData(context);
|
return await this.handleBinaryData(context, prepareOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.contentType === 'multipart/form-data') {
|
if (req.contentType === 'multipart/form-data') {
|
||||||
return await this.handleFormData(context);
|
return await this.handleFormData(context, prepareOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeVersion = context.getNode().typeVersion;
|
|
||||||
if (nodeVersion > 1 && !req.body && !options.rawBody) {
|
if (nodeVersion > 1 && !req.body && !options.rawBody) {
|
||||||
try {
|
try {
|
||||||
return await this.handleBinaryData(context);
|
return await this.handleBinaryData(context, prepareOutput);
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,7 +206,7 @@ export class Webhook extends Node {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
webhookResponse: options.responseData,
|
webhookResponse: options.responseData,
|
||||||
workflowData: [[response]],
|
workflowData: prepareOutput(response),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,10 +258,52 @@ export class Webhook extends Node {
|
||||||
// Provided authentication data is wrong
|
// Provided authentication data is wrong
|
||||||
throw new WebhookAuthorizationError(403);
|
throw new WebhookAuthorizationError(403);
|
||||||
}
|
}
|
||||||
|
} else if (authentication === 'jwtAuth') {
|
||||||
|
let expectedAuth;
|
||||||
|
|
||||||
|
try {
|
||||||
|
expectedAuth = (await context.getCredentials('jwtAuth')) as {
|
||||||
|
keyType: 'passphrase' | 'pemKey';
|
||||||
|
publicKey: string;
|
||||||
|
secret: string;
|
||||||
|
algorithm: jwt.Algorithm;
|
||||||
|
};
|
||||||
|
} 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 && 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, secretOrPublicKey, {
|
||||||
|
algorithms: [expectedAuth.algorithm],
|
||||||
|
}) as IDataObject;
|
||||||
|
} catch (error) {
|
||||||
|
throw new WebhookAuthorizationError(403, error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFormData(context: IWebhookFunctions) {
|
private async handleFormData(
|
||||||
|
context: IWebhookFunctions,
|
||||||
|
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
|
||||||
|
) {
|
||||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
const options = context.getNodeParameter('options', {}) as IDataObject;
|
||||||
const { data, files } = req.body;
|
const { data, files } = req.body;
|
||||||
|
@ -264,10 +356,13 @@ export class Webhook extends Node {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { workflowData: [[returnItem]] };
|
return { workflowData: prepareOutput(returnItem) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleBinaryData(context: IWebhookFunctions): Promise<IWebhookResponseData> {
|
private async handleBinaryData(
|
||||||
|
context: IWebhookFunctions,
|
||||||
|
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
|
||||||
|
): Promise<IWebhookResponseData> {
|
||||||
const req = context.getRequestObject();
|
const req = context.getRequestObject();
|
||||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
const options = context.getNodeParameter('options', {}) as IDataObject;
|
||||||
|
|
||||||
|
@ -298,7 +393,7 @@ export class Webhook extends Node {
|
||||||
returnItem.binary = { [binaryPropertyName]: binaryData };
|
returnItem.binary = { [binaryPropertyName]: binaryData };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { workflowData: [[returnItem]] };
|
return { workflowData: prepareOutput(returnItem) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new NodeOperationError(context.getNode(), error as Error);
|
throw new NodeOperationError(context.getNode(), error as Error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
|
import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
|
||||||
|
import { getResponseCode, getResponseData } from './utils';
|
||||||
|
|
||||||
export const defaultWebhookDescription: IWebhookDescription = {
|
export const defaultWebhookDescription: IWebhookDescription = {
|
||||||
name: 'default',
|
name: 'default',
|
||||||
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
|
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
|
||||||
isFullPath: true,
|
isFullPath: true,
|
||||||
responseCode: '={{$parameter["responseCode"]}}',
|
responseCode: `={{(${getResponseCode})($parameter)}}`,
|
||||||
responseMode: '={{$parameter["responseMode"]}}',
|
responseMode: '={{$parameter["responseMode"]}}',
|
||||||
responseData:
|
responseData: `={{(${getResponseData})($parameter)}}`,
|
||||||
'={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}',
|
|
||||||
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
|
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
|
||||||
responseContentType: '={{$parameter["options"]["responseContentType"]}}',
|
responseContentType: '={{$parameter["options"]["responseContentType"]}}',
|
||||||
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
|
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
|
||||||
|
@ -36,6 +36,15 @@ export const credentialsProperty = (
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'jwtAuth',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
[propertyName]: ['jwtAuth'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const authenticationProperty = (propertyName = 'authentication'): INodeProperties => ({
|
export const authenticationProperty = (propertyName = 'authentication'): INodeProperties => ({
|
||||||
|
@ -51,6 +60,10 @@ export const authenticationProperty = (propertyName = 'authentication'): INodePr
|
||||||
name: 'Header Auth',
|
name: 'Header Auth',
|
||||||
value: 'headerAuth',
|
value: 'headerAuth',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'JWT Auth',
|
||||||
|
value: 'jwtAuth',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'None',
|
name: 'None',
|
||||||
value: 'none',
|
value: 'none',
|
||||||
|
@ -243,6 +256,14 @@ export const optionsProperty: INodeProperties = {
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
|
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'IP(s) Whitelist',
|
||||||
|
name: 'ipWhitelist',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'e.g. 127.0.0.1',
|
||||||
|
default: '',
|
||||||
|
description: 'Comma-separated list of allowed IP addresses. Leave empty to allow all IPs.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'No Response Body',
|
displayName: 'No Response Body',
|
||||||
name: 'noResponseBody',
|
name: 'noResponseBody',
|
||||||
|
@ -368,3 +389,80 @@ export const optionsProperty: INodeProperties = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -15,6 +15,10 @@ describe('Test Webhook Node', () => {
|
||||||
nodeHelpers: mock(),
|
nodeHelpers: mock(),
|
||||||
});
|
});
|
||||||
context.getNodeParameter.calledWith('options').mockReturnValue({});
|
context.getNodeParameter.calledWith('options').mockReturnValue({});
|
||||||
|
context.getNode.calledWith().mockReturnValue({
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
} as any);
|
||||||
const req = mock<Request>();
|
const req = mock<Request>();
|
||||||
req.contentType = 'multipart/form-data';
|
req.contentType = 'multipart/form-data';
|
||||||
context.getRequestObject.mockReturnValue(req);
|
context.getRequestObject.mockReturnValue(req);
|
||||||
|
|
141
packages/nodes-base/nodes/Webhook/utils.ts
Normal file
141
packages/nodes-base/nodes/Webhook/utils.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
|
import type { IWebhookFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
|
type WebhookParameters = {
|
||||||
|
httpMethod: 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;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: `${NodeConnectionType.Main}`,
|
||||||
|
displayName: httpMethod,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupOutputConnection = (
|
||||||
|
ctx: IWebhookFunctions,
|
||||||
|
additionalData: {
|
||||||
|
jwtPayload?: IDataObject;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
let webhookUrl = ctx.getNodeWebhookUrl('default') as string;
|
||||||
|
const executionMode = ctx.getMode() === 'manual' ? 'test' : 'production';
|
||||||
|
|
||||||
|
if (executionMode === 'test') {
|
||||||
|
webhookUrl = webhookUrl.replace('/webhook/', '/webhook-test/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (outputData: INodeExecutionData): INodeExecutionData[][] => {
|
||||||
|
outputData.json.webhookUrl = webhookUrl;
|
||||||
|
outputData.json.executionMode = executionMode;
|
||||||
|
if (additionalData?.jwtPayload) {
|
||||||
|
outputData.json.jwtPayload = additionalData.jwtPayload;
|
||||||
|
}
|
||||||
|
return [[outputData]];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isIpWhitelisted = (
|
||||||
|
whitelist: string | string[] | undefined,
|
||||||
|
ips: string[],
|
||||||
|
ip?: string,
|
||||||
|
) => {
|
||||||
|
if (whitelist === undefined || whitelist === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(whitelist)) {
|
||||||
|
whitelist = whitelist.split(',').map((entry) => entry.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of whitelist) {
|
||||||
|
if (ip && ip.includes(address)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ips.some((entry) => entry.includes(address))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 NodeOperationError(
|
||||||
|
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 && responseMode !== 'responseNode') {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
context.getNode(),
|
||||||
|
new Error('Webhook node not correctly configured'),
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Set the “Respond” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
|
@ -184,6 +184,7 @@
|
||||||
"dist/credentials/JiraSoftwareCloudApi.credentials.js",
|
"dist/credentials/JiraSoftwareCloudApi.credentials.js",
|
||||||
"dist/credentials/JiraSoftwareServerApi.credentials.js",
|
"dist/credentials/JiraSoftwareServerApi.credentials.js",
|
||||||
"dist/credentials/JotFormApi.credentials.js",
|
"dist/credentials/JotFormApi.credentials.js",
|
||||||
|
"dist/credentials/jwtAuth.credentials.js",
|
||||||
"dist/credentials/Kafka.credentials.js",
|
"dist/credentials/Kafka.credentials.js",
|
||||||
"dist/credentials/KeapOAuth2Api.credentials.js",
|
"dist/credentials/KeapOAuth2Api.credentials.js",
|
||||||
"dist/credentials/KibanaApi.credentials.js",
|
"dist/credentials/KibanaApi.credentials.js",
|
||||||
|
|
|
@ -813,6 +813,12 @@ export interface RequestHelperFunctions {
|
||||||
): Promise<any>;
|
): Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NodeTypeAndVersion = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
typeVersion: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FunctionsBase {
|
export interface FunctionsBase {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
getCredentials(type: string, itemIndex?: number): Promise<ICredentialDataDecryptedObject>;
|
getCredentials(type: string, itemIndex?: number): Promise<ICredentialDataDecryptedObject>;
|
||||||
|
@ -824,7 +830,8 @@ export interface FunctionsBase {
|
||||||
getRestApiUrl(): string;
|
getRestApiUrl(): string;
|
||||||
getInstanceBaseUrl(): string;
|
getInstanceBaseUrl(): string;
|
||||||
getInstanceId(): string;
|
getInstanceId(): string;
|
||||||
|
getChildNodes(nodeName: string): NodeTypeAndVersion[];
|
||||||
|
getParentNodes(nodeName: string): NodeTypeAndVersion[];
|
||||||
getMode?: () => WorkflowExecuteMode;
|
getMode?: () => WorkflowExecuteMode;
|
||||||
getActivationMode?: () => WorkflowActivateMode;
|
getActivationMode?: () => WorkflowActivateMode;
|
||||||
|
|
||||||
|
|
|
@ -264,7 +264,8 @@ const commonCORSParameters: INodeProperties[] = [
|
||||||
name: 'allowedOrigins',
|
name: 'allowedOrigins',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
default: '*',
|
default: '*',
|
||||||
description: 'The origin(s) to allow cross-origin non-preflight requests from in a browser',
|
description:
|
||||||
|
'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -278,7 +279,11 @@ export function applySpecialNodeParameters(nodeType: INodeType): void {
|
||||||
}
|
}
|
||||||
if (nodeType.webhook && supportsCORS) {
|
if (nodeType.webhook && supportsCORS) {
|
||||||
const optionsProperty = properties.find(({ name }) => name === 'options');
|
const optionsProperty = properties.find(({ name }) => name === 'options');
|
||||||
if (optionsProperty) optionsProperty.options!.push(...commonCORSParameters);
|
if (optionsProperty)
|
||||||
|
optionsProperty.options = [
|
||||||
|
...commonCORSParameters,
|
||||||
|
...(optionsProperty.options as INodePropertyOptions[]),
|
||||||
|
];
|
||||||
else properties.push(...commonCORSParameters);
|
else properties.push(...commonCORSParameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -533,7 +538,7 @@ export function getParameterResolveOrder(
|
||||||
parameterDependencies: IParameterDependencies,
|
parameterDependencies: IParameterDependencies,
|
||||||
): number[] {
|
): number[] {
|
||||||
const executionOrder: number[] = [];
|
const executionOrder: number[] = [];
|
||||||
const indexToResolve = Array.from({ length: nodePropertiesArray.length }, (v, k) => k);
|
const indexToResolve = Array.from({ length: nodePropertiesArray.length }, (_, k) => k);
|
||||||
const resolvedParameters: string[] = [];
|
const resolvedParameters: string[] = [];
|
||||||
|
|
||||||
let index: number;
|
let index: number;
|
||||||
|
|
|
@ -1332,6 +1332,12 @@ export class Workflow {
|
||||||
// The node did already fail. So throw an error here that it displays and logs it correctly.
|
// The node did already fail. So throw an error here that it displays and logs it correctly.
|
||||||
// Does get used by webhook and trigger nodes in case they throw an error that it is possible
|
// Does get used by webhook and trigger nodes in case they throw an error that it is possible
|
||||||
// to log the error and display in Editor-UI.
|
// to log the error and display in Editor-UI.
|
||||||
|
if (
|
||||||
|
runExecutionData.resultData.error.name === 'NodeOperationError' ||
|
||||||
|
runExecutionData.resultData.error.name === 'NodeApiError'
|
||||||
|
) {
|
||||||
|
throw runExecutionData.resultData.error;
|
||||||
|
}
|
||||||
|
|
||||||
const error = new Error(runExecutionData.resultData.error.message);
|
const error = new Error(runExecutionData.resultData.error.message);
|
||||||
error.stack = runExecutionData.resultData.error.stack;
|
error.stack = runExecutionData.resultData.error.stack;
|
||||||
|
|
Loading…
Reference in a new issue