Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(core): Extract webhook request handler to own file #10301

Merged
merged 4 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions packages/cli/src/AbstractServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares';
import { WaitingForms } from '@/WaitingForms';
import { TestWebhooks } from '@/webhooks/TestWebhooks';
import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks';
import { webhookRequestHandler } from '@/webhooks/WebhookHelpers';
import { createWebhookHandlerFor } from '@/webhooks/WebhookRequestHandler';
import { ActiveWebhooks } from '@/webhooks/ActiveWebhooks';
import { generateHostInstanceId } from './databases/utils/generators';
import { Logger } from '@/Logger';
Expand Down Expand Up @@ -181,33 +181,32 @@ export abstract class AbstractServer {

// Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests
if (this.webhooksEnabled) {
const activeWebhooks = Container.get(ActiveWebhooks);

const activeWebhooksRequestHandler = createWebhookHandlerFor(Container.get(ActiveWebhooks));
// Register a handler for active forms
this.app.all(`/${this.endpointForm}/:path(*)`, webhookRequestHandler(activeWebhooks));
this.app.all(`/${this.endpointForm}/:path(*)`, activeWebhooksRequestHandler);

// Register a handler for active webhooks
this.app.all(`/${this.endpointWebhook}/:path(*)`, webhookRequestHandler(activeWebhooks));
this.app.all(`/${this.endpointWebhook}/:path(*)`, activeWebhooksRequestHandler);

// Register a handler for waiting forms
this.app.all(
`/${this.endpointFormWaiting}/:path/:suffix?`,
webhookRequestHandler(Container.get(WaitingForms)),
createWebhookHandlerFor(Container.get(WaitingForms)),
);

// Register a handler for waiting webhooks
this.app.all(
`/${this.endpointWebhookWaiting}/:path/:suffix?`,
webhookRequestHandler(Container.get(WaitingWebhooks)),
createWebhookHandlerFor(Container.get(WaitingWebhooks)),
);
}

if (this.testWebhooksEnabled) {
const testWebhooks = Container.get(TestWebhooks);
const testWebhooksRequestHandler = createWebhookHandlerFor(Container.get(TestWebhooks));

// Register a handler
this.app.all(`/${this.endpointFormTest}/:path(*)`, webhookRequestHandler(testWebhooks));
this.app.all(`/${this.endpointWebhookTest}/:path(*)`, webhookRequestHandler(testWebhooks));
this.app.all(`/${this.endpointFormTest}/:path(*)`, testWebhooksRequestHandler);
this.app.all(`/${this.endpointWebhookTest}/:path(*)`, testWebhooksRequestHandler);
}

// Block bots from scanning the application
Expand Down
100 changes: 1 addition & 99 deletions packages/cli/src/webhooks/WebhookHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import type {
IDataObject,
IDeferredPromise,
IExecuteData,
IHttpRequestMethods,
IN8nHttpFullResponse,
INode,
IPinData,
Expand All @@ -41,13 +40,7 @@ import {
NodeHelpers,
} from 'n8n-workflow';

import type {
IWebhookResponseCallbackData,
IWebhookManager,
WebhookCORSRequest,
WebhookRequest,
} from './webhook.types';
import * as ResponseHelper from '@/ResponseHelper';
import type { IWebhookResponseCallbackData, WebhookRequest } from './webhook.types';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import { WorkflowRunner } from '@/WorkflowRunner';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
Expand All @@ -62,97 +55,6 @@ import { UnprocessableRequestError } from '@/errors/response-errors/unprocessabl
import type { Project } from '@/databases/entities/Project';
import type { IExecutionDb, IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces';

export const WEBHOOK_METHODS: IHttpRequestMethods[] = [
'DELETE',
'GET',
'HEAD',
'PATCH',
'POST',
'PUT',
];

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);
}
}

