feat(n8n Form Trigger Node): Improvements (#10092)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
Michael Kret 2024-07-29 15:58:03 +03:00 committed by GitHub
parent 7a30d845e9
commit 711b667ebe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1015 additions and 147 deletions

View file

@ -59,6 +59,9 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.n8n-link {
padding-bottom: 24px;
}
.n8n-link a { .n8n-link a {
color: #7e8186; color: #7e8186;
font-weight: 600; font-weight: 600;
@ -103,11 +106,12 @@
border-radius: 6px; border-radius: 6px;
width: 100%; width: 100%;
font-size: 14px; font-size: 14px;
color: #909399; color: #71747A;
font-weight: 400; font-weight: 400;
padding: 12px; padding: 12px;
} }
form textarea:focus,
form input:focus { form input:focus {
outline: none; outline: none;
border-color: rgb(90, 76, 194); border-color: rgb(90, 76, 194);
@ -128,7 +132,7 @@
border-radius: 6px; border-radius: 6px;
width: 100%; width: 100%;
font-size: 14px; font-size: 14px;
color: #909399; color: #71747A;
font-weight: 400; font-weight: 400;
background-color: white; background-color: white;
padding: 12px; padding: 12px;
@ -141,6 +145,10 @@
sans-serif; sans-serif;
} }
::placeholder {
opacity: 0.5;
}
#submit-btn { #submit-btn {
width: 100%; width: 100%;
height: 48px; height: 48px;
@ -225,9 +233,77 @@
height: 18px; height: 18px;
cursor: pointer; cursor: pointer;
} }
/* required field ----------------------------- */
.form-required { .form-required {
} }
label.form-required::after {
content: ' *';
color: #ff6d5a;
}
hr {
border: 0;
height: 1px;
border-top: 1px solid #dbdfe7;
margin-top: 24px;
margin-bottom: 24px;
display: none;
}
.file-input-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
input[type="file"] {
}
.clear-button {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-65%);
background-color: #7e8186;
border: none;
border-radius: 50%;
font-size: 14px;
font-weight: 600;
font-family:
Open Sans,
sans-serif;
color: white;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
cursor: pointer;
display: none;
}
input[type="file"]:not(:empty) + .clear-button {
display: inline-block;
}
@media only screen and (max-width: 400px) {
hr {
display: block;
}
.container {
width: 95%;
min-height: 100vh;
padding: 24px;
background-color: white;
border: 1px solid #dbdfe7;
border-radius: 8px;
box-shadow: 0px 4px 16px 0px #634dff0f;
}
.card {
padding: 0px;
background-color: white;
border: 0px solid #dbdfe7;
border-radius: 0px;
box-shadow: 0px 0px 10px 0px #634dff0f;
margin-bottom: 0px;
}
}
</style> </style>
</head> </head>
@ -238,20 +314,23 @@
<div class='test-notice'> <div class='test-notice'>
<p>This is test version of your form. Use it only for testing your Form Trigger.</p> <p>This is test version of your form. Use it only for testing your Form Trigger.</p>
</div> </div>
<hr>
{{/if}} {{/if}}
{{#if validForm}} {{#if validForm}}
<form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate> <form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate>
<div class='form-header'> <div class='form-header'>
<h1>{{formTitle}}</h1> <h1>{{formTitle}}</h1>
<p>{{formDescription}} </p> <p style="white-space: pre-line">{{formDescription}} </p>
</div> </div>
<div class='inputs-wrapper'> <div class='inputs-wrapper'>
{{#each formFields}} {{#each formFields}}
{{#if isMultiSelect}} {{#if isMultiSelect}}
<div> <div>
<label class='form-label'>{{label}}</label> <label class='form-label {{inputRequired}}'>{{label}}</label>
<div class='multiselect {{inputRequired}}' id='{{id}}'> <div class='multiselect {{inputRequired}}' id='{{id}}'>
{{#each multiSelectOptions}} {{#each multiSelectOptions}}
<div class='multiselect-option'> <div class='multiselect-option'>
@ -268,7 +347,7 @@
{{#if isSelect}} {{#if isSelect}}
<div class='form-group'> <div class='form-group'>
<label class='form-label' for='{{id}}'>{{label}}</label> <label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
<div class='select-input'> <div class='select-input'>
<select id='{{id}}' name='{{id}}' class='{{inputRequired}}'> <select id='{{id}}' name='{{id}}' class='{{inputRequired}}'>
<option value='' disabled selected>Select an option ...</option> <option value='' disabled selected>Select an option ...</option>
@ -285,12 +364,32 @@
{{#if isTextarea}} {{#if isTextarea}}
<div class='form-group'> <div class='form-group'>
<label class='form-label' for='{{id}}'>{{label}}</label> <label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
<textarea <textarea
class='form-input {{inputRequired}}' class='form-input {{inputRequired}}'
id='{{id}}' id='{{id}}'
name='{{id}}' name='{{id}}'
></textarea> placeholder="{{placeholder}}"
>{{defaultValue}}</textarea>
<p class='{{errorId}} error-hidden'>
This field is required
</p>
</div>
{{/if}}
{{#if isFileInput}}
<div class='form-group file-input-wrapper'>
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
<input
class='form-input {{inputRequired}}'
type='file'
id='{{id}}'
name='{{id}}'
accept='{{acceptFileTypes}}'
{{multipleFiles}}
placeholder="{{placeholder}}"
/>
<button class="clear-button">&times;</button>
<p class='{{errorId}} error-hidden'> <p class='{{errorId}} error-hidden'>
This field is required This field is required
</p> </p>
@ -299,12 +398,14 @@
{{#if isInput}} {{#if isInput}}
<div class='form-group'> <div class='form-group'>
<label class='form-label' for='{{id}}'>{{label}}</label> <label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
<input <input
class='form-input {{inputRequired}}' class='form-input {{inputRequired}}'
type='{{type}}' type='{{type}}'
id='{{id}}' id='{{id}}'
name='{{id}}' name='{{id}}'
value="{{defaultValue}}"
placeholder="{{placeholder}}"
/> />
<p class='{{errorId}} error-hidden'> <p class='{{errorId}} error-hidden'>
This field is required This field is required
@ -355,9 +456,13 @@
</div> </div>
{{#if appendAttribution}} {{#if appendAttribution}}
<hr>
<div class='n8n-link'> <div class='n8n-link'>
<a href={{n8nWebsiteLink}} target='_blank'> <a href={{n8nWebsiteLink}} target='_blank'>
Form automated with Form automated with
{{#if customAttribution}}
{{{customAttribution}}}
{{else}}
<svg <svg
width='73' width='73'
height='20' height='20'
@ -384,10 +489,13 @@
fill='#101330' fill='#101330'
/> />
</svg> </svg>
{{/if}}
</a> </a>
</div> </div>
{{/if}} {{/if}}
{{#if redirectUrl}} {{#if redirectUrl}}
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a> <a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
{{/if}} {{/if}}
@ -396,10 +504,13 @@
</div> </div>
<script> <script>
function validateInput(input, errorElement) { function validateInput(input, errorElement) {
if (input.type === 'number' && input.value !== '') { const value = input.value.trim();
const value = input.value.trim(); const type = input.type;
if (value === '' || isNaN(value)) { if (type === 'email' && value !== '') {
return validateEmailInput(value, errorElement);
} else if (type === 'number' && value !== '') {
if (isNaN(value)) {
errorElement.textContent = 'Enter only numbers in this field'; errorElement.textContent = 'Enter only numbers in this field';
errorElement.classList.add('error-show'); errorElement.classList.add('error-show');
return false; return false;
@ -407,7 +518,8 @@
errorElement.classList.remove('error-show'); errorElement.classList.remove('error-show');
return true; return true;
} }
} else if (input.value === '') { } else if (value === '') {
errorElement.textContent = 'This field is required';
errorElement.classList.add('error-show'); errorElement.classList.add('error-show');
return false; return false;
} else { } else {
@ -416,6 +528,21 @@
} }
} }
function validateEmailInput(value, errorElement) {
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const isValidEmail = regex.test(value);
if (!isValidEmail) {
errorElement.textContent = 'Enter a valid email address in this field';
errorElement.classList.add('error-show');
return false;
} else {
errorElement.textContent = 'This field is required';
errorElement.classList.remove('error-show');
return true;
}
}
function getSelectedValues(input) { function getSelectedValues(input) {
const selectedValues = []; const selectedValues = [];
const checkboxes = input.querySelectorAll('.multiselect-checkbox'); const checkboxes = input.querySelectorAll('.multiselect-checkbox');
@ -444,7 +571,44 @@
const form = document.querySelector('#n8n-form'); const form = document.querySelector('#n8n-form');
const requiredInputs = document.querySelectorAll('.form-required'); document.querySelectorAll("input[type=number]").forEach(function (element) {
element.addEventListener("wheel", function(event) {
if (document.activeElement === event.target) {
event.preventDefault();
}
});
});
document.querySelectorAll('input[type="file"]').forEach(fileInput => {
const clearButton = fileInput.nextElementSibling;
let previousFiles = [];
fileInput.addEventListener('change', () => {
const files = fileInput.files;
if (files.length > 0) {
previousFiles = Array.from(files);
clearButton.style.display = 'inline-block';
} else {
if (previousFiles.length > 0) {
const dataTransfer = new DataTransfer();
previousFiles.forEach(file => dataTransfer.items.add(file));
fileInput.files = dataTransfer.files;
clearButton.style.display = 'inline-block';
}
}
});
clearButton.addEventListener('click', (event) => {
event.preventDefault();
fileInput.value = '';
previousFiles = [];
clearButton.style.display = 'none';
});
});
const requiredInputs = document.querySelectorAll('.form-required:not(label)');
const emailInputs = document.querySelectorAll("input[type=email]");
requiredInputs.forEach((input) => { requiredInputs.forEach((input) => {
const errorSelector = `.error-${input.id}`; const errorSelector = `.error-${input.id}`;
@ -464,10 +628,34 @@
} }
}); });
emailInputs.forEach(function (input) {
const errorSelector = `.error-${input.id}`;
const error = document.querySelector(errorSelector);
input.addEventListener("input", function(event) {
const value = input.value.trim();
if (value === "") {
error.classList.remove('error-show');
} else {
validateEmailInput(value, error);
}
});
});
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
const valid = []; const valid = [];
e.preventDefault(); e.preventDefault();
emailInputs.forEach(function (input) {
const value = input.value.trim();
if(value === '') {
return;
}
const errorSelector = `.error-${input.id}`;
const error = document.querySelector(errorSelector);
valid.push(validateEmailInput(value, error));
});
requiredInputs.forEach((input) => { requiredInputs.forEach((input) => {
const errorSelector = `.error-${input.id}`; const errorSelector = `.error-${input.id}`;
const error = document.querySelector(errorSelector); const error = document.querySelector(errorSelector);
@ -480,7 +668,20 @@
}); });
if (valid.every((v) => v)) { if (valid.every((v) => v)) {
var formData = new FormData(form); var formData = new FormData();
for (const filed of form.elements) {
if(filed.type !== 'file') {
formData.append(filed.name, filed.value);
} else {
for (const file of filed.files) {
if(file.size === 0) {
continue;
}
formData.append(filed.name, file);
}
}
}
document.querySelectorAll('.multiselect').forEach((multiselect) => { document.querySelectorAll('.multiselect').forEach((multiselect) => {
const selectedValues = getSelectedValues(multiselect); const selectedValues = getSelectedValues(multiselect);

View file

@ -310,7 +310,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
testUrl = `${rootStore.formWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`; testUrl = `${rootStore.formWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
} }
if (testUrl) openPopUpWindow(testUrl); if (testUrl && options.source !== 'RunData.ManualChatMessage') openPopUpWindow(testUrl);
} }
} }

View file

@ -648,6 +648,7 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [
WAIT_NODE_TYPE, WAIT_NODE_TYPE,
DISCORD_NODE_TYPE, DISCORD_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
]; ];
export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource'; export const NODE_RESOURCE_FIELD_NAME = 'resource';

View file

@ -11,12 +11,13 @@ export class FormTrigger extends VersionedNodeType {
icon: 'file:form.svg', icon: 'file:form.svg',
group: ['trigger'], group: ['trigger'],
description: 'Runs the flow when an n8n generated webform is submitted', description: 'Runs the flow when an n8n generated webform is submitted',
defaultVersion: 2, defaultVersion: 2.1,
}; };
const nodeVersions: IVersionedNodeType['nodeVersions'] = { const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new FormTriggerV1(baseDescription), 1: new FormTriggerV1(baseDescription),
2: new FormTriggerV2(baseDescription), 2: new FormTriggerV2(baseDescription),
2.1: new FormTriggerV2(baseDescription),
}; };
super(nodeVersions, baseDescription); super(nodeVersions, baseDescription);

View file

@ -28,6 +28,9 @@ export const formDescription: INodeProperties = {
placeholder: "e.g. We'll get back to you soon", placeholder: "e.g. We'll get back to you soon",
description: description:
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.', 'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.',
typeOptions: {
rows: 2,
},
}; };
export const formFields: INodeProperties = { export const formFields: INodeProperties = {
@ -69,6 +72,14 @@ export const formFields: INodeProperties = {
name: 'Dropdown List', name: 'Dropdown List',
value: 'dropdown', value: 'dropdown',
}, },
{
name: 'Email',
value: 'email',
},
{
name: 'File',
value: 'file',
},
{ {
name: 'Number', name: 'Number',
value: 'number', value: 'number',
@ -88,6 +99,17 @@ export const formFields: INodeProperties = {
], ],
required: true, required: true,
}, },
{
displayName: 'Placeholder',
name: 'placeholder',
type: 'string',
default: '',
displayOptions: {
hide: {
fieldType: ['dropdown', 'date', 'file'],
},
},
},
{ {
displayName: 'Field Options', displayName: 'Field Options',
name: 'fieldOptions', name: 'fieldOptions',
@ -133,6 +155,48 @@ export const formFields: INodeProperties = {
}, },
}, },
}, },
{
displayName: 'Multiple Files',
name: 'multipleFiles',
type: 'boolean',
default: true,
description:
'Whether to allow the user to select multiple files from the file input or just one',
displayOptions: {
show: {
fieldType: ['file'],
},
},
},
{
displayName: 'Accept File Types',
name: 'acceptFileTypes',
type: 'string',
default: '',
description: 'List of file types that can be uploaded, separated by commas',
hint: 'Leave empty to allow all file types',
placeholder: 'e.g. .jpg, .png',
displayOptions: {
show: {
fieldType: ['file'],
},
},
},
{
displayName: 'Format Date As',
name: 'formatDate',
type: 'string',
default: '',
description:
'Returns a string representation of this field formatted according to the specified format string. For a table of tokens and their interpretations, see <a href="https://moment.github.io/luxon/#/formatting?ID=table-of-tokens" target="_blank">here</a>.',
placeholder: 'e.g. dd/mm/yyyy',
hint: 'Leave empty to use the default format',
displayOptions: {
show: {
fieldType: ['date'],
},
},
},
{ {
displayName: 'Required Field', displayName: 'Required Field',
name: 'requiredField', name: 'requiredField',

View file

@ -4,20 +4,29 @@ export type FormField = {
requiredField: boolean; requiredField: boolean;
fieldOptions?: { values: Array<{ option: string }> }; fieldOptions?: { values: Array<{ option: string }> };
multiselect?: boolean; multiselect?: boolean;
multipleFiles?: boolean;
acceptFileTypes?: string;
formatDate?: string;
placeholder?: string;
}; };
export type FormTriggerInput = { export type FormTriggerInput = {
isSelect?: boolean; isSelect?: boolean;
isMultiSelect?: boolean; isMultiSelect?: boolean;
isTextarea?: boolean; isTextarea?: boolean;
isFileInput?: boolean;
isInput?: boolean; isInput?: boolean;
labbel: string; label: string;
defaultValue?: string;
id: string; id: string;
errorId: string; errorId: string;
type?: 'text' | 'number' | 'date'; type?: 'text' | 'number' | 'date';
inputRequired: 'form-required' | ''; inputRequired: 'form-required' | '';
selectOptions?: string[]; selectOptions?: string[];
multiSelectOptions?: Array<{ id: string; label: string }>; multiSelectOptions?: Array<{ id: string; label: string }>;
acceptFileTypes?: string;
multipleFiles?: 'multiple' | '';
placeholder?: string;
}; };
export type FormTriggerData = { export type FormTriggerData = {
@ -31,4 +40,7 @@ export type FormTriggerData = {
formFields: FormTriggerInput[]; formFields: FormTriggerInput[];
useResponseData?: boolean; useResponseData?: boolean;
appendAttribution?: boolean; appendAttribution?: boolean;
customAttribution?: string;
}; };
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View file

@ -0,0 +1,370 @@
import { mock } from 'jest-mock-extended';
import type { IWebhookFunctions } from 'n8n-workflow';
import type { FormField } from '../interfaces';
import { formWebhook, prepareFormData } from '../utils';
describe('FormTrigger, formWebhook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call response render', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockRender = jest.fn();
const formFields: FormField[] = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{
fieldLabel: 'Gender',
fieldType: 'select',
requiredField: true,
fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] },
},
{
fieldLabel: 'Resume',
fieldType: 'file',
requiredField: true,
acceptFileTypes: '.pdf,.doc',
multipleFiles: false,
},
];
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form');
executeFunctions.getNodeParameter
.calledWith('formDescription')
.mockReturnValue('Test Description');
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} });
executeFunctions.getChildNodes.mockReturnValue([]);
await formWebhook(executeFunctions);
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
appendAttribution: true,
customAttribution: undefined,
formDescription: 'Test Description',
formFields: [
{
defaultValue: '',
errorId: 'error-field-0',
id: 'field-0',
inputRequired: 'form-required',
isInput: true,
label: 'Name',
placeholder: undefined,
type: 'text',
},
{
defaultValue: '',
errorId: 'error-field-1',
id: 'field-1',
inputRequired: '',
isInput: true,
label: 'Age',
placeholder: undefined,
type: 'number',
},
{
defaultValue: '',
errorId: 'error-field-2',
id: 'field-2',
inputRequired: 'form-required',
isInput: true,
label: 'Gender',
placeholder: undefined,
type: 'select',
},
{
acceptFileTypes: '.pdf,.doc',
defaultValue: '',
errorId: 'error-field-3',
id: 'field-3',
inputRequired: 'form-required',
isFileInput: true,
label: 'Resume',
multipleFiles: '',
placeholder: undefined,
},
],
formSubmittedText: 'Your response has been recorded',
formTitle: 'Test Form',
n8nWebsiteLink:
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId',
testRun: true,
useResponseData: false,
validForm: true,
});
});
it('should return workflowData on POST request', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockStatus = jest.fn();
const mockEnd = jest.fn();
const formFields: FormField[] = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const bodyData = {
'field-0': 'John Doe',
'field-1': '30',
};
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getChildNodes.mockReturnValue([]);
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} });
const result = await formWebhook(executeFunctions);
expect(result).toEqual({
webhookResponse: { status: 200 },
workflowData: [
[
{
json: {
Name: 'John Doe',
Age: 30,
submittedAt: expect.any(String),
formMode: 'test',
},
},
],
],
});
});
});
describe('FormTrigger, prepareFormData', () => {
it('should return valid form data with given parameters', () => {
const formFields: FormField[] = [
{
fieldLabel: 'Name',
fieldType: 'text',
requiredField: true,
placeholder: 'Enter your name',
},
{
fieldLabel: 'Email',
fieldType: 'email',
requiredField: true,
placeholder: 'Enter your email',
},
{
fieldLabel: 'Gender',
fieldType: 'dropdown',
requiredField: false,
fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] },
},
{
fieldLabel: 'Files',
fieldType: 'file',
requiredField: false,
acceptFileTypes: '.jpg,.png',
multipleFiles: true,
},
];
const query = { Name: 'John Doe', Email: 'john@example.com' };
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you for your submission',
redirectUrl: 'https://example.com/thank-you',
formFields,
testRun: false,
query,
instanceId: 'test-instance',
useResponseData: true,
});
expect(result).toEqual({
testRun: false,
validForm: true,
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you for your submission',
n8nWebsiteLink:
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=test-instance',
formFields: [
{
id: 'field-0',
errorId: 'error-field-0',
label: 'Name',
inputRequired: 'form-required',
defaultValue: 'John Doe',
placeholder: 'Enter your name',
isInput: true,
type: 'text',
},
{
id: 'field-1',
errorId: 'error-field-1',
label: 'Email',
inputRequired: 'form-required',
defaultValue: 'john@example.com',
placeholder: 'Enter your email',
isInput: true,
type: 'email',
},
{
id: 'field-2',
errorId: 'error-field-2',
label: 'Gender',
inputRequired: '',
defaultValue: '',
placeholder: undefined,
isSelect: true,
selectOptions: ['Male', 'Female'],
},
{
id: 'field-3',
errorId: 'error-field-3',
label: 'Files',
inputRequired: '',
defaultValue: '',
placeholder: undefined,
isFileInput: true,
acceptFileTypes: '.jpg,.png',
multipleFiles: 'multiple',
},
],
useResponseData: true,
appendAttribution: true,
customAttribution: undefined,
redirectUrl: 'https://example.com/thank-you',
});
});
it('should handle missing optional fields gracefully', () => {
const formFields: FormField[] = [
{
fieldLabel: 'Name',
fieldType: 'text',
requiredField: true,
placeholder: 'Enter your name',
},
];
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: undefined,
redirectUrl: undefined,
formFields,
testRun: true,
query: {},
});
expect(result).toEqual({
testRun: true,
validForm: true,
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Your response has been recorded',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
formFields: [
{
id: 'field-0',
errorId: 'error-field-0',
label: 'Name',
inputRequired: 'form-required',
defaultValue: '',
placeholder: 'Enter your name',
isInput: true,
type: 'text',
},
],
useResponseData: undefined,
appendAttribution: true,
customAttribution: undefined,
});
});
it('should set redirectUrl with http if protocol is missing', () => {
const formFields: FormField[] = [
{
fieldLabel: 'Name',
fieldType: 'text',
requiredField: true,
placeholder: 'Enter your name',
},
];
const query = { Name: 'John Doe' };
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: undefined,
redirectUrl: 'example.com/thank-you',
formFields,
testRun: true,
query,
});
expect(result.redirectUrl).toBe('http://example.com/thank-you');
});
it('should return invalid form data when formFields are empty', () => {
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: undefined,
redirectUrl: undefined,
formFields: [],
testRun: true,
query: {},
});
expect(result.validForm).toBe(false);
expect(result.formFields).toEqual([]);
});
it('should correctly handle multiselect fields', () => {
const formFields: FormField[] = [
{
fieldLabel: 'Favorite Colors',
fieldType: 'text',
requiredField: true,
multiselect: true,
fieldOptions: { values: [{ option: 'Red' }, { option: 'Blue' }, { option: 'Green' }] },
},
];
const query = { 'Favorite Colors': 'Red,Blue' };
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].isMultiSelect).toBe(true);
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0', label: 'Red' },
{ id: 'option1', label: 'Blue' },
{ id: 'option2', label: 'Green' },
]);
});
});

View file

@ -1,22 +1,45 @@
import { import type {
NodeOperationError, INodeExecutionData,
jsonParse, MultiPartFormData,
type IDataObject, IDataObject,
type IWebhookFunctions, IWebhookFunctions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces'; import { NodeOperationError, jsonParse } from 'n8n-workflow';
export const prepareFormData = ( import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
formTitle: string, import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
formDescription: string,
formSubmittedText: string | undefined, import { WebhookAuthorizationError } from '../Webhook/error';
redirectUrl: string | undefined, import { validateWebhookAuthentication } from '../Webhook/utils';
formFields: FormField[],
testRun: boolean, import { DateTime } from 'luxon';
instanceId?: string, import isbot from 'isbot';
useResponseData?: boolean,
export function prepareFormData({
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
testRun,
query,
instanceId,
useResponseData,
appendAttribution = true, appendAttribution = true,
) => { customAttribution,
}: {
formTitle: string;
formDescription: string;
formSubmittedText: string | undefined;
redirectUrl: string | undefined;
formFields: FormField[];
testRun: boolean;
query: IDataObject;
instanceId?: string;
useResponseData?: boolean;
appendAttribution?: boolean;
customAttribution?: string;
}) {
const validForm = formFields.length > 0; const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : ''; const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`; const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
@ -35,6 +58,7 @@ export const prepareFormData = (
formFields: [], formFields: [],
useResponseData, useResponseData,
appendAttribution, appendAttribution,
customAttribution,
}; };
if (redirectUrl) { if (redirectUrl) {
@ -49,13 +73,15 @@ export const prepareFormData = (
} }
for (const [index, field] of formFields.entries()) { for (const [index, field] of formFields.entries()) {
const { fieldType, requiredField, multiselect } = field; const { fieldType, requiredField, multiselect, placeholder } = field;
const input: IDataObject = { const input: IDataObject = {
id: `field-${index}`, id: `field-${index}`,
errorId: `error-field-${index}`, errorId: `error-field-${index}`,
label: field.fieldLabel, label: field.fieldLabel,
inputRequired: requiredField ? 'form-required' : '', inputRequired: requiredField ? 'form-required' : '',
defaultValue: query[field.fieldLabel] ?? '',
placeholder,
}; };
if (multiselect) { if (multiselect) {
@ -65,6 +91,10 @@ export const prepareFormData = (
id: `option${i}`, id: `option${i}`,
label: e.option, label: e.option,
})) ?? []; })) ?? [];
} else if (fieldType === 'file') {
input.isFileInput = true;
input.acceptFileTypes = field.acceptFileTypes;
input.multipleFiles = field.multipleFiles ? 'multiple' : '';
} else if (fieldType === 'dropdown') { } else if (fieldType === 'dropdown') {
input.isSelect = true; input.isSelect = true;
const fieldOptions = field.fieldOptions?.values ?? []; const fieldOptions = field.fieldOptions?.values ?? [];
@ -73,14 +103,14 @@ export const prepareFormData = (
input.isTextarea = true; input.isTextarea = true;
} else { } else {
input.isInput = true; input.isInput = true;
input.type = fieldType as 'text' | 'number' | 'date'; input.type = fieldType as 'text' | 'number' | 'date' | 'email';
} }
formData.formFields.push(input as FormTriggerInput); formData.formFields.push(input as FormTriggerInput);
} }
return formData; return formData;
}; }
const checkResponseModeConfiguration = (context: IWebhookFunctions) => { const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string; const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
@ -114,6 +144,37 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
}; };
export async function formWebhook(context: IWebhookFunctions) { export async function formWebhook(context: IWebhookFunctions) {
const nodeVersion = context.getNode().typeVersion;
const options = context.getNodeParameter('options', {}) as {
ignoreBots?: boolean;
respondWithOptions?: {
values: {
respondWith: 'text' | 'redirect';
formSubmittedText: string;
redirectUrl: string;
};
};
formSubmittedText?: string;
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
customAttribution?: string;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
try {
if (options.ignoreBots && isbot(req.headers['user-agent']))
throw new WebhookAuthorizationError(403);
await validateWebhookAuthentication(context, FORM_TRIGGER_AUTHENTICATION_PROPERTY);
} catch (error) {
if (error instanceof WebhookAuthorizationError) {
res.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
res.end(error.message);
return { noWebhookResponse: true };
}
throw error;
}
const mode = context.getMode() === 'manual' ? 'test' : 'production'; const mode = context.getMode() === 'manual' ? 'test' : 'production';
const formFields = context.getNodeParameter('formFields.values', []) as FormField[]; const formFields = context.getNodeParameter('formFields.values', []) as FormField[];
const method = context.getRequestObject().method; const method = context.getRequestObject().method;
@ -123,10 +184,11 @@ export async function formWebhook(context: IWebhookFunctions) {
//Show the form on GET request //Show the form on GET request
if (method === 'GET') { if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string; const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = context.getNodeParameter('formDescription', '') as string; const formDescription = (context.getNodeParameter('formDescription', '') as string)
.replace(/\\n/g, '\n')
.replace(/<br>/g, '\n');
const instanceId = context.getInstanceId(); const instanceId = context.getInstanceId();
const responseMode = context.getNodeParameter('responseMode', '') as string; const responseMode = context.getNodeParameter('responseMode', '') as string;
const options = context.getNodeParameter('options', {}) as IDataObject;
let formSubmittedText; let formSubmittedText;
let redirectUrl; let redirectUrl;
@ -150,19 +212,22 @@ export async function formWebhook(context: IWebhookFunctions) {
const useResponseData = responseMode === 'responseNode'; const useResponseData = responseMode === 'responseNode';
const data = prepareFormData( const query = context.getRequestObject().query as IDataObject;
const data = prepareFormData({
formTitle, formTitle,
formDescription, formDescription,
formSubmittedText, formSubmittedText,
redirectUrl, redirectUrl,
formFields, formFields,
mode === 'test', testRun: mode === 'test',
query,
instanceId, instanceId,
useResponseData, useResponseData,
appendAttribution, appendAttribution,
); customAttribution: options.customAttribution as string,
});
const res = context.getResponseObject();
res.render('form-trigger', data); res.render('form-trigger', data);
return { return {
noWebhookResponse: true, noWebhookResponse: true,
@ -170,13 +235,64 @@ export async function formWebhook(context: IWebhookFunctions) {
} }
const bodyData = (context.getBodyData().data as IDataObject) ?? {}; const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
const returnItem: INodeExecutionData = {
json: {},
};
if (files && Object.keys(files).length) {
returnItem.binary = {};
}
for (const key of Object.keys(files)) {
const processFiles: MultiPartFormData.File[] = [];
let multiFile = false;
const filesInput = files[key] as MultiPartFormData.File[] | MultiPartFormData.File;
if (Array.isArray(filesInput)) {
bodyData[key] = filesInput.map((file) => ({
filename: file.originalFilename,
mimetype: file.mimetype,
size: file.size,
}));
processFiles.push(...filesInput);
multiFile = true;
} else {
bodyData[key] = {
filename: filesInput.originalFilename,
mimetype: filesInput.mimetype,
size: filesInput.size,
};
processFiles.push(filesInput);
}
const entryIndex = Number(key.replace(/field-/g, ''));
const fieldLabel = isNaN(entryIndex) ? key : formFields[entryIndex].fieldLabel;
let fileCount = 0;
for (const file of processFiles) {
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
if (multiFile) {
binaryPropertyName += `_${fileCount++}`;
}
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
}
}
const returnData: IDataObject = {};
for (const [index, field] of formFields.entries()) { for (const [index, field] of formFields.entries()) {
const key = `field-${index}`; const key = `field-${index}`;
let value = bodyData[key] ?? null; let value = bodyData[key] ?? null;
if (value === null) returnData[field.fieldLabel] = null; if (value === null) {
returnItem.json[field.fieldLabel] = null;
continue;
}
if (field.fieldType === 'number') { if (field.fieldType === 'number') {
value = Number(value); value = Number(value);
@ -187,16 +303,31 @@ export async function formWebhook(context: IWebhookFunctions) {
if (field.multiselect && typeof value === 'string') { if (field.multiselect && typeof value === 'string') {
value = jsonParse(value); value = jsonParse(value);
} }
if (field.fieldType === 'date' && value && field.formatDate !== '') {
value = DateTime.fromFormat(String(value), 'yyyy-mm-dd').toFormat(field.formatDate as string);
}
if (field.fieldType === 'file' && field.multipleFiles && !Array.isArray(value)) {
value = [value];
}
returnData[field.fieldLabel] = value; returnItem.json[field.fieldLabel] = value;
} }
returnData.submittedAt = new Date().toISOString();
returnData.formMode = mode; let { useWorkflowTimezone } = options;
if (useWorkflowTimezone === undefined && nodeVersion > 2) {
useWorkflowTimezone = true;
}
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
returnItem.json.formMode = mode;
const webhookResponse: IDataObject = { status: 200 }; const webhookResponse: IDataObject = { status: 200 };
return { return {
webhookResponse, webhookResponse,
workflowData: [context.helpers.returnJsonArray(returnData)], workflowData: [[returnItem]],
}; };
} }

View file

@ -1,9 +1,10 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */ /* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { import type {
type INodeType, INodeProperties,
type INodeTypeBaseDescription, INodeType,
type INodeTypeDescription, INodeTypeBaseDescription,
type IWebhookFunctions, INodeTypeDescription,
IWebhookFunctions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { formWebhook } from '../utils'; import { formWebhook } from '../utils';
@ -16,13 +17,22 @@ import {
respondWithOptions, respondWithOptions,
webhookPath, webhookPath,
} from '../common.descriptions'; } from '../common.descriptions';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
const useWorkflowTimezone: INodeProperties = {
displayName: 'Use Workflow Timezone',
name: 'useWorkflowTimezone',
type: 'boolean',
default: false,
description: "Whether to use the workflow timezone set in node's settings rather than UTC",
};
const descriptionV2: INodeTypeDescription = { const descriptionV2: INodeTypeDescription = {
displayName: 'n8n Form Trigger', displayName: 'n8n Form Trigger',
name: 'formTrigger', name: 'formTrigger',
icon: 'file:form.svg', icon: 'file:form.svg',
group: ['trigger'], group: ['trigger'],
version: 2, version: [2, 2.1],
description: 'Runs the flow when an n8n generated webform is submitted', description: 'Runs the flow when an n8n generated webform is submitted',
defaults: { defaults: {
name: 'n8n Form Trigger', name: 'n8n Form Trigger',
@ -54,7 +64,35 @@ const descriptionV2: INodeTypeDescription = {
eventTriggerDescription: 'Waiting for you to submit the form', eventTriggerDescription: 'Waiting for you to submit the form',
activationMessage: 'You can now make calls to your production Form URL.', activationMessage: 'You can now make calls to your production Form URL.',
triggerPanel: formTriggerPanel, triggerPanel: formTriggerPanel,
credentials: [
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'httpBasicAuth',
required: true,
displayOptions: {
show: {
[FORM_TRIGGER_AUTHENTICATION_PROPERTY]: ['basicAuth'],
},
},
},
],
properties: [ properties: [
{
displayName: 'Authentication',
name: FORM_TRIGGER_AUTHENTICATION_PROPERTY,
type: 'options',
options: [
{
name: 'Basic Auth',
value: 'basicAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
},
webhookPath, webhookPath,
formTitle, formTitle,
formDescription, formDescription,
@ -77,6 +115,17 @@ const descriptionV2: INodeTypeDescription = {
placeholder: 'Add option', placeholder: 'Add option',
default: {}, default: {},
options: [ options: [
{
displayName: 'Custom Attribution',
name: 'customAttribution',
type: 'string',
placeholder: 'e.g. <svg> ...</svg>',
description: "HTML code that will be shown at the bottom of the form instead n8n's logo",
default: '',
typeOptions: {
rows: 2,
},
},
{ {
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Append n8n Attribution', displayName: 'Append n8n Attribution',
@ -94,6 +143,31 @@ const descriptionV2: INodeTypeDescription = {
}, },
}, },
}, },
{
displayName: 'Ignore Bots',
name: 'ignoreBots',
type: 'boolean',
default: false,
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
},
{
...useWorkflowTimezone,
default: false,
displayOptions: {
show: {
'@version': [2],
},
},
},
{
...useWorkflowTimezone,
default: true,
displayOptions: {
show: {
'@version': [{ _cnd: { gt: 2 } }],
},
},
},
], ],
}, },
], ],

View file

@ -4,7 +4,6 @@ import { createWriteStream } from 'fs';
import { stat } from 'fs/promises'; import { stat } from 'fs/promises';
import type { import type {
IWebhookFunctions, IWebhookFunctions,
ICredentialDataDecryptedObject,
IDataObject, IDataObject,
INodeExecutionData, INodeExecutionData,
INodeTypeDescription, INodeTypeDescription,
@ -15,12 +14,9 @@ import type {
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow'; import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
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 { formatPrivateKey } from '../../utils/utilities';
import { import {
authenticationProperty, authenticationProperty,
credentialsProperty, credentialsProperty,
@ -39,6 +35,7 @@ import {
configuredOutputs, configuredOutputs,
isIpWhitelisted, isIpWhitelisted,
setupOutputConnection, setupOutputConnection,
validateWebhookAuthentication,
} from './utils'; } from './utils';
export class Webhook extends Node { export class Webhook extends Node {
@ -265,93 +262,7 @@ export class Webhook extends Node {
} }
private async validateAuth(context: IWebhookFunctions) { private async validateAuth(context: IWebhookFunctions) {
const authentication = context.getNodeParameter(this.authPropertyName) as string; return await validateWebhookAuthentication(context, this.authPropertyName);
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);
}
} 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?.split(' ')[1];
if (!token) {
throw new WebhookAuthorizationError(401, 'No token provided');
}
let secretOrPublicKey;
if (expectedAuth.keyType === 'passphrase') {
secretOrPublicKey = expectedAuth.secret;
} else {
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
}
try {
return jwt.verify(token, secretOrPublicKey, {
algorithms: [expectedAuth.algorithm],
}) as IDataObject;
} catch (error) {
throw new WebhookAuthorizationError(403, error.message);
}
}
} }
private async handleFormData( private async handleFormData(

View file

@ -1,5 +1,14 @@
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { IWebhookFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; import type {
IWebhookFunctions,
INodeExecutionData,
IDataObject,
ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import { WebhookAuthorizationError } from './error';
import basicAuth from 'basic-auth';
import jwt from 'jsonwebtoken';
import { formatPrivateKey } from '../../utils/utilities';
type WebhookParameters = { type WebhookParameters = {
httpMethod: string; httpMethod: string;
@ -167,3 +176,96 @@ export const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
); );
} }
}; };
export async function validateWebhookAuthentication(
ctx: IWebhookFunctions,
authPropertyName: string,
) {
const authentication = ctx.getNodeParameter(authPropertyName) as string;
if (authentication === 'none') return;
const req = ctx.getRequestObject();
const headers = ctx.getHeaderData();
if (authentication === 'basicAuth') {
// Basic authorization is needed to call webhook
let expectedAuth: ICredentialDataDecryptedObject | undefined;
try {
expectedAuth = await ctx.getCredentials('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 ctx.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);
}
} else if (authentication === 'jwtAuth') {
let expectedAuth;
try {
expectedAuth = (await ctx.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?.split(' ')[1];
if (!token) {
throw new WebhookAuthorizationError(401, 'No token provided');
}
let secretOrPublicKey;
if (expectedAuth.keyType === 'passphrase') {
secretOrPublicKey = expectedAuth.secret;
} else {
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
}
try {
return jwt.verify(token, secretOrPublicKey, {
algorithms: [expectedAuth.algorithm],
}) as IDataObject;
} catch (error) {
throw new WebhookAuthorizationError(403, error.message);
}
}
}

View file

@ -1464,6 +1464,7 @@ export namespace MultiPartFormData {
mimetype?: string; mimetype?: string;
originalFilename?: string; originalFilename?: string;
newFilename: string; newFilename: string;
size?: number;
} }
export type Request = express.Request< export type Request = express.Request<