mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 16:44:07 -08:00
c08c5cc37b
Ensure all errors in `cli` are `ApplicationError` or children of it and contain no variables in the message, to continue normalizing all the errors we report to Sentry Follow-up to: https://github.com/n8n-io/n8n/pull/7839
304 lines
8.4 KiB
TypeScript
304 lines
8.4 KiB
TypeScript
import type express from 'express';
|
|
import { Service } from 'typedi';
|
|
|
|
import {
|
|
type IWebhookData,
|
|
type IWorkflowExecuteAdditionalData,
|
|
type IHttpRequestMethods,
|
|
type Workflow,
|
|
type WorkflowActivateMode,
|
|
type WorkflowExecuteMode,
|
|
ApplicationError,
|
|
} from 'n8n-workflow';
|
|
|
|
import { ActiveWebhooks } from '@/ActiveWebhooks';
|
|
import type {
|
|
IResponseCallbackData,
|
|
IWebhookManager,
|
|
IWorkflowDb,
|
|
WebhookAccessControlOptions,
|
|
WebhookRequest,
|
|
} from '@/Interfaces';
|
|
import { Push } from '@/push';
|
|
import { NodeTypes } from '@/NodeTypes';
|
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
|
import { webhookNotFoundErrorMessage } from './utils';
|
|
import { NotFoundError } from './errors/response-errors/not-found.error';
|
|
|
|
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 implements IWebhookManager {
|
|
private testWebhookData: {
|
|
[key: string]: {
|
|
sessionId?: string;
|
|
timeout: NodeJS.Timeout;
|
|
workflowData: IWorkflowDb;
|
|
workflow: Workflow;
|
|
destinationNode?: string;
|
|
};
|
|
} = {};
|
|
|
|
constructor(
|
|
private readonly activeWebhooks: ActiveWebhooks,
|
|
private readonly push: Push,
|
|
private readonly nodeTypes: NodeTypes,
|
|
) {
|
|
activeWebhooks.testWebhooks = true;
|
|
}
|
|
|
|
/**
|
|
* Executes a test-webhook and returns the data. It also makes sure that the
|
|
* data gets additionally send to the UI. After the request got handled it
|
|
* automatically remove the test-webhook.
|
|
*/
|
|
async executeWebhook(
|
|
request: WebhookRequest,
|
|
response: express.Response,
|
|
): Promise<IResponseCallbackData> {
|
|
const httpMethod = request.method;
|
|
let path = request.params.path;
|
|
|
|
// Reset request parameters
|
|
request.params = {} as WebhookRequest['params'];
|
|
|
|
// Remove trailing slash
|
|
if (path.endsWith('/')) {
|
|
path = path.slice(0, -1);
|
|
}
|
|
|
|
const { activeWebhooks, push, testWebhookData } = this;
|
|
|
|
let webhookData: IWebhookData | undefined = activeWebhooks.get(httpMethod, path);
|
|
|
|
// check if path is dynamic
|
|
if (webhookData === undefined) {
|
|
const pathElements = path.split('/');
|
|
const webhookId = pathElements.shift();
|
|
|
|
webhookData = activeWebhooks.get(httpMethod, pathElements.join('/'), webhookId);
|
|
if (webhookData === undefined) {
|
|
// The requested webhook is not registered
|
|
const methods = await this.getWebhookMethods(path);
|
|
throw new NotFoundError(
|
|
webhookNotFoundErrorMessage(path, httpMethod, methods),
|
|
WEBHOOK_TEST_UNREGISTERED_HINT,
|
|
);
|
|
}
|
|
|
|
path = webhookData.path;
|
|
// extracting params from path
|
|
path.split('/').forEach((ele, index) => {
|
|
if (ele.startsWith(':')) {
|
|
// write params to req.params
|
|
// @ts-ignore
|
|
request.params[ele.slice(1)] = pathElements[index];
|
|
}
|
|
});
|
|
}
|
|
|
|
const { workflowId } = webhookData;
|
|
const webhookKey = `${activeWebhooks.getWebhookKey(
|
|
webhookData.httpMethod,
|
|
webhookData.path,
|
|
webhookData.webhookId,
|
|
)}|${workflowId}`;
|
|
|
|
// TODO: Clean that duplication up one day and improve code generally
|
|
if (testWebhookData[webhookKey] === undefined) {
|
|
// The requested webhook is not registered
|
|
const methods = await this.getWebhookMethods(path);
|
|
throw new NotFoundError(
|
|
webhookNotFoundErrorMessage(path, httpMethod, methods),
|
|
WEBHOOK_TEST_UNREGISTERED_HINT,
|
|
);
|
|
}
|
|
|
|
const { destinationNode, sessionId, workflow, workflowData, timeout } =
|
|
testWebhookData[webhookKey];
|
|
|
|
// Get the node which has the webhook defined to know where to start from and to
|
|
// get additional data
|
|
const workflowStartNode = workflow.getNode(webhookData.node);
|
|
if (workflowStartNode === null) {
|
|
throw new NotFoundError('Could not find node to process webhook.');
|
|
}
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
const executionMode = 'manual';
|
|
const executionId = await WebhookHelpers.executeWebhook(
|
|
workflow,
|
|
webhookData!,
|
|
workflowData,
|
|
workflowStartNode,
|
|
executionMode,
|
|
sessionId,
|
|
undefined,
|
|
undefined,
|
|
request,
|
|
response,
|
|
(error: Error | null, data: IResponseCallbackData) => {
|
|
if (error !== null) reject(error);
|
|
else resolve(data);
|
|
},
|
|
destinationNode,
|
|
);
|
|
|
|
// The workflow did not run as the request was probably setup related
|
|
// or a ping so do not resolve the promise and wait for the real webhook
|
|
// request instead.
|
|
if (executionId === undefined) return;
|
|
|
|
// Inform editor-ui that webhook got received
|
|
if (sessionId !== undefined) {
|
|
push.send('testWebhookReceived', { workflowId, executionId }, sessionId);
|
|
}
|
|
} catch {}
|
|
|
|
// Delete webhook also if an error is thrown
|
|
if (timeout) clearTimeout(timeout);
|
|
delete testWebhookData[webhookKey];
|
|
|
|
await activeWebhooks.removeWorkflow(workflow);
|
|
});
|
|
}
|
|
|
|
async getWebhookMethods(path: string): Promise<IHttpRequestMethods[]> {
|
|
const webhookMethods = this.activeWebhooks.getWebhookMethods(path);
|
|
if (!webhookMethods.length) {
|
|
// The requested webhook is not registered
|
|
throw new NotFoundError(webhookNotFoundErrorMessage(path), WEBHOOK_TEST_UNREGISTERED_HINT);
|
|
}
|
|
|
|
return webhookMethods;
|
|
}
|
|
|
|
async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) {
|
|
const webhookKey = Object.keys(this.testWebhookData).find(
|
|
(key) => key.includes(path) && key.startsWith(httpMethod),
|
|
);
|
|
if (!webhookKey) return;
|
|
|
|
const { workflow } = this.testWebhookData[webhookKey];
|
|
const webhookNode = Object.values(workflow.nodes).find(
|
|
({ type, parameters, typeVersion }) =>
|
|
parameters?.path === path &&
|
|
(parameters?.httpMethod ?? 'GET') === httpMethod &&
|
|
'webhook' in this.nodeTypes.getByNameAndVersion(type, typeVersion),
|
|
);
|
|
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
|
|
}
|
|
|
|
/**
|
|
* Checks if it has to wait for webhook data to execute the workflow.
|
|
* If yes it waits for it and resolves with the result of the workflow if not it simply resolves with undefined
|
|
*/
|
|
async needsWebhookData(
|
|
workflowData: IWorkflowDb,
|
|
workflow: Workflow,
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
mode: WorkflowExecuteMode,
|
|
activation: WorkflowActivateMode,
|
|
sessionId?: string,
|
|
destinationNode?: string,
|
|
): Promise<boolean> {
|
|
const webhooks = WebhookHelpers.getWorkflowWebhooks(
|
|
workflow,
|
|
additionalData,
|
|
destinationNode,
|
|
true,
|
|
);
|
|
if (!webhooks.find((webhook) => webhook.webhookDescription.restartWebhook !== true)) {
|
|
// No webhooks found to start a workflow
|
|
return false;
|
|
}
|
|
|
|
if (workflow.id === undefined) {
|
|
throw new ApplicationError(
|
|
'Webhooks can only be added for saved workflows as an ID is needed',
|
|
);
|
|
}
|
|
|
|
// Remove test-webhooks automatically if they do not get called (after 120 seconds)
|
|
const timeout = setTimeout(() => {
|
|
this.cancelTestWebhook(workflowData.id);
|
|
}, 120000);
|
|
|
|
const { activeWebhooks, testWebhookData } = this;
|
|
|
|
let key: string;
|
|
const activatedKey: string[] = [];
|
|
|
|
for (const webhookData of webhooks) {
|
|
key = `${activeWebhooks.getWebhookKey(
|
|
webhookData.httpMethod,
|
|
webhookData.path,
|
|
webhookData.webhookId,
|
|
)}|${workflowData.id}`;
|
|
|
|
activatedKey.push(key);
|
|
|
|
testWebhookData[key] = {
|
|
sessionId,
|
|
timeout,
|
|
workflow,
|
|
workflowData,
|
|
destinationNode,
|
|
};
|
|
|
|
try {
|
|
await activeWebhooks.add(workflow, webhookData, mode, activation);
|
|
} catch (error) {
|
|
activatedKey.forEach((deleteKey) => delete testWebhookData[deleteKey]);
|
|
|
|
await activeWebhooks.removeWorkflow(workflow);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Removes a test webhook of the workflow with the given id
|
|
*
|
|
*/
|
|
cancelTestWebhook(workflowId: string): boolean {
|
|
let foundWebhook = false;
|
|
const { activeWebhooks, push, testWebhookData } = this;
|
|
|
|
for (const webhookKey of Object.keys(testWebhookData)) {
|
|
const { sessionId, timeout, workflow, workflowData } = testWebhookData[webhookKey];
|
|
|
|
if (workflowData.id !== workflowId) {
|
|
continue;
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
|
|
// Inform editor-ui that webhook got received
|
|
if (sessionId !== undefined) {
|
|
try {
|
|
push.send('testWebhookDeleted', { workflowId }, sessionId);
|
|
} catch {
|
|
// Could not inform editor, probably is not connected anymore. So simply go on.
|
|
}
|
|
}
|
|
|
|
// Remove the webhook
|
|
delete testWebhookData[webhookKey];
|
|
|
|
if (!foundWebhook) {
|
|
// As it removes all webhooks of the workflow execute only once
|
|
void activeWebhooks.removeWorkflow(workflow);
|
|
}
|
|
|
|
foundWebhook = true;
|
|
}
|
|
|
|
return foundWebhook;
|
|
}
|
|
}
|