refactor(core): Parse Webhook request bodies on-demand (#6394)

Also,
1. Consistent CORS support ~on all three webhook types~ waiting webhooks never supported CORS. I'll fix that in another PR
2. [Fixes binary-data handling when request body is text, json, or xml](https://linear.app/n8n/issue/NODE-505/webhook-binary-data-handling-fails-for-textplain-files).
3. Reduced number of middleware that each request has to go through.
4. Removed the need to maintain webhook endpoints in the auth-exception list.
5. Skip all middlewares (apart from `compression`) on Webhook routes. 
6. move `multipart/form-data` support out of individual nodes
7. upgrade `formidable`
8. fix the filenames on binary-data in webhooks nodes
9. add unit tests and integration tests for webhook request handling, and increase test coverage
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-08-01 17:32:30 +02:00 committed by GitHub
parent 369a2e9796
commit 31d8f478ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 905 additions and 604 deletions

View file

@ -68,17 +68,18 @@
"@oclif/dev-cli": "^1.22.2",
"@types/basic-auth": "^1.1.3",
"@types/bcryptjs": "^2.4.2",
"@types/body-parser-xml": "^2.0.2",
"@types/compression": "1.0.1",
"@types/connect-history-api-fallback": "^1.3.1",
"@types/convict": "^6.1.1",
"@types/content-disposition": "^0.5.5",
"@types/content-type": "^1.1.5",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/formidable": "^3.4.0",
"@types/json-diff": "^1.0.0",
"@types/jsonwebtoken": "^9.0.1",
"@types/localtunnel": "^1.9.0",
"@types/lodash": "^4.14.195",
"@types/parseurl": "^1.3.1",
"@types/passport-jwt": "^3.0.6",
"@types/psl": "^1.1.0",
"@types/replacestream": "^4.0.1",
@ -91,6 +92,7 @@
"@types/uuid": "^8.3.2",
"@types/validator": "^13.7.0",
"@types/ws": "^8.5.4",
"@types/xml2js": "^0.4.11",
"@types/yamljs": "^0.2.31",
"chokidar": "^3.5.2",
"concurrently": "^8.2.0",
@ -110,8 +112,6 @@
"axios": "^0.21.1",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.1",
"body-parser-xml": "^2.0.3",
"bull": "^4.10.2",
"cache-manager": "^5.2.3",
"cache-manager-ioredis-yet": "^1.2.2",
@ -122,6 +122,8 @@
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",
"convict": "^6.2.4",
"content-disposition": "^0.5.4",
"content-type": "^1.0.4",
"cookie-parser": "^1.4.6",
"crypto-js": "~4.1.1",
"csrf": "^3.1.0",
@ -134,6 +136,7 @@
"express-prom-bundle": "^6.6.0",
"fast-glob": "^3.2.5",
"flatted": "^3.2.4",
"formidable": "^3.5.0",
"google-timezones-json": "^1.1.0",
"handlebars": "4.7.7",
"inquirer": "^7.0.1",
@ -157,7 +160,6 @@
"open": "^7.0.0",
"openapi-types": "^10.0.0",
"p-cancelable": "^2.0.0",
"parseurl": "^1.3.3",
"passport": "^0.6.0",
"passport-cookie": "^1.0.9",
"passport-jwt": "^4.0.0",
@ -167,6 +169,7 @@
"posthog-node": "^2.2.2",
"prom-client": "^13.1.0",
"psl": "^1.8.0",
"raw-body": "^2.5.1",
"reflect-metadata": "^0.1.13",
"replacestream": "^4.0.3",
"samlify": "^2.8.9",
@ -185,6 +188,7 @@
"validator": "13.7.0",
"winston": "^3.3.3",
"ws": "^8.12.0",
"xml2js": "^0.5.0",
"xmllint-wasm": "^3.0.1",
"yamljs": "^0.3.0"
}

View file

@ -1,40 +1,28 @@
import { Container } from 'typedi';
import { readFile } from 'fs/promises';
import type { Server } from 'http';
import type { Url } from 'url';
import express from 'express';
import bodyParser from 'body-parser';
import bodyParserXml from 'body-parser-xml';
import compression from 'compression';
import parseUrl from 'parseurl';
import type { RedisOptions } from 'ioredis';
import type { WebhookHttpMethod } from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow';
import config from '@/config';
import { N8N_VERSION, inDevelopment } from '@/constants';
import { N8N_VERSION, inDevelopment, inTest } from '@/constants';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import * as Db from '@/Db';
import type { IExternalHooksClass } from '@/Interfaces';
import { ExternalHooks } from '@/ExternalHooks';
import {
send,
sendErrorResponse,
sendSuccessResponse,
ServiceUnavailableError,
} from '@/ResponseHelper';
import { corsMiddleware } from '@/middlewares';
import { send, sendErrorResponse, ServiceUnavailableError } from '@/ResponseHelper';
import { rawBody, jsonParser, corsMiddleware } from '@/middlewares';
import { TestWebhooks } from '@/TestWebhooks';
import { WaitingWebhooks } from '@/WaitingWebhooks';
import { WEBHOOK_METHODS } from '@/WebhookHelpers';
import { getRedisClusterNodes } from './GenericHelpers';
const emptyBuffer = Buffer.alloc(0);
import { webhookRequestHandler } from '@/WebhookHelpers';
export abstract class AbstractServer {
protected server: Server;
protected app: express.Application;
readonly app: express.Application;
protected externalHooks: IExternalHooksClass;
@ -58,7 +46,9 @@ export abstract class AbstractServer {
protected instanceId = '';
abstract configure(): Promise<void>;
protected webhooksEnabled = true;
protected testWebhooksEnabled = false;
constructor() {
this.app = express();
@ -76,6 +66,10 @@ export abstract class AbstractServer {
this.endpointWebhookWaiting = config.getEnv('endpoints.webhookWaiting');
}
async configure(): Promise<void> {
// Additional configuration in derived classes
}
private async setupErrorHandlers() {
const { app } = this;
@ -87,66 +81,12 @@ export abstract class AbstractServer {
app.use(errorHandler());
}
private async setupCommonMiddlewares() {
const { app } = this;
private setupCommonMiddlewares() {
// Compress the response data
app.use(compression());
this.app.use(compression());
// Make sure that each request has the "parsedUrl" parameter
app.use((req, res, next) => {
req.parsedUrl = parseUrl(req)!;
req.rawBody = emptyBuffer;
next();
});
const payloadSizeMax = config.getEnv('endpoints.payloadSizeMax');
// Support application/json type post data
app.use(
bodyParser.json({
limit: `${payloadSizeMax}mb`,
verify: (req, res, buf) => {
req.rawBody = buf;
},
}),
);
// Support application/xml type post data
bodyParserXml(bodyParser);
app.use(
bodyParser.xml({
limit: `${payloadSizeMax}mb`,
xmlParseOptions: {
normalize: true, // Trim whitespace inside text nodes
normalizeTags: true, // Transform tags to lowercase
explicitArray: false, // Only put properties in array if length > 1
},
verify: (req, res, buf) => {
req.rawBody = buf;
},
}),
);
app.use(
bodyParser.text({
limit: `${payloadSizeMax}mb`,
verify: (req, res, buf) => {
req.rawBody = buf;
},
}),
);
// support application/x-www-form-urlencoded post data
app.use(
bodyParser.urlencoded({
limit: `${payloadSizeMax}mb`,
extended: false,
verify: (req, res, buf) => {
req.rawBody = buf;
},
}),
);
// Read incoming data into `rawBody`
this.app.use(rawBody);
}
private setupDevMiddlewares() {
@ -246,163 +186,6 @@ export abstract class AbstractServer {
});
}
// ----------------------------------------
// Regular Webhooks
// ----------------------------------------
protected setupWebhookEndpoint() {
const endpoint = this.endpointWebhook;
const activeWorkflowRunner = this.activeWorkflowRunner;
// Register all webhook requests
this.app.all(`/${endpoint}/*`, async (req, res) => {
// Cut away the "/webhook/" to get the registered part of the url
const requestUrl = req.parsedUrl.pathname!.slice(endpoint.length + 2);
const method = req.method.toUpperCase() as WebhookHttpMethod;
if (method === 'OPTIONS') {
let allowedMethods: string[];
try {
allowedMethods = await activeWorkflowRunner.getWebhookMethods(requestUrl);
allowedMethods.push('OPTIONS');
// Add custom "Allow" header to satisfy OPTIONS response.
res.append('Allow', allowedMethods);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
sendErrorResponse(res, error);
return;
}
res.header('Access-Control-Allow-Origin', '*');
sendSuccessResponse(res, {}, true, 204);
return;
}
if (!WEBHOOK_METHODS.includes(method)) {
sendErrorResponse(res, new Error(`The method ${method} is not supported.`));
return;
}
let response;
try {
response = await activeWorkflowRunner.executeWebhook(method, requestUrl, req, res);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
sendSuccessResponse(res, response.data, true, response.responseCode, response.headers);
});
}
// ----------------------------------------
// Waiting Webhooks
// ----------------------------------------
protected setupWaitingWebhookEndpoint() {
const endpoint = this.endpointWebhookWaiting;
const waitingWebhooks = Container.get(WaitingWebhooks);
// Register all webhook-waiting requests
this.app.all(`/${endpoint}/*`, async (req, res) => {
// Cut away the "/webhook-waiting/" to get the registered part of the url
const requestUrl = req.parsedUrl.pathname!.slice(endpoint.length + 2);
const method = req.method.toUpperCase() as WebhookHttpMethod;
if (!WEBHOOK_METHODS.includes(method)) {
sendErrorResponse(res, new Error(`The method ${method} is not supported.`));
return;
}
let response;
try {
response = await waitingWebhooks.executeWebhook(method, requestUrl, req, res);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
sendSuccessResponse(res, response.data, true, response.responseCode, response.headers);
});
}
// ----------------------------------------
// Testing Webhooks
// ----------------------------------------
protected setupTestWebhookEndpoint() {
const endpoint = this.endpointWebhookTest;
const testWebhooks = Container.get(TestWebhooks);
// Register all test webhook requests (for testing via the UI)
this.app.all(`/${endpoint}/*`, async (req, res) => {
// Cut away the "/webhook-test/" to get the registered part of the url
const requestUrl = req.parsedUrl.pathname!.slice(endpoint.length + 2);
const method = req.method.toUpperCase() as WebhookHttpMethod;
if (method === 'OPTIONS') {
let allowedMethods: string[];
try {
allowedMethods = await testWebhooks.getWebhookMethods(requestUrl);
allowedMethods.push('OPTIONS');
// Add custom "Allow" header to satisfy OPTIONS response.
res.append('Allow', allowedMethods);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
sendErrorResponse(res, error);
return;
}
res.header('Access-Control-Allow-Origin', '*');
sendSuccessResponse(res, {}, true, 204);
return;
}
if (!WEBHOOK_METHODS.includes(method)) {
sendErrorResponse(res, new Error(`The method ${method} is not supported.`));
return;
}
let response;
try {
response = await testWebhooks.callTestWebhook(method, requestUrl, req, res);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
sendSuccessResponse(res, response.data, true, response.responseCode, response.headers);
});
// Removes a test webhook
// TODO UM: check if this needs validation with user management.
this.app.delete(
`/${this.restEndpoint}/test-webhook/:id`,
send(async (req) => testWebhooks.cancelTestWebhook(req.params.id)),
);
}
async init(): Promise<void> {
const { app, protocol, sslKey, sslCert } = this;
@ -443,27 +226,60 @@ export abstract class AbstractServer {
}
async start(): Promise<void> {
await this.setupErrorHandlers();
this.setupPushServer();
await this.setupCommonMiddlewares();
if (!inTest) {
await this.setupErrorHandlers();
this.setupPushServer();
}
this.setupCommonMiddlewares();
// Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests
if (this.webhooksEnabled) {
// Register a handler for active webhooks
this.app.all(
`/${this.endpointWebhook}/:path(*)`,
webhookRequestHandler(Container.get(ActiveWorkflowRunner)),
);
// Register a handler for waiting webhooks
this.app.all(
`/${this.endpointWebhookWaiting}/:path/:suffix?`,
webhookRequestHandler(Container.get(WaitingWebhooks)),
);
}
if (this.testWebhooksEnabled) {
const testWebhooks = Container.get(TestWebhooks);
// Register a handler for test webhooks
this.app.all(`/${this.endpointWebhookTest}/:path(*)`, webhookRequestHandler(testWebhooks));
// Removes a test webhook
// TODO UM: check if this needs validation with user management.
this.app.delete(
`/${this.restEndpoint}/test-webhook/:id`,
send(async (req) => testWebhooks.cancelTestWebhook(req.params.id)),
);
}
if (inDevelopment) {
this.setupDevMiddlewares();
}
// Setup JSON parsing middleware after the webhook handlers are setup
this.app.use(jsonParser);
await this.configure();
console.log(`Version: ${N8N_VERSION}`);
const defaultLocale = config.getEnv('defaultLocale');
if (defaultLocale !== 'en') {
console.log(`Locale: ${defaultLocale}`);
if (!inTest) {
console.log(`Version: ${N8N_VERSION}`);
const defaultLocale = config.getEnv('defaultLocale');
if (defaultLocale !== 'en') {
console.log(`Locale: ${defaultLocale}`);
}
await this.externalHooks.run('n8n.ready', [this, config]);
}
await this.externalHooks.run('n8n.ready', [this, config]);
}
}
declare module 'http' {
export interface IncomingMessage {
parsedUrl: Url;
}
}

View file

@ -1,7 +1,7 @@
import { Service } from 'typedi';
import type {
IWebhookData,
WebhookHttpMethod,
IHttpRequestMethods,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
@ -102,7 +102,7 @@ export class ActiveWebhooks {
*
* @param {(string | undefined)} webhookId
*/
get(httpMethod: WebhookHttpMethod, path: string, webhookId?: string): IWebhookData | undefined {
get(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): IWebhookData | undefined {
const webhookKey = this.getWebhookKey(httpMethod, path, webhookId);
if (this.webhookUrls[webhookKey] === undefined) {
return undefined;
@ -133,17 +133,10 @@ export class ActiveWebhooks {
/**
* Gets all request methods associated with a single webhook
*/
getWebhookMethods(path: string): string[] {
const methods: string[] = [];
Object.keys(this.webhookUrls)
getWebhookMethods(path: string): IHttpRequestMethods[] {
return Object.keys(this.webhookUrls)
.filter((key) => key.includes(path))
.map((key) => {
methods.push(key.split('|')[0]);
});
return methods;
.map((key) => key.split('|')[0] as IHttpRequestMethods);
}
/**
@ -159,7 +152,7 @@ export class ActiveWebhooks {
*
* @param {(string | undefined)} webhookId
*/
getWebhookKey(httpMethod: WebhookHttpMethod, path: string, webhookId?: string): string {
getWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): string {
if (webhookId) {
if (path.startsWith(webhookId)) {
const cutFromIndex = path.indexOf('/') + 1;

View file

@ -1,6 +1,3 @@
/* eslint-disable prefer-spread */
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
@ -21,10 +18,11 @@ import type {
IRunExecutionData,
IWorkflowBase,
IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow,
WebhookHttpMethod,
IHttpRequestMethods,
WorkflowActivateMode,
WorkflowExecuteMode,
INodeType,
IWebhookData,
} from 'n8n-workflow';
import {
NodeHelpers,
@ -41,8 +39,10 @@ import type {
IActivationError,
IQueuedWorkflowActivations,
IResponseCallbackData,
IWebhookManager,
IWorkflowDb,
IWorkflowExecutionDataProcess,
WebhookRequest,
} from '@/Interfaces';
import * as ResponseHelper from '@/ResponseHelper';
import * as WebhookHelpers from '@/WebhookHelpers';
@ -73,7 +73,7 @@ const WEBHOOK_PROD_UNREGISTERED_HINT =
"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)";
@Service()
export class ActiveWorkflowRunner {
export class ActiveWorkflowRunner implements IWebhookManager {
private activeWorkflows = new ActiveWorkflows();
private activationErrors: {
@ -168,7 +168,7 @@ export class ActiveWorkflowRunner {
let activeWorkflowIds: string[] = [];
Logger.verbose('Call to remove all active workflows received (removeAll)');
activeWorkflowIds.push.apply(activeWorkflowIds, this.activeWorkflows.allActiveWorkflows());
activeWorkflowIds.push(...this.activeWorkflows.allActiveWorkflows());
const activeWorkflows = await this.getActiveWorkflows();
activeWorkflowIds = [...activeWorkflowIds, ...activeWorkflows];
@ -187,15 +187,13 @@ export class ActiveWorkflowRunner {
* Checks if a webhook for the given method and path exists and executes the workflow.
*/
async executeWebhook(
httpMethod: WebhookHttpMethod,
path: string,
req: express.Request,
res: express.Response,
request: WebhookRequest,
response: express.Response,
): Promise<IResponseCallbackData> {
Logger.debug(`Received webhook "${httpMethod}" for path "${path}"`);
const httpMethod = request.method;
let path = request.params.path;
// Reset request parameters
req.params = {};
Logger.debug(`Received webhook "${httpMethod}" for path "${path}"`);
// Remove trailing slash
if (path.endsWith('/')) {
@ -245,6 +243,7 @@ export class ActiveWorkflowRunner {
webhook = dynamicWebhook;
}
});
if (webhook === null) {
throw new ResponseHelper.NotFoundError(
webhookNotFoundErrorMessage(path, httpMethod),
@ -253,14 +252,14 @@ export class ActiveWorkflowRunner {
}
// @ts-ignore
path = webhook.webhookPath;
// extracting params from path
// @ts-ignore
webhook.webhookPath.split('/').forEach((ele, index) => {
if (ele.startsWith(':')) {
// write params to req.params
req.params[ele.slice(1)] = pathElements[index];
// @ts-ignore
request.params[ele.slice(1)] = pathElements[index];
}
});
}
@ -294,9 +293,7 @@ export class ActiveWorkflowRunner {
workflow,
workflow.getNode(webhook.node) as INode,
additionalData,
).filter((webhook) => {
return webhook.httpMethod === httpMethod && webhook.path === path;
})[0];
).find((w) => w.httpMethod === httpMethod && w.path === path) as IWebhookData;
// Get the node which has the webhook defined to know where to start from and to
// get additional data
@ -317,9 +314,8 @@ export class ActiveWorkflowRunner {
undefined,
undefined,
undefined,
req,
res,
request,
response,
(error: Error | null, data: object) => {
if (error !== null) {
return reject(error);
@ -333,7 +329,7 @@ export class ActiveWorkflowRunner {
/**
* Gets all request methods associated with a single webhook
*/
async getWebhookMethods(path: string): Promise<string[]> {
async getWebhookMethods(path: string): Promise<IHttpRequestMethods[]> {
const webhooks = await this.webhookRepository.find({
select: ['method'],
where: { webhookPath: path },
@ -479,10 +475,10 @@ export class ActiveWorkflowRunner {
try {
await this.removeWorkflowWebhooks(workflow.id as string);
} catch (error) {
ErrorReporter.error(error);
} catch (error1) {
ErrorReporter.error(error1);
Logger.error(
`Could not remove webhooks of workflow "${workflow.id}" because of error: "${error.message}"`,
`Could not remove webhooks of workflow "${workflow.id}" because of error: "${error1.message}"`,
);
}

View file

@ -1,4 +1,4 @@
import type { Application } from 'express';
import type { Application, Request, Response } from 'express';
import type {
ExecutionError,
ICredentialDataDecryptedObject,
@ -22,6 +22,7 @@ import type {
IExecutionsSummary,
FeatureFlags,
IUserSettings,
IHttpRequestMethods,
} from 'n8n-workflow';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -300,6 +301,19 @@ export interface IExternalHooksClass {
run(hookName: string, hookParameters?: any[]): Promise<void>;
}
export type WebhookCORSRequest = Request & { method: 'OPTIONS' };
export type WebhookRequest = Request<{ path: string }> & { method: IHttpRequestMethods };
export type WaitingWebhookRequest = WebhookRequest & {
params: WebhookRequest['path'] & { suffix?: string };
};
export interface IWebhookManager {
getWebhookMethods?: (path: string) => Promise<IHttpRequestMethods[]>;
executeWebhook(req: WebhookRequest, res: Response): Promise<IResponseCallbackData>;
}
export interface IDiagnosticInfo {
versionCli: string;
databaseType: DatabaseType;

View file

@ -69,8 +69,8 @@ export class ConflictError extends ResponseError {
}
export class UnprocessableRequestError extends ResponseError {
constructor(message: string) {
super(message, 422);
constructor(message: string, hint: string | undefined = undefined) {
super(message, 422, 422, hint);
}
}

View file

@ -201,6 +201,9 @@ export class Server extends AbstractServer {
this.app.set('view engine', 'handlebars');
this.app.set('views', TEMPLATES_DIR);
this.testWebhooksEnabled = true;
this.webhooksEnabled = !config.getEnv('endpoints.disableProductionWebhooksOnMainProcess');
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
const telemetrySettings: ITelemetrySettings = {
enabled: config.getEnv('diagnostics.enabled'),
@ -544,8 +547,6 @@ export class Server extends AbstractServer {
'healthz',
'metrics',
'e2e',
this.endpointWebhook,
this.endpointWebhookTest,
this.endpointPresetCredentials,
isApiEnabled() ? '' : publicApiEndpoint,
...excludeEndpoints.split(':'),
@ -1387,17 +1388,6 @@ export class Server extends AbstractServer {
await eventBus.initialize();
}
// ----------------------------------------
// Webhooks
// ----------------------------------------
if (!config.getEnv('endpoints.disableProductionWebhooksOnMainProcess')) {
this.setupWebhookEndpoint();
this.setupWaitingWebhookEndpoint();
}
this.setupTestWebhookEndpoint();
if (this.endpointPresetCredentials !== '') {
// POST endpoint to set preset credentials
this.app.post(
@ -1406,7 +1396,7 @@ export class Server extends AbstractServer {
if (!this.presetCredentialsLoaded) {
const body = req.body as ICredentialsOverwrite;
if (req.headers['content-type'] !== 'application/json') {
if (req.contentType !== 'application/json') {
ResponseHelper.sendErrorResponse(
res,
new Error(

View file

@ -4,14 +4,19 @@ import { Service } from 'typedi';
import type {
IWebhookData,
IWorkflowExecuteAdditionalData,
WebhookHttpMethod,
IHttpRequestMethods,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ActiveWebhooks } from '@/ActiveWebhooks';
import type { IResponseCallbackData, IWorkflowDb } from '@/Interfaces';
import type {
IResponseCallbackData,
IWebhookManager,
IWorkflowDb,
WebhookRequest,
} from '@/Interfaces';
import { Push } from '@/push';
import * as ResponseHelper from '@/ResponseHelper';
import * as WebhookHelpers from '@/WebhookHelpers';
@ -21,7 +26,7 @@ const WEBHOOK_TEST_UNREGISTERED_HINT =
"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)";
@Service()
export class TestWebhooks {
export class TestWebhooks implements IWebhookManager {
private testWebhookData: {
[key: string]: {
sessionId?: string;
@ -44,14 +49,12 @@ export class TestWebhooks {
* data gets additionally send to the UI. After the request got handled it
* automatically remove the test-webhook.
*/
async callTestWebhook(
httpMethod: WebhookHttpMethod,
path: string,
request: express.Request,
async executeWebhook(
request: WebhookRequest,
response: express.Response,
): Promise<IResponseCallbackData> {
// Reset request parameters
request.params = {};
const httpMethod = request.method;
let path = request.params.path;
// Remove trailing slash
if (path.endsWith('/')) {
@ -82,6 +85,7 @@ export class TestWebhooks {
path.split('/').forEach((ele, index) => {
if (ele.startsWith(':')) {
// write params to req.params
// @ts-ignore
request.params[ele.slice(1)] = pathElements[index];
}
});
@ -157,7 +161,7 @@ export class TestWebhooks {
/**
* Gets all request methods associated with a single test webhook
*/
async getWebhookMethods(path: string): Promise<string[]> {
async getWebhookMethods(path: string): Promise<IHttpRequestMethods[]> {
const webhookMethods = this.activeWebhooks.getWebhookMethods(path);
if (!webhookMethods.length) {
// The requested webhook is not registered

View file

@ -1,4 +1,3 @@
import type { INode, WebhookHttpMethod } from 'n8n-workflow';
import { NodeHelpers, Workflow, LoggerProxy as Logger } from 'n8n-workflow';
import { Service } from 'typedi';
import type express from 'express';
@ -6,41 +5,34 @@ import type express from 'express';
import * as ResponseHelper from '@/ResponseHelper';
import * as WebhookHelpers from '@/WebhookHelpers';
import { NodeTypes } from '@/NodeTypes';
import type { IExecutionResponse, IResponseCallbackData, IWorkflowDb } from '@/Interfaces';
import type {
IResponseCallbackData,
IWebhookManager,
IWorkflowDb,
WaitingWebhookRequest,
} from '@/Interfaces';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { ExecutionRepository } from '@db/repositories';
import { OwnershipService } from './services/ownership.service';
@Service()
export class WaitingWebhooks {
export class WaitingWebhooks implements IWebhookManager {
constructor(
private nodeTypes: NodeTypes,
private executionRepository: ExecutionRepository,
private ownershipService: OwnershipService,
) {}
// TODO: implement `getWebhookMethods` for CORS support
async executeWebhook(
httpMethod: WebhookHttpMethod,
fullPath: string,
req: express.Request,
req: WaitingWebhookRequest,
res: express.Response,
): Promise<IResponseCallbackData> {
Logger.debug(`Received waiting-webhook "${httpMethod}" for path "${fullPath}"`);
const { path: executionId, suffix } = req.params;
Logger.debug(`Received waiting-webhook "${req.method}" for execution "${executionId}"`);
// Reset request parameters
req.params = {};
// Remove trailing slash
if (fullPath.endsWith('/')) {
fullPath = fullPath.slice(0, -1);
}
const pathParts = fullPath.split('/');
const executionId = pathParts.shift();
const path = pathParts.join('/');
const execution = await this.executionRepository.findSingleExecution(executionId as string, {
const execution = await this.executionRepository.findSingleExecution(executionId, {
includeData: true,
unflattenData: true,
});
@ -53,35 +45,19 @@ export class WaitingWebhooks {
throw new ResponseHelper.ConflictError(`The execution "${executionId} has finished already.`);
}
return this.startExecution(httpMethod, path, execution, req, res);
}
async startExecution(
httpMethod: WebhookHttpMethod,
path: string,
fullExecutionData: IExecutionResponse,
req: express.Request,
res: express.Response,
): Promise<IResponseCallbackData> {
const executionId = fullExecutionData.id;
if (fullExecutionData.finished) {
throw new Error('The execution did succeed and can so not be started again.');
}
const lastNodeExecuted = fullExecutionData.data.resultData.lastNodeExecuted as string;
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
// Set the node as disabled so that the data does not get executed again as it would result
// in starting the wait all over again
fullExecutionData.data.executionData!.nodeExecutionStack[0].node.disabled = true;
execution.data.executionData!.nodeExecutionStack[0].node.disabled = true;
// Remove waitTill information else the execution would stop
fullExecutionData.data.waitTill = undefined;
execution.data.waitTill = undefined;
// Remove the data of the node execution again else it will display the node as executed twice
fullExecutionData.data.resultData.runData[lastNodeExecuted].pop();
execution.data.resultData.runData[lastNodeExecuted].pop();
const { workflowData } = fullExecutionData;
const { workflowData } = execution;
const workflow = new Workflow({
id: workflowData.id!,
@ -101,34 +77,31 @@ export class WaitingWebhooks {
throw new ResponseHelper.NotFoundError('Could not find workflow');
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id);
const webhookData = NodeHelpers.getNodeWebhooks(
workflow,
workflow.getNode(lastNodeExecuted) as INode,
additionalData,
).find((webhook) => {
return (
webhook.httpMethod === httpMethod &&
webhook.path === path &&
webhook.webhookDescription.restartWebhook === true
);
});
if (webhookData === undefined) {
// If no data got found it means that the execution can not be started via a webhook.
// Return 404 because we do not want to give any data if the execution exists or not.
const errorMessage = `The execution "${executionId}" with webhook suffix path "${path}" is not known.`;
throw new ResponseHelper.NotFoundError(errorMessage);
}
const workflowStartNode = workflow.getNode(lastNodeExecuted);
if (workflowStartNode === null) {
throw new ResponseHelper.NotFoundError('Could not find node to process webhook.');
}
const runExecutionData = fullExecutionData.data;
const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id);
const webhookData = NodeHelpers.getNodeWebhooks(
workflow,
workflowStartNode,
additionalData,
).find(
(webhook) =>
webhook.httpMethod === req.method &&
webhook.path === (suffix ?? '') &&
webhook.webhookDescription.restartWebhook === true,
);
if (webhookData === undefined) {
// If no data got found it means that the execution can not be started via a webhook.
// Return 404 because we do not want to give any data if the execution exists or not.
const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`;
throw new ResponseHelper.NotFoundError(errorMessage);
}
const runExecutionData = execution.data;
return new Promise((resolve, reject) => {
const executionMode = 'webhook';
@ -140,7 +113,7 @@ export class WaitingWebhooks {
executionMode,
undefined,
runExecutionData,
fullExecutionData.id,
execution.id,
req,
res,

View file

@ -1,21 +1,20 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable id-denylist */
/* eslint-disable prefer-spread */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import type express from 'express';
import { Container } from 'typedi';
import get from 'lodash/get';
import stream from 'stream';
import { promisify } from 'util';
import { Container } from 'typedi';
import { parse as parseQueryString } from 'querystring';
import { Parser as XmlParser } from 'xml2js';
import formidable from 'formidable';
import { BinaryDataManager, NodeExecuteFunctions } from 'n8n-core';
@ -26,6 +25,7 @@ import type {
IDeferredPromise,
IExecuteData,
IExecuteResponsePromiseData,
IHttpRequestMethods,
IN8nHttpFullResponse,
INode,
IRunExecutionData,
@ -40,6 +40,7 @@ import {
BINARY_ENCODING,
createDeferredPromise,
ErrorReporterProxy as ErrorReporter,
jsonParse,
LoggerProxy as Logger,
NodeHelpers,
} from 'n8n-workflow';
@ -47,8 +48,11 @@ import {
import type {
IExecutionDb,
IResponseCallbackData,
IWebhookManager,
IWorkflowDb,
IWorkflowExecutionDataProcess,
WebhookCORSRequest,
WebhookRequest,
} from '@/Interfaces';
import * as GenericHelpers from '@/GenericHelpers';
import * as ResponseHelper from '@/ResponseHelper';
@ -63,11 +67,67 @@ import { OwnershipService } from './services/ownership.service';
const pipeline = promisify(stream.pipeline);
export const WEBHOOK_METHODS = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];
export const WEBHOOK_METHODS: IHttpRequestMethods[] = [
'DELETE',
'GET',
'HEAD',
'PATCH',
'POST',
'PUT',
];
const xmlParser = new XmlParser({
async: true,
normalize: true, // Trim whitespace inside text nodes
normalizeTags: true, // Transform tags to lowercase
explicitArray: false, // Only put properties in array if length > 1
});
export const webhookRequestHandler =
(webhookManager: IWebhookManager) =>
async (req: WebhookRequest | WebhookCORSRequest, res: express.Response) => {
const { path } = req.params;
const method = req.method;
if (method !== 'OPTIONS' && !WEBHOOK_METHODS.includes(method)) {
return ResponseHelper.sendErrorResponse(
res,
new Error(`The method ${method} is not supported.`),
);
}
// Setup CORS headers only if the incoming request has an `origin` header
if ('origin' in req.headers) {
if (webhookManager.getWebhookMethods) {
try {
const allowedMethods = await webhookManager.getWebhookMethods(path);
res.header('Access-Control-Allow-Methods', ['OPTIONS', ...allowedMethods].join(', '));
} catch (error) {
return ResponseHelper.sendErrorResponse(res, error as Error);
}
}
res.header('Access-Control-Allow-Origin', req.headers.origin);
}
if (method === 'OPTIONS') {
return ResponseHelper.sendSuccessResponse(res, {}, true, 204);
}
let response;
try {
response = await webhookManager.executeWebhook(req, res);
} catch (error) {
return ResponseHelper.sendErrorResponse(res, error as Error);
}
// Don't respond, if already responded
if (response.noWebhookResponse !== true) {
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
}
};
/**
* Returns all the webhooks which should be created for the given workflow
*
*/
export function getWorkflowWebhooks(
workflow: Workflow,
@ -134,9 +194,6 @@ export function encodeWebhookResponse(
/**
* Executes a webhook
*
* @param {(string | undefined)} sessionId
* @param {((error: Error | null, data: IResponseCallbackData) => void)} responseCallback
*/
export async function executeWebhook(
workflow: Workflow,
@ -147,7 +204,7 @@ export async function executeWebhook(
sessionId: string | undefined,
runExecutionData: IRunExecutionData | undefined,
executionId: string | undefined,
req: express.Request,
req: WebhookRequest,
res: express.Response,
responseCallback: (error: Error | null, data: IResponseCallbackData) => void,
destinationNode?: string,
@ -227,6 +284,16 @@ export async function executeWebhook(
additionalData.httpRequest = req;
additionalData.httpResponse = res;
const binaryData = workflow.expression.getSimpleParameterValue(
workflowStartNode,
'={{$parameter["options"]["binaryData"]}}',
executionMode,
additionalData.timezone,
additionalKeys,
undefined,
false,
);
let didSendResponse = false;
let runExecutionDataMerge = {};
try {
@ -234,6 +301,46 @@ export async function executeWebhook(
// the workflow should be executed or not
let webhookResultData: IWebhookResponseData;
// if `Webhook` or `Wait` node, and binaryData is enabled, skip pre-parse the request-body
if (!binaryData) {
const { contentType, encoding } = req;
if (contentType === 'multipart/form-data') {
const form = formidable({
multiples: true,
encoding: encoding as formidable.BufferEncoding,
// TODO: pass a custom `fileWriteStreamHandler` to create binary data files directly
});
req.body = await new Promise((resolve) => {
form.parse(req, async (err, data, files) => {
resolve({ data, files });
});
});
} else {
await req.readRawBody();
const { rawBody } = req;
if (rawBody?.length) {
try {
if (contentType === 'application/json') {
req.body = jsonParse(rawBody.toString(encoding));
} else if (contentType?.endsWith('/xml') || contentType?.endsWith('+xml')) {
req.body = await xmlParser.parseStringPromise(rawBody.toString(encoding));
} else if (contentType === 'application/x-www-form-urlencoded') {
req.body = parseQueryString(rawBody.toString(encoding), undefined, undefined, {
maxKeys: 1000,
});
} else if (contentType === 'text/plain') {
req.body = rawBody.toString(encoding);
}
} catch (error) {
throw new ResponseHelper.UnprocessableRequestError(
'Failed to parse request body',
error.message,
);
}
}
}
}
try {
webhookResultData = await workflow.runWebhook(
webhookData,
@ -685,11 +792,13 @@ export async function executeWebhook(
return executionId;
} catch (e) {
if (!didSendResponse) {
responseCallback(new Error('There was a problem executing the workflow'), {});
}
throw new ResponseHelper.InternalServerError(e.message);
const error =
e instanceof ResponseHelper.UnprocessableRequestError
? e
: new Error('There was a problem executing the workflow', { cause: e });
if (didSendResponse) throw error;
responseCallback(error, {});
return;
}
}

View file

@ -1,8 +1,3 @@
import { AbstractServer } from '@/AbstractServer';
export class WebhookServer extends AbstractServer {
async configure() {
this.setupWebhookEndpoint();
this.setupWaitingWebhookEndpoint();
}
}
export class WebhookServer extends AbstractServer {}

View file

@ -26,6 +26,7 @@ if (inE2ETests) {
process.env.N8N_ENCRYPTION_KEY = 'test-encryption-key';
process.env.N8N_PUBLIC_API_DISABLED = 'true';
process.env.N8N_USER_FOLDER = mkdtempSync(testsDir);
process.env.SKIP_STATISTICS_EVENTS = 'true';
} else {
dotenv.config();
}

View file

@ -1,3 +1,4 @@
import { IHttpRequestMethods } from 'n8n-workflow';
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity()
@ -9,8 +10,8 @@ export class WebhookEntity {
@PrimaryColumn()
webhookPath: string;
@PrimaryColumn()
method: string;
@PrimaryColumn({ type: 'text' })
method: IHttpRequestMethods;
@Column()
node: string;

View file

@ -0,0 +1,61 @@
import { parse as parseContentDisposition } from 'content-disposition';
import { parse as parseContentType } from 'content-type';
import getRawBody from 'raw-body';
import { type RequestHandler } from 'express';
import { jsonParse } from 'n8n-workflow';
import config from '@/config';
const payloadSizeMax = config.getEnv('endpoints.payloadSizeMax');
export const rawBody: RequestHandler = async (req, res, next) => {
if ('content-type' in req.headers) {
const { type: contentType, parameters } = (() => {
try {
return parseContentType(req);
} catch {
return { type: undefined, parameters: undefined };
}
})();
req.contentType = contentType;
req.encoding = (parameters?.charset ?? 'utf-8').toLowerCase() as BufferEncoding;
const contentDispositionHeader = req.headers['content-disposition'];
if (contentDispositionHeader?.length) {
const {
type,
parameters: { filename },
} = parseContentDisposition(contentDispositionHeader);
req.contentDisposition = { type, filename };
}
}
req.readRawBody = async () => {
if (!req.rawBody) {
req.rawBody = await getRawBody(req, {
length: req.headers['content-length'],
limit: `${String(payloadSizeMax)}mb`,
});
req._body = true;
}
};
next();
};
export const jsonParser: RequestHandler = async (req, res, next) => {
await req.readRawBody();
if (Buffer.isBuffer(req.rawBody)) {
if (req.contentType === 'application/json') {
try {
req.body = jsonParse<unknown>(req.rawBody.toString(req.encoding));
} catch (error) {
res.status(400).send({ error: 'Failed to parse request body' });
return;
}
} else {
req.body = {};
}
}
next();
};

View file

@ -1,2 +1,3 @@
export * from './auth';
export * from './bodyParser';
export * from './cors';

View file

@ -1,6 +1,5 @@
import { Container } from 'typedi';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import express from 'express';
import { LoggerProxy } from 'n8n-workflow';
import type superagent from 'superagent';
@ -31,7 +30,7 @@ import {
TagsController,
UsersController,
} from '@/controllers';
import { setupAuthMiddlewares } from '@/middlewares';
import { rawBody, jsonParser, setupAuthMiddlewares } from '@/middlewares';
import { InternalHooks } from '@/InternalHooks';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
@ -119,6 +118,9 @@ export const setupTestServer = ({
enabledFeatures,
}: SetupProps): TestServer => {
const app = express();
app.use(rawBody);
app.use(cookieParser());
const testServer: TestServer = {
app,
httpServer: app.listen(0),
@ -137,10 +139,6 @@ export const setupTestServer = ({
mockInstance(InternalHooks);
mockInstance(PostHogClient);
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser());
config.set('userManagement.jwtSecret', 'My JWT secret');
config.set('userManagement.isInstanceOwnerSetUp', true);
@ -155,6 +153,8 @@ export const setupTestServer = ({
if (!endpointGroups) return;
app.use(jsonParser);
const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups);
if (routerEndpoints.length) {

View file

@ -0,0 +1,178 @@
import { readFileSync } from 'fs';
import type { SuperAgentTest } from 'supertest';
import { agent as testAgent } from 'supertest';
import type { INodeType, INodeTypeDescription, IWebhookFunctions } from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow';
import { AbstractServer } from '@/AbstractServer';
import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { getLogger } from '@/Logger';
import { NodeTypes } from '@/NodeTypes';
import { Push } from '@/push';
import { mockInstance, initActiveWorkflowRunner } from './shared/utils';
import * as testDb from './shared/testDb';
describe('Webhook API', () => {
mockInstance(ExternalHooks);
mockInstance(InternalHooks);
mockInstance(Push);
LoggerProxy.init(getLogger());
let agent: SuperAgentTest;
describe('Content-Type support', () => {
beforeAll(async () => {
await testDb.init();
const node = new WebhookTestingNode();
const user = await testDb.createUser();
await testDb.createWorkflow(
{
active: true,
nodes: [
{
name: 'Webhook',
type: node.description.name,
typeVersion: 1,
parameters: {
httpMethod: 'POST',
path: 'abcd',
},
id: '74786112-fb73-4d80-bd9a-43982939b801',
webhookId: '5ccef736-be16-4d10-b7fb-feed7a61ff22',
position: [740, 420],
},
],
},
user,
);
const nodeTypes = mockInstance(NodeTypes);
nodeTypes.getByName.mockReturnValue(node);
nodeTypes.getByNameAndVersion.mockReturnValue(node);
await initActiveWorkflowRunner();
const server = new (class extends AbstractServer {})();
await server.start();
agent = testAgent(server.app);
});
test('should handle JSON', async () => {
const response = await agent.post('/webhook/abcd').send({ test: true });
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({ type: 'application/json', body: { test: true } });
});
test('should handle XML', async () => {
const response = await agent
.post('/webhook/abcd')
.set('content-type', 'application/xml')
.send(
'<?xml version="1.0" encoding="UTF-8"?><Outer attr="test"><Inner>value</Inner></Outer>',
);
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({
type: 'application/xml',
body: {
outer: {
$: {
attr: 'test',
},
inner: 'value',
},
},
});
});
test('should handle form-urlencoded', async () => {
const response = await agent
.post('/webhook/abcd')
.set('content-type', 'application/x-www-form-urlencoded')
.send('x=5&y=str&z=false');
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({
type: 'application/x-www-form-urlencoded',
body: { x: '5', y: 'str', z: 'false' },
});
});
test('should handle plain text', async () => {
const response = await agent
.post('/webhook/abcd')
.set('content-type', 'text/plain')
.send('{"key": "value"}');
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({
type: 'text/plain',
body: '{"key": "value"}',
});
});
test('should handle multipart/form-data', async () => {
const response = await agent
.post('/webhook/abcd')
.field('field', 'value')
.attach('file', Buffer.from('random-text'))
.set('content-type', 'multipart/form-data');
expect(response.statusCode).toEqual(200);
expect(response.body.type).toEqual('multipart/form-data');
const {
data,
files: {
file: [file],
},
} = response.body.body;
expect(data).toEqual({ field: ['value'] });
expect(file.mimetype).toEqual('application/octet-stream');
expect(readFileSync(file.filepath, 'utf-8')).toEqual('random-text');
});
});
class WebhookTestingNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Webhook Testing Node',
name: 'webhook-testing-node',
group: ['trigger'],
version: 1,
description: '',
defaults: {},
inputs: [],
outputs: ['main'],
webhooks: [
{
name: 'default',
isFullPath: true,
httpMethod: '={{$parameter["httpMethod"]}}',
path: '={{$parameter["path"]}}',
},
],
properties: [
{
name: 'httpMethod',
type: 'string',
displayName: 'Method',
default: 'GET',
},
{
displayName: 'Path',
name: 'path',
type: 'string',
default: 'xyz',
},
],
};
async webhook(this: IWebhookFunctions) {
const req = this.getRequestObject();
return {
webhookResponse: {
type: req.contentType,
body: req.body,
},
};
}
}
});

View file

@ -0,0 +1,76 @@
import type { SuperAgentTest } from 'supertest';
import { agent as testAgent } from 'supertest';
import { mock } from 'jest-mock-extended';
import config from '@/config';
import { AbstractServer } from '@/AbstractServer';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { TestWebhooks } from '@/TestWebhooks';
import { WaitingWebhooks } from '@/WaitingWebhooks';
import type { IResponseCallbackData } from '@/Interfaces';
import { mockInstance } from '../integration/shared/utils';
let agent: SuperAgentTest;
describe('WebhookServer', () => {
mockInstance(ExternalHooks);
mockInstance(InternalHooks);
describe('CORS', () => {
const corsOrigin = 'https://example.com';
const activeWorkflowRunner = mockInstance(ActiveWorkflowRunner);
const testWebhooks = mockInstance(TestWebhooks);
mockInstance(WaitingWebhooks);
beforeAll(async () => {
const server = new (class extends AbstractServer {
testWebhooksEnabled = true;
})();
await server.start();
agent = testAgent(server.app);
});
const tests = [
['webhook', activeWorkflowRunner],
['webhookTest', testWebhooks],
// TODO: enable webhookWaiting after CORS support is added
// ['webhookWaiting', waitingWebhooks],
] as const;
for (const [key, manager] of tests) {
describe(`for ${key}`, () => {
it('should handle preflight requests', async () => {
const pathPrefix = config.getEnv(`endpoints.${key}`);
manager.getWebhookMethods.mockResolvedValueOnce(['GET']);
const response = await agent.options(`/${pathPrefix}/abcd`).set('origin', corsOrigin);
expect(response.statusCode).toEqual(204);
expect(response.body).toEqual({});
expect(response.headers['access-control-allow-origin']).toEqual(corsOrigin);
expect(response.headers['access-control-allow-methods']).toEqual('OPTIONS, GET');
});
it('should handle regular requests', async () => {
const pathPrefix = config.getEnv(`endpoints.${key}`);
manager.getWebhookMethods.mockResolvedValueOnce(['GET']);
manager.executeWebhook.mockResolvedValueOnce(mockResponse({ test: true }));
const response = await agent.get(`/${pathPrefix}/abcd`).set('origin', corsOrigin);
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({ test: true });
expect(response.headers['access-control-allow-origin']).toEqual(corsOrigin);
});
});
}
const mockResponse = (data = {}, status = 200) => {
const response = mock<IResponseCallbackData>();
response.responseCode = status;
response.data = data;
return response;
};
});
});

View file

@ -1,5 +1,3 @@
import * as formidable from 'formidable';
import type {
IHookFunctions,
IWebhookFunctions,
@ -9,6 +7,7 @@ import type {
INodeType,
INodeTypeDescription,
IWebhookResponseData,
MultiPartFormData,
} from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
@ -132,7 +131,6 @@ export class JotFormTrigger implements INodeType {
const endpoint = `/form/${formId}/webhooks`;
const body: IDataObject = {
webhookURL: webhookUrl,
//webhookURL: 'https://en0xsizp3qyt7f.x.pipedream.net/',
};
const { content } = await jotformApiRequest.call(this, 'POST', endpoint, body);
webhookData.webhookId = Object.keys(content as IDataObject)[0];
@ -158,71 +156,65 @@ export class JotFormTrigger implements INodeType {
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const req = this.getRequestObject();
const req = this.getRequestObject() as MultiPartFormData.Request;
const formId = this.getNodeParameter('form') as string;
const resolveData = this.getNodeParameter('resolveData', false) as boolean;
const onlyAnswers = this.getNodeParameter('onlyAnswers', false) as boolean;
const form = new formidable.IncomingForm({});
const { data } = req.body;
return new Promise((resolve, _reject) => {
form.parse(req, async (err, data, _files) => {
const rawRequest = jsonParse<any>(data.rawRequest as string);
data.rawRequest = rawRequest;
const rawRequest = jsonParse<any>(data.rawRequest as string);
data.rawRequest = rawRequest;
let returnData: IDataObject;
if (!resolveData) {
if (onlyAnswers) {
returnData = data.rawRequest as unknown as IDataObject;
} else {
returnData = data;
}
let returnData: IDataObject;
if (!resolveData) {
if (onlyAnswers) {
returnData = data.rawRequest as unknown as IDataObject;
} else {
returnData = data;
}
resolve({
workflowData: [this.helpers.returnJsonArray(returnData)],
});
}
return {
workflowData: [this.helpers.returnJsonArray(returnData)],
};
}
// Resolve the data by requesting the information via API
const endpoint = `/form/${formId}/questions`;
const responseData = await jotformApiRequest.call(this, 'GET', endpoint, {});
// Resolve the data by requesting the information via API
const endpoint = `/form/${formId}/questions`;
const responseData = await jotformApiRequest.call(this, 'GET', endpoint, {});
// Create a dictionary to resolve the keys
const questionNames: IDataObject = {};
for (const question of Object.values<IQuestionData>(
responseData.content as IQuestionData[],
)) {
questionNames[question.name] = question.text;
}
// Create a dictionary to resolve the keys
const questionNames: IDataObject = {};
for (const question of Object.values<IQuestionData>(responseData.content as IQuestionData[])) {
questionNames[question.name] = question.text;
}
// Resolve the keys
let questionKey: string;
const questionsData: IDataObject = {};
for (const key of Object.keys(rawRequest as IDataObject)) {
if (!key.includes('_')) {
continue;
}
// Resolve the keys
let questionKey: string;
const questionsData: IDataObject = {};
for (const key of Object.keys(rawRequest as IDataObject)) {
if (!key.includes('_')) {
continue;
}
questionKey = key.split('_').slice(1).join('_');
if (questionNames[questionKey] === undefined) {
continue;
}
questionKey = key.split('_').slice(1).join('_');
if (questionNames[questionKey] === undefined) {
continue;
}
questionsData[questionNames[questionKey] as string] = rawRequest[key];
}
questionsData[questionNames[questionKey] as string] = rawRequest[key];
}
if (onlyAnswers) {
returnData = questionsData as unknown as IDataObject;
} else {
// @ts-ignore
data.rawRequest = questionsData;
returnData = data;
}
if (onlyAnswers) {
returnData = questionsData as unknown as IDataObject;
} else {
// @ts-ignore
data.rawRequest = questionsData;
returnData = data;
}
resolve({
workflowData: [this.helpers.returnJsonArray(returnData)],
});
});
});
return {
workflowData: [this.helpers.returnJsonArray(returnData)],
};
}
}

View file

@ -6,14 +6,14 @@ import type {
INodeExecutionData,
INodeTypeDescription,
IWebhookResponseData,
MultiPartFormData,
} from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
import fs from 'fs';
import stream from 'stream';
import { promisify } from 'util';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import { v4 as uuid } from 'uuid';
import basicAuth from 'basic-auth';
import formidable from 'formidable';
import isbot from 'isbot';
import { file as tmpFile } from 'tmp-promise';
@ -30,8 +30,6 @@ import {
} from './description';
import { WebhookAuthorizationError } from './error';
const pipeline = promisify(stream.pipeline);
export class Webhook extends Node {
authPropertyName = 'authentication';
@ -118,15 +116,14 @@ export class Webhook extends Node {
throw error;
}
const mimeType = req.headers['content-type'] ?? 'application/json';
if (mimeType.includes('multipart/form-data')) {
return this.handleFormData(context);
}
if (options.binaryData) {
return this.handleBinaryData(context);
}
if (req.contentType === 'multipart/form-data') {
return this.handleFormData(context);
}
const response: INodeExecutionData = {
json: {
headers: req.headers,
@ -138,7 +135,7 @@ export class Webhook extends Node {
? {
data: {
data: req.rawBody.toString(BINARY_ENCODING),
mimeType,
mimeType: req.contentType ?? 'application/json',
},
}
: undefined,
@ -202,70 +199,65 @@ export class Webhook extends Node {
}
private async handleFormData(context: IWebhookFunctions) {
const req = context.getRequestObject();
const req = context.getRequestObject() as MultiPartFormData.Request;
const options = context.getNodeParameter('options', {}) as IDataObject;
const { data, files } = req.body;
const form = new formidable.IncomingForm({ multiples: true });
const returnItem: INodeExecutionData = {
binary: {},
json: {
headers: req.headers,
params: req.params,
query: req.query,
body: data,
},
};
return new Promise<IWebhookResponseData>((resolve, _reject) => {
form.parse(req, async (err, data, files) => {
const returnItem: INodeExecutionData = {
binary: {},
json: {
headers: req.headers,
params: req.params,
query: req.query,
body: data,
},
};
let count = 0;
for (const key of Object.keys(files)) {
const processFiles: MultiPartFormData.File[] = [];
let multiFile = false;
if (Array.isArray(files[key])) {
processFiles.push(...(files[key] as MultiPartFormData.File[]));
multiFile = true;
} else {
processFiles.push(files[key] as MultiPartFormData.File);
}
let count = 0;
for (const xfile of Object.keys(files)) {
const processFiles: formidable.File[] = [];
let multiFile = false;
if (Array.isArray(files[xfile])) {
processFiles.push(...(files[xfile] as formidable.File[]));
multiFile = true;
} else {
processFiles.push(files[xfile] as formidable.File);
}
let fileCount = 0;
for (const file of processFiles) {
let binaryPropertyName = xfile;
if (binaryPropertyName.endsWith('[]')) {
binaryPropertyName = binaryPropertyName.slice(0, -2);
}
if (multiFile) {
binaryPropertyName += fileCount++;
}
if (options.binaryPropertyName) {
binaryPropertyName = `${options.binaryPropertyName}${count}`;
}
const fileJson = file.toJSON();
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.path,
fileJson.name || fileJson.filename,
fileJson.type as string,
);
count += 1;
}
let fileCount = 0;
for (const file of processFiles) {
let binaryPropertyName = key;
if (binaryPropertyName.endsWith('[]')) {
binaryPropertyName = binaryPropertyName.slice(0, -2);
}
resolve({ workflowData: [[returnItem]] });
});
});
if (multiFile) {
binaryPropertyName += fileCount++;
}
if (options.binaryPropertyName) {
binaryPropertyName = `${options.binaryPropertyName}${count}`;
}
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
count += 1;
}
}
return { workflowData: [[returnItem]] };
}
private async handleBinaryData(context: IWebhookFunctions): Promise<IWebhookResponseData> {
const req = context.getRequestObject();
const options = context.getNodeParameter('options', {}) as IDataObject;
// TODO: create empty binaryData placeholder, stream into that path, and then finalize the binaryData
const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' });
try {
await pipeline(req, fs.createWriteStream(binaryFile.path));
await pipeline(req, createWriteStream(binaryFile.path));
const returnItem: INodeExecutionData = {
binary: {},
@ -273,14 +265,16 @@ export class Webhook extends Node {
headers: req.headers,
params: req.params,
query: req.query,
body: req.body,
body: {},
},
};
const binaryPropertyName = (options.binaryPropertyName || 'data') as string;
const fileName = req.contentDisposition?.filename ?? uuid();
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
binaryFile.path,
req.headers['content-type'] ?? 'application/octet-stream',
fileName,
req.contentType ?? 'application/octet-stream',
);
return { workflowData: [[returnItem]] };

View file

@ -0,0 +1,60 @@
{
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "test",
"options": {
"binaryData": false
}
},
"id": "ec188f16-b2c5-44e3-bd83-259a94f15c94",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"webhookId": "a59a3be7-6d43-47e7-951d-86b8172c2006"
}
],
"connections": {
"Webhook": {
"main": [[]]
}
},
"trigger": {
"mode": "webhook",
"input": {
"json": {
"headers": {
"host": "localhost:5678",
"user-agent": "curl/8.2.0",
"accept": "*/*",
"content-length": "137",
"content-type": "multipart/form-data; boundary=--boundary"
},
"params": { "path": "test" },
"query": {},
"body": { "a": ["b"] }
}
}
},
"pinData": {
"Webhook": [
{
"json": {
"headers": {
"host": "localhost:5678",
"user-agent": "curl/8.2.0",
"accept": "*/*",
"content-length": "137",
"content-type": "multipart/form-data; boundary=--boundary"
},
"params": { "path": "test" },
"query": {},
"body": {
"a": ["b"]
}
}
}
]
}
}

View file

@ -0,0 +1,4 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Webhook Node', () => testWorkflows(workflows));

View file

@ -782,7 +782,6 @@
"@types/cron": "~1.7.1",
"@types/eventsource": "^1.1.2",
"@types/express": "^4.17.6",
"@types/formidable": "^1.0.31",
"@types/gm": "^1.25.0",
"@types/imap-simple": "^4.2.0",
"@types/js-nacl": "^1.3.0",
@ -820,7 +819,6 @@
"eventsource": "^2.0.2",
"fast-glob": "^3.2.5",
"fflate": "^0.7.0",
"formidable": "^1.2.1",
"get-system-fonts": "^2.0.2",
"gm": "^1.25.0",
"iconv-lite": "^0.6.2",

View file

@ -1,11 +1,11 @@
import { WorkflowExecute } from 'n8n-core';
import type { INodeTypes, IRun } from 'n8n-workflow';
import type { INodeTypes, IRun, IRunExecutionData } from 'n8n-workflow';
import { createDeferredPromise, Workflow } from 'n8n-workflow';
import * as Helpers from './Helpers';
import type { WorkflowTestData } from './types';
export async function executeWorkflow(testData: WorkflowTestData, nodeTypes: INodeTypes) {
const executionMode = 'manual';
const executionMode = testData.trigger?.mode ?? 'manual';
const workflowInstance = new Workflow({
id: 'test',
nodes: testData.input.workflowData.nodes,
@ -21,9 +21,30 @@ export async function executeWorkflow(testData: WorkflowTestData, nodeTypes: INo
nodeExecutionOrder,
testData,
);
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
const executionData = await workflowExecute.run(workflowInstance);
let executionData: IRun;
const runExecutionData: IRunExecutionData = {
resultData: {
runData: {},
},
executionData: {
contextData: {},
waitingExecution: {},
waitingExecutionSource: null,
nodeExecutionStack: [
{
node: workflowInstance.getStartNode()!,
data: {
main: [[testData.trigger?.input ?? { json: {} }]],
},
source: null,
},
],
},
};
const workflowExecute = new WorkflowExecute(additionalData, executionMode, runExecutionData);
executionData = await workflowExecute.processRunExecutionData(workflowInstance);
const result = await waitPromise.promise();
return { executionData, result, nodeExecutionOrder };
}

View file

@ -182,6 +182,7 @@ export function WorkflowExecuteAdditionalData(
webhookTestBaseUrl: 'webhook-test',
userId: '123',
variables: {},
instanceBaseUrl: '',
};
}
@ -353,7 +354,9 @@ export const workflowToTests = (workflowFiles: string[]) => {
const testCases: WorkflowTestData[] = [];
for (const filePath of workflowFiles) {
const description = filePath.replace('.json', '');
const workflowData = readJsonFileSync<IWorkflowBase>(filePath);
const workflowData = readJsonFileSync<IWorkflowBase & Pick<WorkflowTestData, 'trigger'>>(
filePath,
);
const testDir = path.join(baseDir, path.dirname(filePath));
workflowData.nodes.forEach((node) => {
if (node.parameters) {
@ -367,13 +370,15 @@ export const workflowToTests = (workflowFiles: string[]) => {
}
const nodeData = preparePinData(workflowData.pinData);
delete workflowData.pinData;
const { trigger } = workflowData;
delete workflowData.trigger;
const input = { workflowData };
const output = { nodeData };
testCases.push({ description, input, output });
testCases.push({ description, input, output, trigger });
}
return testCases;
};

View file

@ -1235,6 +1235,24 @@ export interface ITriggerResponse {
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';
export namespace MultiPartFormData {
export interface File {
filepath: string;
mimetype?: string;
originalFilename?: string;
newFilename: string;
}
export type Request = express.Request<
{},
{},
{
data: Record<string, string | string[]>;
files: Record<string, File | File[]>;
}
>;
}
export interface INodeType {
description: INodeTypeDescription;
execute?(
@ -1492,7 +1510,7 @@ export interface INodeHookDescription {
}
export interface IWebhookData {
httpMethod: WebhookHttpMethod;
httpMethod: IHttpRequestMethods;
node: string;
path: string;
webhookDescription: IWebhookDescription;
@ -1502,8 +1520,8 @@ export interface IWebhookData {
}
export interface IWebhookDescription {
[key: string]: WebhookHttpMethod | WebhookResponseMode | boolean | string | undefined;
httpMethod: WebhookHttpMethod | string;
[key: string]: IHttpRequestMethods | WebhookResponseMode | boolean | string | undefined;
httpMethod: IHttpRequestMethods | string;
isFullPath?: boolean;
name: 'default' | 'setup';
path: string;
@ -1555,8 +1573,6 @@ export interface IWorkflowMetadata {
active: boolean;
}
export type WebhookHttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS';
export interface IWebhookResponseData {
workflowData?: INodeExecutionData[][];
webhookResponse?: any;
@ -1811,6 +1827,10 @@ export interface WorkflowTestData {
[key: string]: any[][];
};
};
trigger?: {
mode: WorkflowExecuteMode;
input: INodeExecutionData;
};
}
export type LogTypes = 'debug' | 'verbose' | 'info' | 'warn' | 'error';

View file

@ -32,7 +32,7 @@ import type {
IWebhookData,
IWorkflowExecuteAdditionalData,
NodeParameterValue,
WebhookHttpMethod,
IHttpRequestMethods,
FieldType,
INodePropertyOptions,
ResourceMapperValue,
@ -870,8 +870,6 @@ export async function prepareOutputData(
/**
* Returns all the webhooks which should be created for the give node
*
*
*/
export function getNodeWebhooks(
workflow: Workflow,
@ -968,7 +966,7 @@ export function getNodeWebhooks(
}
returnData.push({
httpMethod: httpMethod.toString() as WebhookHttpMethod,
httpMethod: httpMethod.toString() as IHttpRequestMethods,
node: node.name,
path,
webhookDescription,

View file

@ -50,6 +50,11 @@ export type { DocMetadata, NativeDoc } from './Extensions';
declare module 'http' {
export interface IncomingMessage {
contentType?: string;
encoding: BufferEncoding;
contentDisposition?: { type: string; filename?: string };
rawBody: Buffer;
readRawBody(): Promise<void>;
_body: boolean;
}
}

View file

@ -219,12 +219,6 @@ importers:
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
body-parser:
specifier: ^1.20.1
version: 1.20.1
body-parser-xml:
specifier: ^2.0.3
version: 2.0.3
bull:
specifier: ^4.10.2
version: 4.10.2
@ -252,6 +246,12 @@ importers:
connect-history-api-fallback:
specifier: ^1.6.0
version: 1.6.0
content-disposition:
specifier: ^0.5.4
version: 0.5.4
content-type:
specifier: ^1.0.4
version: 1.0.4
convict:
specifier: ^6.2.4
version: 6.2.4
@ -291,6 +291,9 @@ importers:
flatted:
specifier: ^3.2.4
version: 3.2.7
formidable:
specifier: ^3.5.0
version: 3.5.0
google-timezones-json:
specifier: ^1.1.0
version: 1.1.0
@ -360,9 +363,6 @@ importers:
p-cancelable:
specifier: ^2.0.0
version: 2.1.1
parseurl:
specifier: ^1.3.3
version: 1.3.3
passport:
specifier: ^0.6.0
version: 0.6.0
@ -390,6 +390,9 @@ importers:
psl:
specifier: ^1.8.0
version: 1.9.0
raw-body:
specifier: ^2.5.1
version: 2.5.1
reflect-metadata:
specifier: ^0.1.13
version: 0.1.13
@ -444,6 +447,9 @@ importers:
ws:
specifier: ^8.12.0
version: 8.12.0
xml2js:
specifier: ^0.5.0
version: 0.5.0
xmllint-wasm:
specifier: ^3.0.1
version: 3.0.1
@ -463,15 +469,18 @@ importers:
'@types/bcryptjs':
specifier: ^2.4.2
version: 2.4.2
'@types/body-parser-xml':
specifier: ^2.0.2
version: 2.0.2
'@types/compression':
specifier: 1.0.1
version: 1.0.1
'@types/connect-history-api-fallback':
specifier: ^1.3.1
version: 1.3.5
'@types/content-disposition':
specifier: ^0.5.5
version: 0.5.5
'@types/content-type':
specifier: ^1.1.5
version: 1.1.5
'@types/convict':
specifier: ^6.1.1
version: 6.1.1
@ -481,6 +490,9 @@ importers:
'@types/express':
specifier: ^4.17.6
version: 4.17.14
'@types/formidable':
specifier: ^3.4.0
version: 3.4.0
'@types/json-diff':
specifier: ^1.0.0
version: 1.0.0
@ -493,9 +505,6 @@ importers:
'@types/lodash':
specifier: ^4.14.195
version: 4.14.195
'@types/parseurl':
specifier: ^1.3.1
version: 1.3.1
'@types/passport-jwt':
specifier: ^3.0.6
version: 3.0.7
@ -532,6 +541,9 @@ importers:
'@types/ws':
specifier: ^8.5.4
version: 8.5.4
'@types/xml2js':
specifier: ^0.4.11
version: 0.4.11
'@types/yamljs':
specifier: ^0.2.31
version: 0.2.31
@ -1013,9 +1025,6 @@ importers:
fflate:
specifier: ^0.7.0
version: 0.7.4
formidable:
specifier: ^1.2.1
version: 1.2.6
get-system-fonts:
specifier: ^2.0.2
version: 2.0.2
@ -1179,9 +1188,6 @@ importers:
'@types/express':
specifier: ^4.17.6
version: 4.17.14
'@types/formidable':
specifier: ^1.0.31
version: 1.2.5
'@types/gm':
specifier: ^1.25.0
version: 1.25.0
@ -5970,16 +5976,6 @@ packages:
resolution: {integrity: sha512-g2qEd+zkfkTEudA2SrMAeAvY7CrFqtbsLILm2dT2VIeKTqMqVzcdfURlvu6FU3srRgbmXN1Srm94pg34EIehww==}
dev: true
/@types/body-parser-xml@2.0.2:
resolution: {integrity: sha512-LlmFkP3BTfacofFevInpM8iZ6+hALZ9URUt5JpSw76irhHCdbqbcBtbxbu2MO8HUGoIROQ5wuB55rLS99xNgCg==}
dependencies:
'@types/body-parser': 1.19.2
'@types/connect': 3.4.35
'@types/express-serve-static-core': 4.17.31
'@types/node': 18.16.16
'@types/xml2js': 0.4.11
dev: true
/@types/body-parser@1.19.2:
resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
dependencies:
@ -6030,6 +6026,14 @@ packages:
dependencies:
'@types/node': 18.16.16
/@types/content-disposition@0.5.5:
resolution: {integrity: sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==}
dev: true
/@types/content-type@1.1.5:
resolution: {integrity: sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==}
dev: true
/@types/convict@6.1.1:
resolution: {integrity: sha512-R+JLaTvhsD06p4jyjUDtbd5xMtZTRE3c0iI+lrFWZogSVEjgTWPYwvJPVf+t92E+yrlbXa4X4Eg9ro6gPdUt4w==}
dependencies:
@ -6142,8 +6146,8 @@ packages:
resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==}
dev: true
/@types/formidable@1.2.5:
resolution: {integrity: sha512-zu3mQJa4hDNubEMViSj937602XdDGzK7Q5pJ5QmLUbNxclbo9tZGt5jtwM352ssZ+pqo5V4H14TBvT/ALqQQcA==}
/@types/formidable@3.4.0:
resolution: {integrity: sha512-JXP+LsspYYBIJJxZ9VJsswb5U1hkUUhLmtAb6EB1SWcDDbJQlWRhGoZYasMSnk2NsqtUEHd3uuaiImmSys+8AQ==}
dependencies:
'@types/node': 18.16.16
dev: true
@ -6384,12 +6388,6 @@ packages:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: true
/@types/parseurl@1.3.1:
resolution: {integrity: sha512-sAfjGAYgJ/MZsk95f3ByThfJgasZk1hWJROghBwfKnLLNANEAG/WHckAcT6HNqx2sHwVlci7OAX7k1KYpOcgMw==}
dependencies:
'@types/node': 18.16.16
dev: true
/@types/passport-jwt@3.0.7:
resolution: {integrity: sha512-qRQ4qlww1Yhs3IaioDKrsDNmKy6gLDLgFsGwpCnc2YqWovO2Oxu9yCQdWHMJafQ7UIuOba4C4/TNXcGkQfEjlQ==}
dependencies:
@ -8349,13 +8347,6 @@ packages:
resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==}
dev: false
/body-parser-xml@2.0.3:
resolution: {integrity: sha512-tWvcAbh8QPd/lj+yfGZBMY/roof/e2iSXrJbYXYjxVhHQ88D2CF3AxDTdwhb9wcNdHVNbCttaWipchJPEs5r0g==}
engines: {node: '>=10'}
dependencies:
xml2js: 0.5.0
dev: false
/body-parser@1.20.1:
resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@ -10021,7 +10012,6 @@ packages:
dependencies:
asap: 2.0.6
wrappy: 1.0.2
dev: true
/diff-sequences@29.4.3:
resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==}
@ -11741,11 +11731,6 @@ packages:
combined-stream: 1.0.8
mime-types: 2.1.35
/formidable@1.2.6:
resolution: {integrity: sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==}
deprecated: 'Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau'
dev: false
/formidable@2.1.2:
resolution: {integrity: sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==}
dependencies:
@ -11755,6 +11740,14 @@ packages:
qs: 6.11.0
dev: true
/formidable@3.5.0:
resolution: {integrity: sha512-WwsMWvPmY+Kv37C3+KP3A+2Ym1aZoac4nz4ZEe5z0UPBoCg0O/wHay3eeYkZr4KJIbCzpSUeno+STMhde+KCfw==}
dependencies:
dezalgo: 1.0.4
hexoid: 1.0.0
once: 1.4.0
dev: false
/formstream@1.1.1:
resolution: {integrity: sha512-yHRxt3qLFnhsKAfhReM4w17jP+U1OlhUjnKPPtonwKbIJO7oBP0MvoxkRUwb8AU9n0MIkYy5X5dK6pQnbj+R2Q==}
dependencies:
@ -12500,7 +12493,6 @@ packages:
/hexoid@1.0.0:
resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==}
engines: {node: '>=8'}
dev: true
/highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}