diff --git a/packages/cli/package.json b/packages/cli/package.json index f343968ecc..5cb213c996 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" } diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 9b82e230e9..64bf53dcbc 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -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; + 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 { + // 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 { const { app, protocol, sslKey, sslCert } = this; @@ -443,27 +226,60 @@ export abstract class AbstractServer { } async start(): Promise { - 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; } } diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 27489eaddd..14e4f56a13 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -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; diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 429228e90f..a6c31307a6 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -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 { - 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 { + async getWebhookMethods(path: string): Promise { 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}"`, ); } diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 349fc807ec..a6c9c0e804 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -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; } +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; + executeWebhook(req: WebhookRequest, res: Response): Promise; +} + export interface IDiagnosticInfo { versionCli: string; databaseType: DatabaseType; diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index c759fd5bcd..98089d56a6 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -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); } } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index cfbd897b47..01110021c0 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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( diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index b9fb05837d..a16d47099b 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -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 { - // 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 { + async getWebhookMethods(path: string): Promise { const webhookMethods = this.activeWebhooks.getWebhookMethods(path); if (!webhookMethods.length) { // The requested webhook is not registered diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index f7857aa547..617dc98ed6 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -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 { - 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 { - 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, diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 0184ad7835..6e37b35b13 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -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; } } diff --git a/packages/cli/src/WebhookServer.ts b/packages/cli/src/WebhookServer.ts index efae9be78b..1c14014eca 100644 --- a/packages/cli/src/WebhookServer.ts +++ b/packages/cli/src/WebhookServer.ts @@ -1,8 +1,3 @@ import { AbstractServer } from '@/AbstractServer'; -export class WebhookServer extends AbstractServer { - async configure() { - this.setupWebhookEndpoint(); - this.setupWaitingWebhookEndpoint(); - } -} +export class WebhookServer extends AbstractServer {} diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 5b250b572e..351b31d853 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -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(); } diff --git a/packages/cli/src/databases/entities/WebhookEntity.ts b/packages/cli/src/databases/entities/WebhookEntity.ts index 208de86f20..977d87335f 100644 --- a/packages/cli/src/databases/entities/WebhookEntity.ts +++ b/packages/cli/src/databases/entities/WebhookEntity.ts @@ -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; diff --git a/packages/cli/src/middlewares/bodyParser.ts b/packages/cli/src/middlewares/bodyParser.ts new file mode 100644 index 0000000000..a8c5c1b970 --- /dev/null +++ b/packages/cli/src/middlewares/bodyParser.ts @@ -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(req.rawBody.toString(req.encoding)); + } catch (error) { + res.status(400).send({ error: 'Failed to parse request body' }); + return; + } + } else { + req.body = {}; + } + } + + next(); +}; diff --git a/packages/cli/src/middlewares/index.ts b/packages/cli/src/middlewares/index.ts index e287c326a2..e578155a0a 100644 --- a/packages/cli/src/middlewares/index.ts +++ b/packages/cli/src/middlewares/index.ts @@ -1,2 +1,3 @@ export * from './auth'; +export * from './bodyParser'; export * from './cors'; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index cf86bcda6b..80f6c70acc 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -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) { diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts new file mode 100644 index 0000000000..f03740dd02 --- /dev/null +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -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( + 'value', + ); + 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, + }, + }; + } + } +}); diff --git a/packages/cli/test/unit/webhooks.test.ts b/packages/cli/test/unit/webhooks.test.ts new file mode 100644 index 0000000000..804d3d8d36 --- /dev/null +++ b/packages/cli/test/unit/webhooks.test.ts @@ -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(); + response.responseCode = status; + response.data = data; + return response; + }; + }); +}); diff --git a/packages/nodes-base/nodes/JotForm/JotFormTrigger.node.ts b/packages/nodes-base/nodes/JotForm/JotFormTrigger.node.ts index bb2b31b602..25f13ab4aa 100644 --- a/packages/nodes-base/nodes/JotForm/JotFormTrigger.node.ts +++ b/packages/nodes-base/nodes/JotForm/JotFormTrigger.node.ts @@ -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 { - 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(data.rawRequest as string); - data.rawRequest = rawRequest; + const rawRequest = jsonParse(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( - 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(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)], + }; } } diff --git a/packages/nodes-base/nodes/Webhook/Webhook.node.ts b/packages/nodes-base/nodes/Webhook/Webhook.node.ts index 2e8546bac5..fb870c7169 100644 --- a/packages/nodes-base/nodes/Webhook/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook/Webhook.node.ts @@ -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((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 { 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]] }; diff --git a/packages/nodes-base/nodes/Webhook/test/Webhook.formdata.test.json b/packages/nodes-base/nodes/Webhook/test/Webhook.formdata.test.json new file mode 100644 index 0000000000..94449c295e --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/test/Webhook.formdata.test.json @@ -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"] + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts b/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts new file mode 100644 index 0000000000..bb903fd934 --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts @@ -0,0 +1,4 @@ +import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers'; +const workflows = getWorkflowFilenames(__dirname); + +describe('Test Webhook Node', () => testWorkflows(workflows)); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 49356d63e3..01fca14b5a 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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", diff --git a/packages/nodes-base/test/nodes/ExecuteWorkflow.ts b/packages/nodes-base/test/nodes/ExecuteWorkflow.ts index ed603b3150..a7814f04cd 100644 --- a/packages/nodes-base/test/nodes/ExecuteWorkflow.ts +++ b/packages/nodes-base/test/nodes/ExecuteWorkflow.ts @@ -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 }; } diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index dc359391dd..c038f7621d 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -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(filePath); + const workflowData = readJsonFileSync>( + 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; }; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 3b08473fca..2c6dd39386 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -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; + files: Record; + } + >; +} + 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'; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index f13c9a1f66..453b32535a 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -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, diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 43c3eadef5..f2a6ac66e6 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -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; + _body: boolean; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e18a27f7b..7a77a86f9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}