2023-01-27 05:56:56 -08:00
|
|
|
import type express from 'express';
|
2023-02-21 10:21:56 -08:00
|
|
|
import { Service } from 'typedi';
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-11-29 03:25:10 -08:00
|
|
|
import {
|
|
|
|
type IWebhookData,
|
|
|
|
type IWorkflowExecuteAdditionalData,
|
|
|
|
type IHttpRequestMethods,
|
|
|
|
type Workflow,
|
|
|
|
type WorkflowActivateMode,
|
|
|
|
type WorkflowExecuteMode,
|
2023-12-19 08:32:02 -08:00
|
|
|
WebhookPathTakenError,
|
2019-06-23 03:35:23 -07:00
|
|
|
} from 'n8n-workflow';
|
2023-02-21 10:21:56 -08:00
|
|
|
|
2023-08-01 08:32:30 -07:00
|
|
|
import type {
|
|
|
|
IResponseCallbackData,
|
|
|
|
IWebhookManager,
|
|
|
|
IWorkflowDb,
|
2023-12-19 08:32:02 -08:00
|
|
|
RegisteredWebhook,
|
2023-11-22 08:49:56 -08:00
|
|
|
WebhookAccessControlOptions,
|
2023-08-01 08:32:30 -07:00
|
|
|
WebhookRequest,
|
|
|
|
} from '@/Interfaces';
|
2023-02-21 10:21:56 -08:00
|
|
|
import { Push } from '@/push';
|
2023-11-22 08:49:56 -08:00
|
|
|
import { NodeTypes } from '@/NodeTypes';
|
2022-11-09 06:25:00 -08:00
|
|
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
2023-11-28 01:19:27 -08:00
|
|
|
import { NotFoundError } from './errors/response-errors/not-found.error';
|
2023-12-19 08:32:02 -08:00
|
|
|
import { TIME } from './constants';
|
|
|
|
import { WorkflowMissingIdError } from './errors/workflow-missing-id.error';
|
|
|
|
import { WebhookNotFoundError } from './errors/response-errors/webhook-not-found.error';
|
|
|
|
import * as NodeExecuteFunctions from 'n8n-core';
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-02-21 10:21:56 -08:00
|
|
|
@Service()
|
2023-08-01 08:32:30 -07:00
|
|
|
export class TestWebhooks implements IWebhookManager {
|
2023-07-28 09:28:17 -07:00
|
|
|
constructor(
|
2023-11-22 08:49:56 -08:00
|
|
|
private readonly push: Push,
|
|
|
|
private readonly nodeTypes: NodeTypes,
|
2023-12-19 08:32:02 -08:00
|
|
|
) {}
|
|
|
|
|
|
|
|
private registeredWebhooks: { [webhookKey: string]: RegisteredWebhook } = {};
|
|
|
|
|
|
|
|
private workflowWebhooks: { [workflowId: string]: IWebhookData[] } = {};
|
|
|
|
|
|
|
|
private webhookUrls: { [webhookUrl: string]: IWebhookData[] } = {};
|
2019-06-23 03:35:23 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2023-08-01 08:32:30 -07:00
|
|
|
async executeWebhook(
|
|
|
|
request: WebhookRequest,
|
2019-06-23 03:35:23 -07:00
|
|
|
response: express.Response,
|
|
|
|
): Promise<IResponseCallbackData> {
|
2023-08-01 08:32:30 -07:00
|
|
|
const httpMethod = request.method;
|
2021-01-28 06:44:10 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
let path = request.params.path.endsWith('/')
|
|
|
|
? request.params.path.slice(0, -1)
|
|
|
|
: request.params.path;
|
|
|
|
|
2023-08-25 04:28:32 -07:00
|
|
|
request.params = {} as WebhookRequest['params'];
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
let webhook = this.getActiveWebhook(httpMethod, path);
|
2021-02-09 00:14:40 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
if (!webhook) {
|
|
|
|
// no static webhook, so check if dynamic
|
|
|
|
// e.g. `/webhook-test/<uuid>/user/:id/create`
|
2023-02-10 06:02:47 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
const [webhookId, ...segments] = path.split('/');
|
2021-02-09 00:14:40 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
webhook = this.getActiveWebhook(httpMethod, segments.join('/'), webhookId);
|
2023-07-31 02:00:48 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
if (!webhook)
|
|
|
|
throw new WebhookNotFoundError({
|
|
|
|
path,
|
|
|
|
httpMethod,
|
|
|
|
webhookMethods: await this.getWebhookMethods(path),
|
|
|
|
});
|
2021-01-28 06:44:10 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
path = webhook.path;
|
|
|
|
|
|
|
|
path.split('/').forEach((segment, index) => {
|
|
|
|
if (segment.startsWith(':')) {
|
|
|
|
request.params[segment.slice(1)] = segments[index];
|
2021-01-23 11:00:32 -08:00
|
|
|
}
|
|
|
|
});
|
2019-06-23 03:35:23 -07:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
const key = [
|
|
|
|
this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId),
|
|
|
|
webhook.workflowId,
|
|
|
|
].join('|');
|
2020-01-22 15:06:43 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
if (!(key in this.registeredWebhooks))
|
|
|
|
throw new WebhookNotFoundError({
|
|
|
|
path,
|
|
|
|
httpMethod,
|
|
|
|
webhookMethods: await this.getWebhookMethods(path),
|
|
|
|
});
|
|
|
|
|
|
|
|
const { destinationNode, sessionId, workflow, workflowEntity, timeout } =
|
|
|
|
this.registeredWebhooks[key];
|
2020-01-22 15:06:43 -08:00
|
|
|
|
2019-06-23 03:35:23 -07:00
|
|
|
// Get the node which has the webhook defined to know where to start from and to
|
|
|
|
// get additional data
|
2023-12-19 08:32:02 -08:00
|
|
|
const workflowStartNode = workflow.getNode(webhook.node);
|
2019-06-23 03:35:23 -07:00
|
|
|
if (workflowStartNode === null) {
|
2023-11-28 01:19:27 -08:00
|
|
|
throw new NotFoundError('Could not find node to process webhook.');
|
2019-06-23 03:35:23 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
|
|
try {
|
|
|
|
const executionMode = 'manual';
|
2021-08-21 05:11:32 -07:00
|
|
|
const executionId = await WebhookHelpers.executeWebhook(
|
|
|
|
workflow,
|
2023-12-19 08:32:02 -08:00
|
|
|
webhook!,
|
|
|
|
workflowEntity,
|
2021-08-21 05:11:32 -07:00
|
|
|
workflowStartNode,
|
|
|
|
executionMode,
|
2023-02-10 06:02:47 -08:00
|
|
|
sessionId,
|
2021-08-21 05:11:32 -07:00
|
|
|
undefined,
|
|
|
|
undefined,
|
|
|
|
request,
|
|
|
|
response,
|
|
|
|
(error: Error | null, data: IResponseCallbackData) => {
|
2023-02-10 06:02:47 -08:00
|
|
|
if (error !== null) reject(error);
|
|
|
|
else resolve(data);
|
2019-06-23 03:35:23 -07:00
|
|
|
},
|
2023-02-10 06:02:47 -08:00
|
|
|
destinationNode,
|
2019-06-23 03:35:23 -07:00
|
|
|
);
|
|
|
|
|
2023-02-10 06:02:47 -08:00
|
|
|
// 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;
|
2019-06-23 03:35:23 -07:00
|
|
|
|
|
|
|
// Inform editor-ui that webhook got received
|
2023-02-10 06:02:47 -08:00
|
|
|
if (sessionId !== undefined) {
|
2023-12-19 08:32:02 -08:00
|
|
|
this.push.send(
|
|
|
|
'testWebhookReceived',
|
|
|
|
{ workflowId: webhook?.workflowId, executionId },
|
|
|
|
sessionId,
|
|
|
|
);
|
2019-06-23 03:35:23 -07:00
|
|
|
}
|
2023-02-13 07:16:53 -08:00
|
|
|
} catch {}
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-02-13 07:16:53 -08:00
|
|
|
// Delete webhook also if an error is thrown
|
|
|
|
if (timeout) clearTimeout(timeout);
|
2023-12-19 08:32:02 -08:00
|
|
|
delete this.registeredWebhooks[key];
|
2023-02-13 07:16:53 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
await this.deactivateWebhooksFor(workflow);
|
2019-06-23 03:35:23 -07:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
async getWebhookMethods(path: string) {
|
|
|
|
const webhookMethods = Object.keys(this.webhookUrls)
|
|
|
|
.filter((key) => key.includes(path))
|
|
|
|
.map((key) => key.split('|')[0] as IHttpRequestMethods);
|
|
|
|
|
|
|
|
if (!webhookMethods.length) throw new WebhookNotFoundError({ path });
|
2020-07-24 07:24:18 -07:00
|
|
|
|
|
|
|
return webhookMethods;
|
|
|
|
}
|
|
|
|
|
2023-11-22 08:49:56 -08:00
|
|
|
async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) {
|
2023-12-19 08:32:02 -08:00
|
|
|
const webhookKey = Object.keys(this.registeredWebhooks).find(
|
2023-11-22 08:49:56 -08:00
|
|
|
(key) => key.includes(path) && key.startsWith(httpMethod),
|
|
|
|
);
|
2023-12-19 08:32:02 -08:00
|
|
|
|
2023-11-22 08:49:56 -08:00
|
|
|
if (!webhookKey) return;
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
const { workflow } = this.registeredWebhooks[webhookKey];
|
2023-11-22 08:49:56 -08:00
|
|
|
const webhookNode = Object.values(workflow.nodes).find(
|
|
|
|
({ type, parameters, typeVersion }) =>
|
|
|
|
parameters?.path === path &&
|
|
|
|
(parameters?.httpMethod ?? 'GET') === httpMethod &&
|
|
|
|
'webhook' in this.nodeTypes.getByNameAndVersion(type, typeVersion),
|
|
|
|
);
|
2023-12-19 08:32:02 -08:00
|
|
|
|
2023-11-22 08:49:56 -08:00
|
|
|
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
async needsWebhook(
|
|
|
|
workflowEntity: IWorkflowDb,
|
2021-03-23 11:08:47 -07:00
|
|
|
workflow: Workflow,
|
|
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
2023-12-19 08:32:02 -08:00
|
|
|
executionMode: WorkflowExecuteMode,
|
|
|
|
activationMode: WorkflowActivateMode,
|
2021-03-23 11:08:47 -07:00
|
|
|
sessionId?: string,
|
|
|
|
destinationNode?: string,
|
2023-12-19 08:32:02 -08:00
|
|
|
) {
|
|
|
|
if (!workflow.id) throw new WorkflowMissingIdError(workflow);
|
|
|
|
|
2021-08-21 05:11:32 -07:00
|
|
|
const webhooks = WebhookHelpers.getWorkflowWebhooks(
|
|
|
|
workflow,
|
|
|
|
additionalData,
|
|
|
|
destinationNode,
|
|
|
|
true,
|
|
|
|
);
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
if (!webhooks.find((w) => w.webhookDescription.restartWebhook !== true)) {
|
|
|
|
return false; // no webhooks found to start a workflow
|
2020-05-03 08:55:14 -07:00
|
|
|
}
|
|
|
|
|
2019-06-23 03:35:23 -07:00
|
|
|
const timeout = setTimeout(() => {
|
2023-12-19 08:32:02 -08:00
|
|
|
this.cancelTestWebhook(workflowEntity.id);
|
|
|
|
}, 2 * TIME.MINUTE);
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
const activatedKeys: string[] = [];
|
2023-02-10 06:02:47 -08:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
for (const webhook of webhooks) {
|
|
|
|
const key = [
|
|
|
|
this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId),
|
|
|
|
workflowEntity.id,
|
|
|
|
].join('|');
|
2023-07-31 02:00:48 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
activatedKeys.push(key);
|
2020-05-30 16:03:58 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
this.registeredWebhooks[key] = {
|
2019-06-23 03:35:23 -07:00
|
|
|
sessionId,
|
|
|
|
timeout,
|
2020-09-16 14:55:34 -07:00
|
|
|
workflow,
|
2023-12-19 08:32:02 -08:00
|
|
|
workflowEntity,
|
2022-12-27 03:50:50 -08:00
|
|
|
destinationNode,
|
2019-06-23 03:35:23 -07:00
|
|
|
};
|
|
|
|
|
2020-10-21 08:50:23 -07:00
|
|
|
try {
|
2023-12-19 08:32:02 -08:00
|
|
|
await this.activateWebhook(workflow, webhook, executionMode, activationMode);
|
2020-10-21 08:50:23 -07:00
|
|
|
} catch (error) {
|
2023-12-19 08:32:02 -08:00
|
|
|
activatedKeys.forEach((ak) => delete this.registeredWebhooks[ak]);
|
|
|
|
|
|
|
|
await this.deactivateWebhooksFor(workflow);
|
2023-07-31 02:00:48 -07:00
|
|
|
|
2020-10-21 08:50:23 -07:00
|
|
|
throw error;
|
|
|
|
}
|
2020-03-20 16:30:03 -07:00
|
|
|
}
|
2020-01-22 15:06:43 -08:00
|
|
|
|
2019-06-23 03:35:23 -07:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
cancelTestWebhook(workflowId: string) {
|
2019-06-23 03:35:23 -07:00
|
|
|
let foundWebhook = false;
|
2023-07-31 02:00:48 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
for (const key of Object.keys(this.registeredWebhooks)) {
|
|
|
|
const { sessionId, timeout, workflow, workflowEntity } = this.registeredWebhooks[key];
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
if (workflowEntity.id !== workflowId) continue;
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-02-10 06:02:47 -08:00
|
|
|
clearTimeout(timeout);
|
2019-06-23 03:35:23 -07:00
|
|
|
|
2023-02-10 06:02:47 -08:00
|
|
|
if (sessionId !== undefined) {
|
2019-06-23 03:35:23 -07:00
|
|
|
try {
|
2023-12-19 08:32:02 -08:00
|
|
|
this.push.send('testWebhookDeleted', { workflowId }, sessionId);
|
2023-02-10 06:02:47 -08:00
|
|
|
} catch {
|
2022-09-02 07:13:17 -07:00
|
|
|
// Could not inform editor, probably is not connected anymore. So simply go on.
|
2019-06-23 03:35:23 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:32:02 -08:00
|
|
|
delete this.registeredWebhooks[key];
|
2020-10-21 08:50:23 -07:00
|
|
|
|
|
|
|
if (!foundWebhook) {
|
|
|
|
// As it removes all webhooks of the workflow execute only once
|
2023-12-19 08:32:02 -08:00
|
|
|
void this.deactivateWebhooksFor(workflow);
|
2020-10-21 08:50:23 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
foundWebhook = true;
|
2019-06-23 03:35:23 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
return foundWebhook;
|
|
|
|
}
|
2023-12-19 08:32:02 -08:00
|
|
|
|
|
|
|
async activateWebhook(
|
|
|
|
workflow: Workflow,
|
|
|
|
webhook: IWebhookData,
|
|
|
|
executionMode: WorkflowExecuteMode,
|
|
|
|
activationMode: WorkflowActivateMode,
|
|
|
|
) {
|
|
|
|
if (!workflow.id) throw new WorkflowMissingIdError(workflow);
|
|
|
|
|
|
|
|
if (webhook.path.endsWith('/')) {
|
|
|
|
webhook.path = webhook.path.slice(0, -1);
|
|
|
|
}
|
|
|
|
|
|
|
|
const key = this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId);
|
|
|
|
|
|
|
|
// check that there is not a webhook already registered with that path/method
|
|
|
|
if (this.webhookUrls[key] && !webhook.webhookId) {
|
|
|
|
throw new WebhookPathTakenError(webhook.node);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.workflowWebhooks[webhook.workflowId] === undefined) {
|
|
|
|
this.workflowWebhooks[webhook.workflowId] = [];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make the webhook available directly because sometimes to create it successfully
|
|
|
|
// it gets called
|
|
|
|
if (!this.webhookUrls[key]) {
|
|
|
|
this.webhookUrls[key] = [];
|
|
|
|
}
|
|
|
|
webhook.isTest = true;
|
|
|
|
this.webhookUrls[key].push(webhook);
|
|
|
|
|
|
|
|
try {
|
|
|
|
await workflow.createWebhookIfNotExists(
|
|
|
|
webhook,
|
|
|
|
NodeExecuteFunctions,
|
|
|
|
executionMode,
|
|
|
|
activationMode,
|
|
|
|
);
|
|
|
|
} catch (error) {
|
|
|
|
// If there was a problem unregister the webhook again
|
|
|
|
if (this.webhookUrls[key].length <= 1) {
|
|
|
|
delete this.webhookUrls[key];
|
|
|
|
} else {
|
|
|
|
this.webhookUrls[key] = this.webhookUrls[key].filter((w) => w.path !== w.path);
|
|
|
|
}
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
this.workflowWebhooks[webhook.workflowId].push(webhook);
|
|
|
|
}
|
|
|
|
|
|
|
|
getActiveWebhook(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) {
|
|
|
|
const webhookKey = this.toWebhookKey(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;
|
|
|
|
}
|
|
|
|
|
|
|
|
toWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) {
|
|
|
|
if (!webhookId) return `${httpMethod}|${path}`;
|
|
|
|
|
|
|
|
if (path.startsWith(webhookId)) {
|
|
|
|
const cutFromIndex = path.indexOf('/') + 1;
|
|
|
|
|
|
|
|
path = path.slice(cutFromIndex);
|
|
|
|
}
|
|
|
|
|
|
|
|
return `${httpMethod}|${webhookId}|${path.split('/').length}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
async deactivateWebhooksFor(workflow: Workflow) {
|
|
|
|
const workflowId = workflow.id;
|
|
|
|
|
|
|
|
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.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update');
|
|
|
|
|
|
|
|
const key = this.toWebhookKey(
|
|
|
|
webhookData.httpMethod,
|
|
|
|
webhookData.path,
|
|
|
|
webhookData.webhookId,
|
|
|
|
);
|
|
|
|
|
|
|
|
delete this.webhookUrls[key];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Remove also the workflow-webhook entry
|
|
|
|
delete this.workflowWebhooks[workflowId];
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
2019-06-23 03:35:23 -07:00
|
|
|
}
|