const requestedMethod =
method === 'OPTIONS'
? (req.headers['access-control-request-method'] as IHttpRequestMethods)
: method;
if (webhookManager.findAccessControlOptions && requestedMethod) {
const options = await webhookManager.findAccessControlOptions(path, requestedMethod);
const { allowedOrigins } = options ?? {};

if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) {
const originsList = allowedOrigins.split(',');
const defaultOrigin = originsList[0];

if (originsList.length === 1) {
res.header('Access-Control-Allow-Origin', defaultOrigin);
}

if (originsList.includes(req.headers.origin as string)) {
res.header('Access-Control-Allow-Origin', req.headers.origin);
} else {
res.header('Access-Control-Allow-Origin', defaultOrigin);
}
} else {
res.header('Access-Control-Allow-Origin', req.headers.origin);
}

if (method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '300');
const requestedHeaders = req.headers['access-control-request-headers'];
if (requestedHeaders?.length) {
res.header('Access-Control-Allow-Headers', requestedHeaders);
}
}
}
}

if (method === 'OPTIONS') {
return ResponseHelper.sendSuccessResponse(res, {}, true, 204);
}

let response: IWebhookResponseCallbackData;
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,
response.headers,
);
}
};

/**
* Returns all the webhooks which should be created for the given workflow
*/
Expand Down
119 changes: 119 additions & 0 deletions packages/cli/src/webhooks/WebhookRequestHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type express from 'express';
import type { IHttpRequestMethods } from 'n8n-workflow';
import type {
IWebhookManager,
WebhookOptionsRequest,
WebhookRequest,
} from '@/webhooks/webhook.types';
import * as ResponseHelper from '@/ResponseHelper';

const WEBHOOK_METHODS: IHttpRequestMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];

class WebhookRequestHandler {
constructor(private readonly webhookManager: IWebhookManager) {}

/**
* Handles an incoming webhook request. Handles CORS and delegates the
* request to the webhook manager to execute the webhook.
*/
async handleRequest(req: WebhookRequest | WebhookOptionsRequest, res: express.Response) {
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) {
const corsSetupError = await this.setupCorsHeaders(req, res);
if (corsSetupError) {
return ResponseHelper.sendErrorResponse(res, corsSetupError);
}
}

if (method === 'OPTIONS') {
return ResponseHelper.sendSuccessResponse(res, {}, true, 204);
}

try {
const response = await this.webhookManager.executeWebhook(req, res);

// Don't respond, if already responded
if (response.noWebhookResponse !== true) {
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}
} catch (error) {
return ResponseHelper.sendErrorResponse(res, error as Error);
}
}

private async setupCorsHeaders(
req: WebhookRequest | WebhookOptionsRequest,
res: express.Response,
): Promise<Error | null> {
const method = req.method;
const { path } = req.params;

if (this.webhookManager.getWebhookMethods) {
try {
const allowedMethods = await this.webhookManager.getWebhookMethods(path);
res.header('Access-Control-Allow-Methods', ['OPTIONS', ...allowedMethods].join(', '));
} catch (error) {
return error as Error;
}
}

const requestedMethod =
method === 'OPTIONS'
? (req.headers['access-control-request-method'] as IHttpRequestMethods)
: method;
if (this.webhookManager.findAccessControlOptions && requestedMethod) {
const options = await this.webhookManager.findAccessControlOptions(path, requestedMethod);
const { allowedOrigins } = options ?? {};

if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) {
const originsList = allowedOrigins.split(',');
const defaultOrigin = originsList[0];

if (originsList.length === 1) {
res.header('Access-Control-Allow-Origin', defaultOrigin);
}

if (originsList.includes(req.headers.origin as string)) {
res.header('Access-Control-Allow-Origin', req.headers.origin);
} else {
res.header('Access-Control-Allow-Origin', defaultOrigin);
}
} else {
res.header('Access-Control-Allow-Origin', req.headers.origin);
}

if (method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '300');
const requestedHeaders = req.headers['access-control-request-headers'];
if (requestedHeaders?.length) {
res.header('Access-Control-Allow-Headers', requestedHeaders);
}
}
}

return null;
}
}

export function createWebhookHandlerFor(webhookManager: IWebhookManager) {
const handler = new WebhookRequestHandler(webhookManager);

return async (req: WebhookRequest | WebhookOptionsRequest, res: express.Response) => {
await handler.handleRequest(req, res);
};
}
Loading
Loading