n8n/packages/cli/src/ActiveWebhooks.ts
कारतोफ्फेलस्क्रिप्ट™ 31d8f478ee
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
2023-08-01 17:32:30 +02:00

219 lines
5.5 KiB
TypeScript

import { Service } from 'typedi';
import type {
IWebhookData,
IHttpRequestMethods,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import * as NodeExecuteFunctions from 'n8n-core';
@Service()
export class ActiveWebhooks {
private workflowWebhooks: {
[key: string]: IWebhookData[];
} = {};
private webhookUrls: {
[key: string]: IWebhookData[];
} = {};
testWebhooks = false;
/**
* Adds a new webhook
*
*/
async add(
workflow: Workflow,
webhookData: IWebhookData,
mode: WorkflowExecuteMode,
activation: WorkflowActivateMode,
): Promise<void> {
if (workflow.id === undefined) {
throw new Error('Webhooks can only be added for saved workflows as an id is needed!');
}
if (webhookData.path.endsWith('/')) {
webhookData.path = webhookData.path.slice(0, -1);
}
const webhookKey = this.getWebhookKey(
webhookData.httpMethod,
webhookData.path,
webhookData.webhookId,
);
// check that there is not a webhook already registered with that path/method
if (this.webhookUrls[webhookKey] && !webhookData.webhookId) {
throw new Error(
`The URL path that the "${webhookData.node}" node uses is already taken. Please change it to something else.`,
);
}
if (this.workflowWebhooks[webhookData.workflowId] === undefined) {
this.workflowWebhooks[webhookData.workflowId] = [];
}
// Make the webhook available directly because sometimes to create it successfully
// it gets called
if (!this.webhookUrls[webhookKey]) {
this.webhookUrls[webhookKey] = [];
}
this.webhookUrls[webhookKey].push(webhookData);
try {
const webhookExists = await workflow.runWebhookMethod(
'checkExists',
webhookData,
NodeExecuteFunctions,
mode,
activation,
this.testWebhooks,
);
if (webhookExists !== true) {
// If webhook does not exist yet create it
await workflow.runWebhookMethod(
'create',
webhookData,
NodeExecuteFunctions,
mode,
activation,
this.testWebhooks,
);
}
} catch (error) {
// If there was a problem unregister the webhook again
if (this.webhookUrls[webhookKey].length <= 1) {
delete this.webhookUrls[webhookKey];
} else {
this.webhookUrls[webhookKey] = this.webhookUrls[webhookKey].filter(
(webhook) => webhook.path !== webhookData.path,
);
}
throw error;
}
this.workflowWebhooks[webhookData.workflowId].push(webhookData);
}
/**
* Returns webhookData if a webhook with matches is currently registered
*
* @param {(string | undefined)} webhookId
*/
get(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): IWebhookData | undefined {
const webhookKey = this.getWebhookKey(httpMethod, path, webhookId);
if (this.webhookUrls[webhookKey] === undefined) {
return undefined;
}
let webhook: IWebhookData | undefined;
let maxMatches = 0;
const pathElementsSet = new Set(path.split('/'));
// check if static elements match in path
// if more results have been returned choose the one with the most static-route matches
this.webhookUrls[webhookKey].forEach((dynamicWebhook) => {
const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':'));
const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle));
if (allStaticExist && staticElements.length > maxMatches) {
maxMatches = staticElements.length;
webhook = dynamicWebhook;
}
// handle routes with no static elements
else if (staticElements.length === 0 && !webhook) {
webhook = dynamicWebhook;
}
});
return webhook;
}
/**
* Gets all request methods associated with a single webhook
*/
getWebhookMethods(path: string): IHttpRequestMethods[] {
return Object.keys(this.webhookUrls)
.filter((key) => key.includes(path))
.map((key) => key.split('|')[0] as IHttpRequestMethods);
}
/**
* Returns the ids of all the workflows which have active webhooks
*
*/
getWorkflowIds(): string[] {
return Object.keys(this.workflowWebhooks);
}
/**
* Returns key to uniquely identify a webhook
*
* @param {(string | undefined)} webhookId
*/
getWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): string {
if (webhookId) {
if (path.startsWith(webhookId)) {
const cutFromIndex = path.indexOf('/') + 1;
path = path.slice(cutFromIndex);
}
return `${httpMethod}|${webhookId}|${path.split('/').length}`;
}
return `${httpMethod}|${path}`;
}
/**
* Removes all webhooks of a workflow
*
*/
async removeWorkflow(workflow: Workflow): Promise<boolean> {
const workflowId = workflow.id!.toString();
if (this.workflowWebhooks[workflowId] === undefined) {
// If it did not exist then there is nothing to remove
return false;
}
const webhooks = this.workflowWebhooks[workflowId];
const mode = 'internal';
// Go through all the registered webhooks of the workflow and remove them
for (const webhookData of webhooks) {
await workflow.runWebhookMethod(
'delete',
webhookData,
NodeExecuteFunctions,
mode,
'update',
this.testWebhooks,
);
delete this.webhookUrls[
this.getWebhookKey(webhookData.httpMethod, webhookData.path, webhookData.webhookId)
];
}
// Remove also the workflow-webhook entry
delete this.workflowWebhooks[workflowId];
return true;
}
/**
* Removes all the webhooks of the given workflows
*/
async removeAll(workflows: Workflow[]): Promise<void> {
const removePromises = [];
for (const workflow of workflows) {
removePromises.push(this.removeWorkflow(workflow));
}
await Promise.all(removePromises);
}
}