mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-22 18:11:29 -08:00
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:
parent
369a2e9796
commit
31d8f478ee
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}"`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,3 @@
|
|||
import { AbstractServer } from '@/AbstractServer';
|
||||
|
||||
export class WebhookServer extends AbstractServer {
|
||||
async configure() {
|
||||
this.setupWebhookEndpoint();
|
||||
this.setupWaitingWebhookEndpoint();
|
||||
}
|
||||
}
|
||||
export class WebhookServer extends AbstractServer {}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
61
packages/cli/src/middlewares/bodyParser.ts
Normal file
61
packages/cli/src/middlewares/bodyParser.ts
Normal 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();
|
||||
};
|
|
@ -1,2 +1,3 @@
|
|||
export * from './auth';
|
||||
export * from './bodyParser';
|
||||
export * from './cors';
|
||||
|
|
|
@ -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) {
|
||||
|
|
178
packages/cli/test/integration/webhooks.api.test.ts
Normal file
178
packages/cli/test/integration/webhooks.api.test.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
76
packages/cli/test/unit/webhooks.test.ts
Normal file
76
packages/cli/test/unit/webhooks.test.ts
Normal 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;
|
||||
};
|
||||
});
|
||||
});
|
|
@ -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)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]] };
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
4
packages/nodes-base/nodes/Webhook/test/Webhook.test.ts
Normal file
4
packages/nodes-base/nodes/Webhook/test/Webhook.test.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
|
||||
describe('Test Webhook Node', () => testWorkflows(workflows));
|
|
@ -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",
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue