n8n-nodes-elearning-magic 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,9 @@
1
- import type { INodeType, INodeTypeDescription, IWebhookFunctions, IWebhookResponseData } from 'n8n-workflow';
2
- export declare class ElearningMagicTrigger implements INodeType {
1
+ import type { IWebhookFunctions, INodeTypeDescription, IWebhookResponseData } from 'n8n-workflow';
2
+ import { Node } from 'n8n-workflow';
3
+ export declare class ElearningMagicTrigger extends Node {
4
+ authPropertyName: string;
3
5
  description: INodeTypeDescription;
4
- webhook(this: IWebhookFunctions): Promise<IWebhookResponseData>;
6
+ webhook(context: IWebhookFunctions): Promise<IWebhookResponseData>;
7
+ private validateAuth;
8
+ private handleBinaryData;
5
9
  }
@@ -1,83 +1,84 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.ElearningMagicTrigger = void 0;
4
- const crypto_1 = require("crypto");
5
- const computeSignature = (secret, rawBody) => {
6
- return (0, crypto_1.createHmac)('sha256', secret).update(rawBody).digest('hex');
7
- };
8
- const safeCompare = (a, b) => {
9
- if (!a || !b || a.length !== b.length) {
10
- return false;
11
- }
12
- try {
13
- return (0, crypto_1.timingSafeEqual)(Buffer.from(a), Buffer.from(b));
14
- }
15
- catch {
16
- return false;
17
- }
18
- };
19
- class ElearningMagicTrigger {
7
+ const fs_1 = require("fs");
8
+ const promises_1 = require("fs/promises");
9
+ const promises_2 = require("stream/promises");
10
+ const tmp_promise_1 = require("tmp-promise");
11
+ const uuid_1 = require("uuid");
12
+ const isbot_1 = __importDefault(require("isbot"));
13
+ const n8n_workflow_1 = require("n8n-workflow");
14
+ const description_1 = require("./description");
15
+ const error_1 = require("./error");
16
+ const utils_1 = require("./utils");
17
+ class ElearningMagicTrigger extends n8n_workflow_1.Node {
20
18
  constructor() {
19
+ super(...arguments);
20
+ this.authPropertyName = 'authentication';
21
21
  this.description = {
22
22
  displayName: 'eLearning Magic',
23
- name: 'elearningMagicTrigger',
24
23
  icon: 'file:elearningMagic.svg',
24
+ name: 'elearningMagicTrigger',
25
25
  group: ['trigger'],
26
- version: 1,
27
- description: 'Receive SCORM wrapper payloads from eLearning Magic via a signed webhook',
26
+ version: [1],
27
+ defaultVersion: 1,
28
+ description: 'Starts the workflow when eLearning Magic calls this webhook',
29
+ eventTriggerDescription: 'Waiting for you to call the Test URL',
30
+ activationMessage: 'You can now make calls to your production webhook URL.',
28
31
  defaults: {
29
32
  name: 'eLearning Magic',
30
33
  },
31
- subtitle: '={{$parameter["path"]}}',
32
- inputs: [],
33
- outputs: ['main'],
34
+ supportsCORS: true,
34
35
  triggerPanel: {
35
- header: 'Webhook URLs',
36
+ header: '',
36
37
  executionsHelp: {
37
- inactive: 'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'Execute workflow\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Activate the workflow, then make requests to the production URL. These executions will show up in the executions list, but not in the editor.',
38
- active: 'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'Execute workflow\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Since the workflow is activated, requests to the production URL will trigger executions. These executions will show up in the executions list, but not in the editor.',
39
- },
40
- activationHint: {
41
- active: 'This node will also trigger automatically on new webhook requests (but those executions won\'t show up here).',
42
- inactive: 'Activate this workflow to have it also run automatically for new webhook requests via the production URL.',
38
+ inactive: "Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'listen' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Publish the workflow, then make requests to the production URL. These executions will show up in the executions list, but not in the editor.",
39
+ active: 'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Since the workflow is activated, you can make requests to the production URL. These executions will show up in the executions list, but not in the editor.',
43
40
  },
41
+ activationHint: "Once you've finished building your workflow, run it without having to click this button by using the production webhook URL.",
44
42
  },
45
- credentials: [
46
- {
47
- name: 'elearningMagicApi',
48
- required: false,
49
- },
50
- ],
51
- webhooks: [
43
+ inputs: [],
44
+ outputs: `={{(${utils_1.configuredOutputs})($parameter)}}`,
45
+ credentials: (0, description_1.credentialsProperty)(this.authPropertyName),
46
+ webhooks: [description_1.defaultWebhookDescription],
47
+ properties: [
52
48
  {
53
- name: 'default',
54
- httpMethod: 'POST',
55
- responseMode: 'onReceived',
56
- path: '={{$parameter["path"]}}',
57
- restartWebhook: true,
49
+ displayName: 'Allow Multiple HTTP Methods',
50
+ name: 'multipleMethods',
51
+ type: 'boolean',
52
+ default: false,
53
+ isNodeSetting: true,
54
+ description: 'Whether to allow the webhook to listen for multiple HTTP methods',
58
55
  },
59
- ],
60
- properties: [
61
56
  {
62
- displayName: '<strong>📌 Webhook URL for your eLearning Magic App:</strong><br><br>' +
63
- 'The production URL below is auto-generated from your n8n base URL (WEBHOOK_URL / N8N_WEBHOOK_URL / N8N_HOST) and the Path value.',
64
- name: 'webhookUrlNotice',
65
- type: 'notice',
66
- default: '',
57
+ ...description_1.httpMethodsProperty,
58
+ displayOptions: {
59
+ show: {
60
+ multipleMethods: [false],
61
+ },
62
+ },
67
63
  },
68
64
  {
69
- displayName: 'Production Webhook URL (auto-generated)',
70
- name: 'productionWebhookUrl',
71
- type: 'string',
72
- default: '={{ ' +
73
- '((($env.WEBHOOK_URL || $env.N8N_WEBHOOK_URL) || ' +
74
- "($env.N8N_HOST ? ('https://' + $env.N8N_HOST + ($env.N8N_PORT ? (':' + $env.N8N_PORT) : '')) : 'https://YOUR-N8N-INSTANCE'))" +
75
- " + '/webhook/' + $parameter['path']" +
76
- ' ) }}',
77
- description: 'Copy this URL into the eLearning Magic app when configuring the connection. Change Path to update it.',
78
- placeholder: 'https://your-n8n-server/webhook/elearning-magic',
79
- typeOptions: {
80
- rows: 2,
65
+ displayName: 'HTTP Methods',
66
+ name: 'httpMethod',
67
+ type: 'multiOptions',
68
+ options: [
69
+ { name: 'DELETE', value: 'DELETE' },
70
+ { name: 'GET', value: 'GET' },
71
+ { name: 'HEAD', value: 'HEAD' },
72
+ { name: 'PATCH', value: 'PATCH' },
73
+ { name: 'POST', value: 'POST' },
74
+ { name: 'PUT', value: 'PUT' },
75
+ ],
76
+ default: ['GET', 'POST'],
77
+ description: 'The HTTP methods to listen to',
78
+ displayOptions: {
79
+ show: {
80
+ multipleMethods: [true],
81
+ },
81
82
  },
82
83
  },
83
84
  {
@@ -85,56 +86,187 @@ class ElearningMagicTrigger {
85
86
  name: 'path',
86
87
  type: 'string',
87
88
  default: 'elearning-magic',
88
- placeholder: 'elearning-magic',
89
- required: true,
90
- description: 'The path segment for the webhook URL',
89
+ placeholder: 'webhook',
90
+ description: "The path to listen to, dynamic values could be specified by using ':', e.g. 'your-path/:dynamic-value'. If dynamic values are set 'webhookId' would be prepended to path.",
91
+ },
92
+ (0, description_1.authenticationProperty)(this.authPropertyName),
93
+ description_1.responseModeProperty,
94
+ description_1.responseModePropertyStreaming,
95
+ {
96
+ displayName: 'Insert a \'Respond to Webhook\' node to control when and how you respond. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook/" target="_blank">More details</a>',
97
+ name: 'webhookNotice',
98
+ type: 'notice',
99
+ displayOptions: {
100
+ show: {
101
+ responseMode: ['responseNode'],
102
+ },
103
+ },
104
+ default: '',
105
+ },
106
+ {
107
+ displayName: 'Insert a node that supports streaming (e.g. \'AI Agent\') and enable streaming to stream directly to the response while the workflow is executed. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook/" target="_blank">More details</a>',
108
+ name: 'webhookStreamingNotice',
109
+ type: 'notice',
110
+ displayOptions: {
111
+ show: {
112
+ responseMode: ['streaming'],
113
+ },
114
+ },
115
+ default: '',
116
+ },
117
+ {
118
+ ...description_1.responseCodeProperty,
119
+ displayOptions: {
120
+ show: {
121
+ '@version': [1],
122
+ },
123
+ hide: {
124
+ responseMode: ['responseNode'],
125
+ },
126
+ },
127
+ },
128
+ description_1.responseDataProperty,
129
+ description_1.responseBinaryPropertyNameProperty,
130
+ {
131
+ displayName: 'If you are sending back a response, add a "Content-Type" response header with the appropriate value to avoid unexpected behavior',
132
+ name: 'contentTypeNotice',
133
+ type: 'notice',
134
+ default: '',
135
+ displayOptions: {
136
+ show: {
137
+ responseMode: ['onReceived'],
138
+ },
139
+ },
140
+ },
141
+ {
142
+ ...description_1.optionsProperty,
143
+ options: [...description_1.optionsProperty.options, description_1.responseCodeOption].sort((a, b) => {
144
+ const nameA = a.displayName.toUpperCase();
145
+ const nameB = b.displayName.toUpperCase();
146
+ if (nameA < nameB)
147
+ return -1;
148
+ if (nameA > nameB)
149
+ return 1;
150
+ return 0;
151
+ }),
91
152
  },
92
153
  ],
93
154
  };
94
155
  }
95
- async webhook() {
96
- const req = this.getRequestObject();
97
- const res = this.getResponseObject();
98
- const credentials = await this.getCredentials('elearningMagicApi');
99
- const signingSecret = (credentials === null || credentials === void 0 ? void 0 : credentials.signingSecret) || '';
100
- const rawBody = req.rawBody !== undefined
101
- ? req.rawBody.toString('utf8')
102
- : JSON.stringify(req.body || {});
103
- const headerSignature = req.headers['x-em-signature'] || '';
104
- let signatureValid = false;
105
- if (headerSignature && signingSecret) {
106
- const computed = computeSignature(signingSecret, rawBody);
107
- signatureValid = safeCompare(computed, headerSignature);
156
+ async webhook(context) {
157
+ var _a, _b, _c, _d;
158
+ const { typeVersion: nodeVersion } = context.getNode();
159
+ const responseMode = context.getNodeParameter('responseMode', 'onReceived');
160
+ (0, utils_1.checkResponseModeConfiguration)(context);
161
+ const options = context.getNodeParameter('options', {});
162
+ const req = context.getRequestObject();
163
+ const resp = context.getResponseObject();
164
+ const requestMethod = req.method;
165
+ if (!(0, utils_1.isIpWhitelisted)(options.ipWhitelist, req.ips, req.ip)) {
166
+ resp.writeHead(403);
167
+ resp.end('IP is not whitelisted to access the webhook!');
168
+ return { noWebhookResponse: true };
108
169
  }
109
- if (!signingSecret) {
110
- res.status(401);
111
- return {
112
- webhookResponse: { error: 'Signing secret is not configured on this node' },
113
- };
170
+ let validationData;
171
+ try {
172
+ if (options.ignoreBots && (0, isbot_1.default)(req.headers['user-agent']))
173
+ throw new error_1.WebhookAuthorizationError(403);
174
+ validationData = await this.validateAuth(context);
114
175
  }
115
- if (!headerSignature || !signatureValid) {
116
- res.status(403);
117
- return {
118
- webhookResponse: { error: 'Invalid or missing X-EM-Signature header' },
119
- };
176
+ catch (error) {
177
+ if (error instanceof error_1.WebhookAuthorizationError) {
178
+ resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
179
+ resp.end(error.message);
180
+ return { noWebhookResponse: true };
181
+ }
182
+ throw error;
183
+ }
184
+ const prepareOutput = (0, utils_1.setupOutputConnection)(context, requestMethod, {
185
+ jwtPayload: validationData,
186
+ });
187
+ if (options.binaryData) {
188
+ return await this.handleBinaryData(context, prepareOutput);
189
+ }
190
+ if (req.contentType === 'multipart/form-data') {
191
+ return await (0, utils_1.handleFormData)(context, prepareOutput);
192
+ }
193
+ if (nodeVersion > 1 && !req.body && !options.rawBody) {
194
+ try {
195
+ return await this.handleBinaryData(context, prepareOutput);
196
+ }
197
+ catch (error) { }
198
+ }
199
+ if (options.rawBody && !req.rawBody) {
200
+ await ((_b = (_a = req).readRawBody) === null || _b === void 0 ? void 0 : _b.call(_a));
120
201
  }
121
- const payload = {
202
+ const response = {
122
203
  json: {
123
- body: req.body,
124
- query: req.query,
125
204
  headers: req.headers,
126
- meta: {
127
- receivedAt: new Date().toISOString(),
128
- signatureValid,
129
- webhookPath: req.url,
130
- },
205
+ params: req.params,
206
+ query: req.query,
207
+ body: req.body,
131
208
  },
209
+ binary: options.rawBody
210
+ ? {
211
+ data: {
212
+ data: ((_c = req.rawBody) !== null && _c !== void 0 ? _c : '').toString(n8n_workflow_1.BINARY_ENCODING),
213
+ mimeType: (_d = req.contentType) !== null && _d !== void 0 ? _d : 'application/json',
214
+ },
215
+ }
216
+ : undefined,
132
217
  };
133
- res.status(200);
218
+ if (responseMode === 'streaming') {
219
+ const res = context.getResponseObject();
220
+ res.writeHead(200, {
221
+ 'Content-Type': 'application/json; charset=utf-8',
222
+ 'Transfer-Encoding': 'chunked',
223
+ 'Cache-Control': 'no-cache',
224
+ Connection: 'keep-alive',
225
+ });
226
+ res.flushHeaders();
227
+ return {
228
+ noWebhookResponse: true,
229
+ workflowData: prepareOutput(response),
230
+ };
231
+ }
134
232
  return {
135
- webhookResponse: { received: true },
136
- workflowData: [this.helpers.returnJsonArray([payload.json])],
233
+ webhookResponse: options.responseData,
234
+ workflowData: prepareOutput(response),
137
235
  };
138
236
  }
237
+ async validateAuth(context) {
238
+ return await (0, utils_1.validateWebhookAuthentication)(context, this.authPropertyName);
239
+ }
240
+ async handleBinaryData(context, prepareOutput) {
241
+ var _a, _b, _c, _d;
242
+ const req = context.getRequestObject();
243
+ const options = context.getNodeParameter('options', {});
244
+ const binaryFile = await (0, tmp_promise_1.file)({ prefix: 'elearning-magic-webhook-' });
245
+ try {
246
+ await (0, promises_2.pipeline)(req, (0, fs_1.createWriteStream)(binaryFile.path));
247
+ const returnItem = {
248
+ json: {
249
+ headers: req.headers,
250
+ params: req.params,
251
+ query: req.query,
252
+ body: {},
253
+ },
254
+ };
255
+ const stats = await (0, promises_1.stat)(binaryFile.path);
256
+ if (stats.size) {
257
+ const binaryPropertyName = ((_a = options.binaryPropertyName) !== null && _a !== void 0 ? _a : 'data');
258
+ const fileName = (_c = (_b = req.contentDisposition) === null || _b === void 0 ? void 0 : _b.filename) !== null && _c !== void 0 ? _c : (0, uuid_1.v4)();
259
+ const binaryData = await context.nodeHelpers.copyBinaryFile(binaryFile.path, fileName, (_d = req.contentType) !== null && _d !== void 0 ? _d : 'application/octet-stream');
260
+ returnItem.binary = { [binaryPropertyName]: binaryData };
261
+ }
262
+ return { workflowData: prepareOutput(returnItem) };
263
+ }
264
+ catch (error) {
265
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), error);
266
+ }
267
+ finally {
268
+ await binaryFile.cleanup();
269
+ }
270
+ }
139
271
  }
140
272
  exports.ElearningMagicTrigger = ElearningMagicTrigger;
@@ -0,0 +1,13 @@
1
+ import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
2
+ export declare const defaultWebhookDescription: IWebhookDescription;
3
+ export declare const credentialsProperty: (propertyName?: string) => INodeTypeDescription["credentials"];
4
+ export declare const authenticationProperty: (propertyName?: string) => INodeProperties;
5
+ export declare const httpMethodsProperty: INodeProperties;
6
+ export declare const responseCodeProperty: INodeProperties;
7
+ export declare const responseModeProperty: INodeProperties;
8
+ export declare const responseModePropertyStreaming: INodeProperties;
9
+ export declare const responseDataProperty: INodeProperties;
10
+ export declare const responseBinaryPropertyNameProperty: INodeProperties;
11
+ export declare const optionsProperty: INodeProperties;
12
+ export declare const responseCodeSelector: INodeProperties;
13
+ export declare const responseCodeOption: INodeProperties;
@@ -0,0 +1,421 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.responseCodeOption = exports.responseCodeSelector = exports.optionsProperty = exports.responseBinaryPropertyNameProperty = exports.responseDataProperty = exports.responseModePropertyStreaming = exports.responseModeProperty = exports.responseCodeProperty = exports.httpMethodsProperty = exports.authenticationProperty = exports.credentialsProperty = exports.defaultWebhookDescription = void 0;
4
+ const utils_1 = require("./utils");
5
+ exports.defaultWebhookDescription = {
6
+ name: 'default',
7
+ httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
8
+ isFullPath: true,
9
+ responseCode: `={{(${utils_1.getResponseCode})($parameter)}}`,
10
+ responseMode: '={{$parameter["responseMode"]}}',
11
+ responseData: `={{(${utils_1.getResponseData})($parameter)}}`,
12
+ responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
13
+ responseContentType: '={{$parameter["options"]["responseContentType"]}}',
14
+ responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
15
+ responseHeaders: '={{$parameter["options"]["responseHeaders"]}}',
16
+ path: '={{$parameter["path"]}}',
17
+ };
18
+ const credentialsProperty = (propertyName = 'authentication') => [
19
+ {
20
+ name: 'elearningMagicApi',
21
+ required: true,
22
+ displayOptions: {
23
+ show: {
24
+ [propertyName]: ['signingSecret'],
25
+ },
26
+ },
27
+ },
28
+ ];
29
+ exports.credentialsProperty = credentialsProperty;
30
+ const authenticationProperty = (propertyName = 'authentication') => ({
31
+ displayName: 'Authentication',
32
+ name: propertyName,
33
+ type: 'options',
34
+ options: [
35
+ {
36
+ name: 'Signing Secret (X-EM-Signature)',
37
+ value: 'signingSecret',
38
+ },
39
+ {
40
+ name: 'None',
41
+ value: 'none',
42
+ },
43
+ ],
44
+ default: 'signingSecret',
45
+ description: 'Verify requests using your eLearning Magic signing secret header',
46
+ });
47
+ exports.authenticationProperty = authenticationProperty;
48
+ exports.httpMethodsProperty = {
49
+ displayName: 'HTTP Method',
50
+ name: 'httpMethod',
51
+ type: 'options',
52
+ options: [
53
+ { name: 'DELETE', value: 'DELETE' },
54
+ { name: 'GET', value: 'GET' },
55
+ { name: 'HEAD', value: 'HEAD' },
56
+ { name: 'PATCH', value: 'PATCH' },
57
+ { name: 'POST', value: 'POST' },
58
+ { name: 'PUT', value: 'PUT' },
59
+ ],
60
+ default: 'GET',
61
+ description: 'The HTTP method to listen to',
62
+ };
63
+ exports.responseCodeProperty = {
64
+ displayName: 'Response Code',
65
+ name: 'responseCode',
66
+ type: 'number',
67
+ displayOptions: {
68
+ hide: {
69
+ responseMode: ['responseNode'],
70
+ },
71
+ },
72
+ typeOptions: {
73
+ minValue: 100,
74
+ maxValue: 599,
75
+ },
76
+ default: 200,
77
+ description: 'The HTTP Response code to return',
78
+ };
79
+ const responseModeOptions = [
80
+ {
81
+ name: 'Immediately',
82
+ value: 'onReceived',
83
+ description: 'As soon as this node executes',
84
+ },
85
+ {
86
+ name: 'When Last Node Finishes',
87
+ value: 'lastNode',
88
+ description: 'Returns data of the last-executed node',
89
+ },
90
+ {
91
+ name: "Using 'Respond to Webhook' Node",
92
+ value: 'responseNode',
93
+ description: 'Response defined in that node',
94
+ },
95
+ ];
96
+ exports.responseModeProperty = {
97
+ displayName: 'Respond',
98
+ name: 'responseMode',
99
+ type: 'options',
100
+ options: responseModeOptions,
101
+ default: 'onReceived',
102
+ description: 'When and how to respond to the webhook',
103
+ displayOptions: {
104
+ show: {
105
+ '@version': [1],
106
+ },
107
+ },
108
+ };
109
+ exports.responseModePropertyStreaming = {
110
+ displayName: 'Respond',
111
+ name: 'responseMode',
112
+ type: 'options',
113
+ options: [
114
+ ...responseModeOptions,
115
+ {
116
+ name: 'Streaming',
117
+ value: 'streaming',
118
+ description: 'Returns data in real time from streaming enabled nodes',
119
+ },
120
+ ],
121
+ default: 'onReceived',
122
+ description: 'When and how to respond to the webhook',
123
+ displayOptions: {
124
+ hide: {
125
+ '@version': [1],
126
+ },
127
+ },
128
+ };
129
+ exports.responseDataProperty = {
130
+ displayName: 'Response Data',
131
+ name: 'responseData',
132
+ type: 'options',
133
+ displayOptions: {
134
+ show: {
135
+ responseMode: ['lastNode'],
136
+ },
137
+ },
138
+ options: [
139
+ {
140
+ name: 'All Entries',
141
+ value: 'allEntries',
142
+ description: 'Returns all the entries of the last node. Always returns an array.',
143
+ },
144
+ {
145
+ name: 'First Entry JSON',
146
+ value: 'firstEntryJson',
147
+ description: 'Returns the JSON data of the first entry of the last node. Always returns a JSON object.',
148
+ },
149
+ {
150
+ name: 'First Entry Binary',
151
+ value: 'firstEntryBinary',
152
+ description: 'Returns the binary data of the first entry of the last node. Always returns a binary file.',
153
+ },
154
+ {
155
+ name: 'No Response Body',
156
+ value: 'noData',
157
+ description: 'Returns without a body',
158
+ },
159
+ ],
160
+ default: 'firstEntryJson',
161
+ description: 'What data should be returned. If it should return all items as an array or only the first item as object.',
162
+ };
163
+ exports.responseBinaryPropertyNameProperty = {
164
+ displayName: 'Property Name',
165
+ name: 'responseBinaryPropertyName',
166
+ type: 'string',
167
+ required: true,
168
+ default: 'data',
169
+ displayOptions: {
170
+ show: {
171
+ responseData: ['firstEntryBinary'],
172
+ },
173
+ },
174
+ description: 'Name of the binary property to return',
175
+ };
176
+ exports.optionsProperty = {
177
+ displayName: 'Options',
178
+ name: 'options',
179
+ type: 'collection',
180
+ placeholder: 'Add option',
181
+ default: {},
182
+ options: [
183
+ {
184
+ displayName: 'Binary File',
185
+ name: 'binaryData',
186
+ type: 'boolean',
187
+ displayOptions: {
188
+ show: {
189
+ '/httpMethod': ['PATCH', 'PUT', 'POST'],
190
+ '@version': [1],
191
+ },
192
+ },
193
+ default: false,
194
+ description: 'Whether the webhook will receive binary data',
195
+ },
196
+ {
197
+ displayName: 'Put Output File in Field',
198
+ name: 'binaryPropertyName',
199
+ type: 'string',
200
+ default: 'data',
201
+ displayOptions: {
202
+ show: {
203
+ binaryData: [true],
204
+ '@version': [1],
205
+ },
206
+ },
207
+ hint: 'The name of the output binary field to put the file in',
208
+ description: 'If the data gets received via "Form-Data Multipart" it will be the prefix and a number starting with 0 will be attached to it',
209
+ },
210
+ {
211
+ displayName: 'Field Name for Binary Data',
212
+ name: 'binaryPropertyName',
213
+ type: 'string',
214
+ default: 'data',
215
+ displayOptions: {
216
+ hide: {
217
+ '@version': [1],
218
+ },
219
+ },
220
+ description: 'The name of the output field to put any binary file data in. Only relevant if binary data is received.',
221
+ },
222
+ {
223
+ displayName: 'Ignore Bots',
224
+ name: 'ignoreBots',
225
+ type: 'boolean',
226
+ default: false,
227
+ description: 'Whether to ignore requests from bots like link previewers and web crawlers',
228
+ },
229
+ {
230
+ displayName: 'IP(s) Whitelist',
231
+ name: 'ipWhitelist',
232
+ type: 'string',
233
+ placeholder: 'e.g. 127.0.0.1',
234
+ default: '',
235
+ description: 'Comma-separated list of allowed IP addresses. Leave empty to allow all IPs.',
236
+ },
237
+ {
238
+ displayName: 'No Response Body',
239
+ name: 'noResponseBody',
240
+ type: 'boolean',
241
+ default: false,
242
+ description: 'Whether to send any body in the response',
243
+ displayOptions: {
244
+ hide: {
245
+ rawBody: [true],
246
+ },
247
+ show: {
248
+ '/responseMode': ['onReceived'],
249
+ },
250
+ },
251
+ },
252
+ {
253
+ displayName: 'Raw Body',
254
+ name: 'rawBody',
255
+ type: 'boolean',
256
+ displayOptions: {
257
+ show: {
258
+ '@version': [1],
259
+ },
260
+ hide: {
261
+ binaryData: [true],
262
+ noResponseBody: [true],
263
+ },
264
+ },
265
+ default: false,
266
+ description: 'Raw body (binary)',
267
+ },
268
+ {
269
+ displayName: 'Raw Body',
270
+ name: 'rawBody',
271
+ type: 'boolean',
272
+ displayOptions: {
273
+ hide: {
274
+ noResponseBody: [true],
275
+ '@version': [1],
276
+ },
277
+ },
278
+ default: false,
279
+ description: 'Whether to return the raw body',
280
+ },
281
+ {
282
+ displayName: 'Response Data',
283
+ name: 'responseData',
284
+ type: 'string',
285
+ displayOptions: {
286
+ show: {
287
+ '/responseMode': ['onReceived'],
288
+ },
289
+ hide: {
290
+ noResponseBody: [true],
291
+ },
292
+ },
293
+ default: '',
294
+ placeholder: 'success',
295
+ description: 'Custom response data to send',
296
+ },
297
+ {
298
+ displayName: 'Response Content-Type',
299
+ name: 'responseContentType',
300
+ type: 'string',
301
+ displayOptions: {
302
+ show: {
303
+ '/responseData': ['firstEntryJson'],
304
+ '/responseMode': ['lastNode'],
305
+ },
306
+ },
307
+ default: '',
308
+ placeholder: 'application/xml',
309
+ description: 'Set a custom content-type to return if another one as the "application/json" should be returned',
310
+ },
311
+ {
312
+ displayName: 'Response Headers',
313
+ name: 'responseHeaders',
314
+ placeholder: 'Add Response Header',
315
+ description: 'Add headers to the webhook response',
316
+ type: 'fixedCollection',
317
+ typeOptions: {
318
+ multipleValues: true,
319
+ },
320
+ default: {},
321
+ options: [
322
+ {
323
+ name: 'entries',
324
+ displayName: 'Entries',
325
+ values: [
326
+ {
327
+ displayName: 'Name',
328
+ name: 'name',
329
+ type: 'string',
330
+ default: '',
331
+ description: 'Name of the header',
332
+ },
333
+ {
334
+ displayName: 'Value',
335
+ name: 'value',
336
+ type: 'string',
337
+ default: '',
338
+ description: 'Value of the header',
339
+ },
340
+ ],
341
+ },
342
+ ],
343
+ },
344
+ {
345
+ displayName: 'Property Name',
346
+ name: 'responsePropertyName',
347
+ type: 'string',
348
+ displayOptions: {
349
+ show: {
350
+ '/responseData': ['firstEntryJson'],
351
+ '/responseMode': ['lastNode'],
352
+ },
353
+ },
354
+ default: 'data',
355
+ description: 'Name of the property to return the data of instead of the whole JSON',
356
+ },
357
+ ],
358
+ };
359
+ exports.responseCodeSelector = {
360
+ displayName: 'Response Code',
361
+ name: 'responseCode',
362
+ type: 'options',
363
+ options: [
364
+ { name: '200', value: 200, description: 'OK - Request has succeeded' },
365
+ { name: '201', value: 201, description: 'Created - Request has been fulfilled' },
366
+ { name: '204', value: 204, description: 'No Content - Request processed, no content returned' },
367
+ { name: '301', value: 301, description: 'Moved Permanently - Requested resource moved permanently' },
368
+ { name: '302', value: 302, description: 'Found - Requested resource moved temporarily' },
369
+ { name: '304', value: 304, description: 'Not Modified - Resource has not been modified' },
370
+ { name: '400', value: 400, description: 'Bad Request - Request could not be understood' },
371
+ { name: '401', value: 401, description: 'Unauthorized - Request requires user authentication' },
372
+ { name: '403', value: 403, description: 'Forbidden - Server understood, but refuses to fulfill' },
373
+ { name: '404', value: 404, description: 'Not Found - Server has not found a match' },
374
+ { name: 'Custom Code', value: 'customCode', description: 'Write any HTTP code' },
375
+ ],
376
+ default: 200,
377
+ description: 'The HTTP response code to return',
378
+ };
379
+ exports.responseCodeOption = {
380
+ displayName: 'Response Code',
381
+ name: 'responseCode',
382
+ placeholder: 'Add Response Code',
383
+ type: 'fixedCollection',
384
+ default: {
385
+ values: {
386
+ responseCode: 200,
387
+ },
388
+ },
389
+ options: [
390
+ {
391
+ name: 'values',
392
+ displayName: 'Values',
393
+ values: [
394
+ exports.responseCodeSelector,
395
+ {
396
+ displayName: 'Code',
397
+ name: 'customCode',
398
+ type: 'number',
399
+ default: 200,
400
+ placeholder: 'e.g. 400',
401
+ typeOptions: {
402
+ minValue: 100,
403
+ },
404
+ displayOptions: {
405
+ show: {
406
+ responseCode: ['customCode'],
407
+ },
408
+ },
409
+ },
410
+ ],
411
+ },
412
+ ],
413
+ displayOptions: {
414
+ show: {
415
+ '@version': [{ _cnd: { gte: 2 } }],
416
+ },
417
+ hide: {
418
+ '/responseMode': ['responseNode'],
419
+ },
420
+ },
421
+ };
@@ -0,0 +1,5 @@
1
+ import { ApplicationError } from '@n8n/errors';
2
+ export declare class WebhookAuthorizationError extends ApplicationError {
3
+ readonly responseCode: number;
4
+ constructor(responseCode: number, message?: string);
5
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebhookAuthorizationError = void 0;
4
+ const errors_1 = require("@n8n/errors");
5
+ class WebhookAuthorizationError extends errors_1.ApplicationError {
6
+ constructor(responseCode, message) {
7
+ if (message === undefined) {
8
+ message = 'Authorization problem!';
9
+ if (responseCode === 401) {
10
+ message = 'Authorization is required!';
11
+ }
12
+ else if (responseCode === 403) {
13
+ message = 'Authorization data is wrong!';
14
+ }
15
+ }
16
+ super(message);
17
+ this.responseCode = responseCode;
18
+ }
19
+ }
20
+ exports.WebhookAuthorizationError = WebhookAuthorizationError;
@@ -0,0 +1,32 @@
1
+ import type { IWebhookFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
2
+ export type WebhookParameters = {
3
+ httpMethod: string | string[];
4
+ responseMode: string;
5
+ responseData: string;
6
+ responseCode?: number;
7
+ options?: {
8
+ responseData?: string;
9
+ responseCode?: {
10
+ values?: {
11
+ responseCode: number;
12
+ customCode?: number;
13
+ };
14
+ };
15
+ noResponseBody?: boolean;
16
+ };
17
+ };
18
+ export declare const getResponseCode: (parameters: WebhookParameters) => number;
19
+ export declare const getResponseData: (parameters: WebhookParameters) => string | undefined;
20
+ export declare const configuredOutputs: (parameters: WebhookParameters) => {
21
+ type: string;
22
+ displayName: string;
23
+ }[];
24
+ export declare const setupOutputConnection: (ctx: IWebhookFunctions, method: string, additionalData: {
25
+ jwtPayload?: IDataObject;
26
+ }) => (outputData: INodeExecutionData) => INodeExecutionData[][];
27
+ export declare const isIpWhitelisted: (whitelist: string | string[] | undefined, ips: string[], ip?: string) => boolean;
28
+ export declare const checkResponseModeConfiguration: (context: IWebhookFunctions) => void;
29
+ export declare function validateWebhookAuthentication(ctx: IWebhookFunctions, authPropertyName: string): Promise<IDataObject | undefined>;
30
+ export declare function handleFormData(context: IWebhookFunctions, prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][]): Promise<{
31
+ workflowData: INodeExecutionData[][];
32
+ }>;
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.checkResponseModeConfiguration = exports.isIpWhitelisted = exports.setupOutputConnection = exports.configuredOutputs = exports.getResponseData = exports.getResponseCode = void 0;
37
+ exports.validateWebhookAuthentication = validateWebhookAuthentication;
38
+ exports.handleFormData = handleFormData;
39
+ const promises_1 = require("fs/promises");
40
+ const crypto_1 = require("crypto");
41
+ const n8n_workflow_1 = require("n8n-workflow");
42
+ const a = __importStar(require("node:assert"));
43
+ const node_net_1 = require("node:net");
44
+ const error_1 = require("./error");
45
+ const computeSignature = (secret, rawBody) => (0, crypto_1.createHmac)('sha256', secret).update(rawBody).digest('hex');
46
+ const safeCompare = (a, b) => {
47
+ if (!a || !b || a.length !== b.length) {
48
+ return false;
49
+ }
50
+ try {
51
+ return (0, crypto_1.timingSafeEqual)(Buffer.from(a), Buffer.from(b));
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ };
57
+ const getResponseCode = (parameters) => {
58
+ var _a;
59
+ if (parameters.responseCode) {
60
+ return parameters.responseCode;
61
+ }
62
+ const responseCodeOptions = parameters.options;
63
+ if ((_a = responseCodeOptions === null || responseCodeOptions === void 0 ? void 0 : responseCodeOptions.responseCode) === null || _a === void 0 ? void 0 : _a.values) {
64
+ const { responseCode, customCode } = responseCodeOptions.responseCode.values;
65
+ if (customCode) {
66
+ return customCode;
67
+ }
68
+ return responseCode;
69
+ }
70
+ return 200;
71
+ };
72
+ exports.getResponseCode = getResponseCode;
73
+ const getResponseData = (parameters) => {
74
+ const { responseData, responseMode, options } = parameters;
75
+ if (responseData)
76
+ return responseData;
77
+ if (responseMode === 'onReceived') {
78
+ const data = options === null || options === void 0 ? void 0 : options.responseData;
79
+ if (data)
80
+ return data;
81
+ }
82
+ if (options === null || options === void 0 ? void 0 : options.noResponseBody)
83
+ return 'noData';
84
+ return undefined;
85
+ };
86
+ exports.getResponseData = getResponseData;
87
+ const configuredOutputs = (parameters) => {
88
+ const httpMethod = parameters.httpMethod;
89
+ if (!Array.isArray(httpMethod))
90
+ return [
91
+ {
92
+ type: 'main',
93
+ displayName: httpMethod,
94
+ },
95
+ ];
96
+ const outputs = httpMethod.map((method) => {
97
+ return {
98
+ type: 'main',
99
+ displayName: method,
100
+ };
101
+ });
102
+ return outputs;
103
+ };
104
+ exports.configuredOutputs = configuredOutputs;
105
+ const setupOutputConnection = (ctx, method, additionalData) => {
106
+ const httpMethod = ctx.getNodeParameter('httpMethod', []);
107
+ let webhookUrl = ctx.getNodeWebhookUrl('default');
108
+ const executionMode = ctx.getMode() === 'manual' ? 'test' : 'production';
109
+ if (executionMode === 'test') {
110
+ webhookUrl = webhookUrl.replace('/webhook/', '/webhook-test/');
111
+ }
112
+ if (!Array.isArray(httpMethod)) {
113
+ return (outputData) => {
114
+ outputData.json.webhookUrl = webhookUrl;
115
+ outputData.json.executionMode = executionMode;
116
+ if (additionalData === null || additionalData === void 0 ? void 0 : additionalData.jwtPayload) {
117
+ outputData.json.jwtPayload = additionalData.jwtPayload;
118
+ }
119
+ return [[outputData]];
120
+ };
121
+ }
122
+ const outputIndex = httpMethod.indexOf(method.toUpperCase());
123
+ const outputs = httpMethod.map(() => []);
124
+ return (outputData) => {
125
+ outputData.json.webhookUrl = webhookUrl;
126
+ outputData.json.executionMode = executionMode;
127
+ if (additionalData === null || additionalData === void 0 ? void 0 : additionalData.jwtPayload) {
128
+ outputData.json.jwtPayload = additionalData.jwtPayload;
129
+ }
130
+ outputs[outputIndex] = [outputData];
131
+ return outputs;
132
+ };
133
+ };
134
+ exports.setupOutputConnection = setupOutputConnection;
135
+ const isIpWhitelisted = (whitelist, ips, ip) => {
136
+ if (whitelist === undefined || whitelist === '') {
137
+ return true;
138
+ }
139
+ if (!Array.isArray(whitelist)) {
140
+ whitelist = whitelist.split(',').map((entry) => entry.trim());
141
+ }
142
+ const allowList = getAllowList(whitelist);
143
+ if (allowList.check(ip !== null && ip !== void 0 ? ip : '')) {
144
+ return true;
145
+ }
146
+ if (ips.some((ipEntry) => allowList.check(ipEntry))) {
147
+ return true;
148
+ }
149
+ return false;
150
+ };
151
+ exports.isIpWhitelisted = isIpWhitelisted;
152
+ const getAllowList = (whitelist) => {
153
+ const allowList = new node_net_1.BlockList();
154
+ for (const entry of whitelist) {
155
+ try {
156
+ allowList.addAddress(entry);
157
+ }
158
+ catch {
159
+ // Ignore invalid entries
160
+ }
161
+ }
162
+ return allowList;
163
+ };
164
+ const checkResponseModeConfiguration = (context) => {
165
+ const responseMode = context.getNodeParameter('responseMode', 'onReceived');
166
+ const connectedNodes = context.getChildNodes(context.getNode().name);
167
+ const isRespondToWebhookConnected = connectedNodes.some((node) => node.type === 'n8n-nodes-base.respondToWebhook');
168
+ if (!isRespondToWebhookConnected && responseMode === 'responseNode') {
169
+ throw new n8n_workflow_1.WorkflowConfigurationError(context.getNode(), new Error('No Respond to Webhook node found in the workflow'), {
170
+ description: 'Insert a Respond to Webhook node to your workflow to respond to the webhook or choose another option for the “Respond” parameter',
171
+ });
172
+ }
173
+ if (isRespondToWebhookConnected && !['responseNode', 'streaming'].includes(responseMode)) {
174
+ throw new n8n_workflow_1.WorkflowConfigurationError(context.getNode(), new Error('Unused Respond to Webhook node found in the workflow'), {
175
+ description: 'Set the “Respond” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node',
176
+ });
177
+ }
178
+ };
179
+ exports.checkResponseModeConfiguration = checkResponseModeConfiguration;
180
+ async function validateWebhookAuthentication(ctx, authPropertyName) {
181
+ var _a, _b, _c;
182
+ const authentication = ctx.getNodeParameter(authPropertyName);
183
+ if (authentication === 'none')
184
+ return;
185
+ const req = ctx.getRequestObject();
186
+ const headers = ctx.getHeaderData();
187
+ if (authentication === 'signingSecret') {
188
+ let credentials;
189
+ try {
190
+ credentials = await ctx.getCredentials('elearningMagicApi');
191
+ }
192
+ catch { }
193
+ const secret = (credentials === null || credentials === void 0 ? void 0 : credentials.signingSecret) || '';
194
+ if (!secret) {
195
+ throw new error_1.WebhookAuthorizationError(500, 'No signing secret defined on node!');
196
+ }
197
+ if (!req.rawBody) {
198
+ await ((_b = (_a = req).readRawBody) === null || _b === void 0 ? void 0 : _b.call(_a));
199
+ }
200
+ const raw = req.rawBody !== undefined
201
+ ? req.rawBody.toString('utf8')
202
+ : JSON.stringify((_c = req.body) !== null && _c !== void 0 ? _c : {});
203
+ const headerSignature = headers['x-em-signature'] || '';
204
+ if (!headerSignature) {
205
+ throw new error_1.WebhookAuthorizationError(401, 'Missing X-EM-Signature header');
206
+ }
207
+ const computed = computeSignature(secret, raw);
208
+ if (!safeCompare(computed, headerSignature)) {
209
+ throw new error_1.WebhookAuthorizationError(403, 'Invalid X-EM-Signature');
210
+ }
211
+ return undefined;
212
+ }
213
+ return undefined;
214
+ }
215
+ async function handleFormData(context, prepareOutput) {
216
+ var _a;
217
+ const req = context.getRequestObject();
218
+ a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
219
+ const options = context.getNodeParameter('options', {});
220
+ const { data, files } = req.body;
221
+ const returnItem = {
222
+ json: {
223
+ headers: req.headers,
224
+ params: req.params,
225
+ query: req.query,
226
+ body: data,
227
+ },
228
+ };
229
+ if (files && Object.keys(files).length) {
230
+ returnItem.binary = {};
231
+ }
232
+ let count = 0;
233
+ for (const key of Object.keys(files)) {
234
+ const processFiles = [];
235
+ let multiFile = false;
236
+ if (Array.isArray(files[key])) {
237
+ processFiles.push.apply(processFiles, files[key]);
238
+ multiFile = true;
239
+ }
240
+ else {
241
+ processFiles.push(files[key]);
242
+ }
243
+ let fileCount = 0;
244
+ for (const file of processFiles) {
245
+ let binaryPropertyName = key;
246
+ if (binaryPropertyName.endsWith('[]')) {
247
+ binaryPropertyName = binaryPropertyName.slice(0, -2);
248
+ }
249
+ if (!binaryPropertyName.trim().length) {
250
+ binaryPropertyName = `data${count}`;
251
+ }
252
+ else if (multiFile) {
253
+ binaryPropertyName += fileCount++;
254
+ }
255
+ if (options.binaryPropertyName) {
256
+ binaryPropertyName = `${options.binaryPropertyName}${count}`;
257
+ }
258
+ returnItem.binary[binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(file.filepath, (_a = file.originalFilename) !== null && _a !== void 0 ? _a : file.newFilename, file.mimetype);
259
+ await (0, promises_1.rm)(file.filepath, { force: true });
260
+ count += 1;
261
+ }
262
+ }
263
+ return { workflowData: prepareOutput(returnItem) };
264
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-elearning-magic",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "n8n community node for receiving signed payloads from the eLearning Magic SCORM wrapper",
5
5
  "keywords": [
6
6
  "n8n-community-node",
@@ -37,8 +37,10 @@
37
37
  "typescript": "^5.6.3"
38
38
  },
39
39
  "dependencies": {
40
+ "isbot": "^3.7.0",
40
41
  "n8n-core": "^1.0.0",
41
- "n8n-workflow": "^1.0.0"
42
+ "n8n-workflow": "^1.0.0",
43
+ "tmp-promise": "^3.0.3"
42
44
  },
43
45
  "overrides": {
44
46
  "form-data": "^4.0.4"