@inteli.city/node-red-contrib-http-plus 1.0.0

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.
package/httpin+.js ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * Copyright JS Foundation and other contributors, http://js.foundation
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ **/
16
+
17
+ var swagger = require("./libs/swagger");
18
+ var swaggerInitialized = false;
19
+
20
+ module.exports = function(RED) {
21
+ "use strict";
22
+
23
+ if (!swaggerInitialized) {
24
+ swagger.registerRoutes(RED);
25
+ swaggerInitialized = true;
26
+ }
27
+
28
+ var bodyParser = require("body-parser");
29
+ var multer = require("multer");
30
+ var cookieParser = require("cookie-parser");
31
+ var getBody = require('raw-body');
32
+ var cors = require('cors');
33
+ var onHeaders = require('on-headers');
34
+ var typer = require('content-type');
35
+ var mediaTyper = require('media-typer');
36
+ var isUtf8 = require('is-utf8');
37
+ var hashSum = require("hash-sum");
38
+ var jwt = require('jsonwebtoken');
39
+ var jwksRsa = require('jwks-rsa');
40
+ var { z } = require('zod');
41
+ var os = require('os');
42
+
43
+ function rawBodyParser(req, res, next) {
44
+ if (req.skipRawBodyParser) { next(); } // don't parse this if told to skip
45
+ if (req._body) { return next(); }
46
+ req.body = "";
47
+ req._body = true;
48
+
49
+ var isText = true;
50
+ var checkUTF = false;
51
+
52
+ if (req.headers['content-type']) {
53
+ var contentType = typer.parse(req.headers['content-type'])
54
+ if (contentType.type) {
55
+ var parsedType = mediaTyper.parse(contentType.type);
56
+ if (parsedType.type === "text") {
57
+ isText = true;
58
+ } else if (parsedType.subtype === "xml" || parsedType.suffix === "xml") {
59
+ isText = true;
60
+ } else if (parsedType.type !== "application") {
61
+ isText = false;
62
+ } else if ((parsedType.subtype !== "octet-stream")
63
+ && (parsedType.subtype !== "cbor")
64
+ && (parsedType.subtype !== "x-protobuf")) {
65
+ checkUTF = true;
66
+ } else {
67
+ // application/octet-stream or application/cbor
68
+ isText = false;
69
+ }
70
+
71
+ }
72
+ }
73
+
74
+ getBody(req, {
75
+ length: req.headers['content-length'],
76
+ encoding: isText ? "utf8" : null
77
+ }, function (err, buf) {
78
+ if (err) { return next(err); }
79
+ if (!isText && checkUTF && isUtf8(buf)) {
80
+ buf = buf.toString()
81
+ }
82
+ req.body = buf;
83
+ next();
84
+ });
85
+ }
86
+
87
+ var corsSetup = false;
88
+
89
+ function createRequestWrapper(node,req) {
90
+ // This misses a bunch of properties (eg headers). Before we use this function
91
+ // need to ensure it captures everything documented by Express and HTTP modules.
92
+ var wrapper = {
93
+ _req: req
94
+ };
95
+ var toWrap = [
96
+ "param",
97
+ "get",
98
+ "is",
99
+ "acceptsCharset",
100
+ "acceptsLanguage",
101
+ "app",
102
+ "baseUrl",
103
+ "body",
104
+ "cookies",
105
+ "fresh",
106
+ "hostname",
107
+ "ip",
108
+ "ips",
109
+ "originalUrl",
110
+ "params",
111
+ "path",
112
+ "protocol",
113
+ "query",
114
+ "route",
115
+ "secure",
116
+ "signedCookies",
117
+ "stale",
118
+ "subdomains",
119
+ "xhr",
120
+ "socket" // TODO: tidy this up
121
+ ];
122
+ toWrap.forEach(function(f) {
123
+ if (typeof req[f] === "function") {
124
+ wrapper[f] = function() {
125
+ node.warn(RED._("httpin.errors.deprecated-call",{method:"msg.req."+f}));
126
+ var result = req[f].apply(req,arguments);
127
+ if (result === req) {
128
+ return wrapper;
129
+ } else {
130
+ return result;
131
+ }
132
+ }
133
+ } else {
134
+ wrapper[f] = req[f];
135
+ }
136
+ });
137
+
138
+
139
+ return wrapper;
140
+ }
141
+ function createResponseWrapper(node,res) {
142
+ var wrapper = {
143
+ _res: res
144
+ };
145
+ var toWrap = [
146
+ "append",
147
+ "attachment",
148
+ "cookie",
149
+ "clearCookie",
150
+ "download",
151
+ "end",
152
+ "format",
153
+ "get",
154
+ "json",
155
+ "jsonp",
156
+ "links",
157
+ "location",
158
+ "redirect",
159
+ "render",
160
+ "send",
161
+ "sendfile",
162
+ "sendFile",
163
+ "sendStatus",
164
+ "set",
165
+ "status",
166
+ "type",
167
+ "vary"
168
+ ];
169
+ toWrap.forEach(function(f) {
170
+ wrapper[f] = function() {
171
+ node.warn(RED._("httpin.errors.deprecated-call",{method:"msg.res."+f}));
172
+ var result = res[f].apply(res,arguments);
173
+ if (result === res) {
174
+ return wrapper;
175
+ } else {
176
+ return result;
177
+ }
178
+ }
179
+ });
180
+ return wrapper;
181
+ }
182
+
183
+ var corsHandler = function(req,res,next) { next(); }
184
+
185
+ if (RED.settings.httpNodeCors) {
186
+ corsHandler = cors(RED.settings.httpNodeCors);
187
+ RED.httpNode.options("*",corsHandler);
188
+ }
189
+
190
+ function HTTPIn(n) {
191
+ RED.nodes.createNode(this,n);
192
+
193
+ // Zod schema compilation (once at deploy time)
194
+ var compiledSchema = null;
195
+ var schemaText = (n.zodSchema || "").trim();
196
+ if (n.enableZod === true && schemaText) {
197
+ try {
198
+ compiledSchema = eval(schemaText); // z is in scope
199
+ } catch(err) {
200
+ this.error("http.in+: invalid Zod schema — " + err.message);
201
+ }
202
+ }
203
+
204
+ if (compiledSchema && n.method === "get") {
205
+ try {
206
+ var _def = compiledSchema._def;
207
+ if (_def && _def.typeName === "ZodObject") {
208
+ var _shape = typeof _def.shape === "function" ? _def.shape() : _def.shape;
209
+ if (_shape.body) {
210
+ this.warn(
211
+ "http.in+: GET endpoint defines a body schema. " +
212
+ "Use 'query' instead. Body will be ignored."
213
+ );
214
+ }
215
+ }
216
+ } catch(e) {
217
+ // ignore
218
+ }
219
+ }
220
+
221
+ if (compiledSchema) {
222
+ swagger.registerEndpoint({
223
+ nodeId: this.id,
224
+ method: n.method,
225
+ path: n.url && n.url[0] !== '/' ? '/' + n.url : n.url,
226
+ schema: compiledSchema,
227
+ rawSchemaText: schemaText
228
+ });
229
+ }
230
+
231
+ // Load auth config node (may be null if not configured)
232
+ var authConfig = RED.nodes.getNode(n.authConfig);
233
+
234
+ // Create JWKS client once per node instance (built-in key caching)
235
+ var jwksClient = null;
236
+ if (authConfig && authConfig.authType === 'cognito' && authConfig.jwksUrl) {
237
+ jwksClient = jwksRsa({
238
+ jwksUri: authConfig.jwksUrl,
239
+ cache: true,
240
+ cacheMaxEntries: 5,
241
+ cacheMaxAge: 3600000 // 1 hour
242
+ });
243
+ }
244
+
245
+ if (RED.settings.httpNodeRoot !== false) {
246
+
247
+ if (!n.url) {
248
+ this.warn(RED._("httpin.errors.missing-path"));
249
+ return;
250
+ }
251
+ this.url = n.url;
252
+ if (this.url[0] !== '/') {
253
+ this.url = '/'+this.url;
254
+ }
255
+ this.method = n.method;
256
+ this.swaggerDoc = n.swaggerDoc;
257
+ this.enableUpload = n.enableUpload;
258
+ this.uploadStorage = n.uploadStorage || 'memory';
259
+ this.maxFileSize = n.maxFileSize || 5;
260
+ this.uploadDir = n.uploadDir || '';
261
+
262
+ var node = this;
263
+
264
+ this.errorHandler = function(err,req,res,next) {
265
+ node.warn(err);
266
+ res.sendStatus(500);
267
+ };
268
+
269
+ this.callback = function(req,res) {
270
+ // Dispatch msg downstream after successful auth
271
+ function dispatchMsg(user) {
272
+ var msgid = RED.util.generateId();
273
+ res._msgid = msgid;
274
+ // Since Node 15, req.headers are lazily computed and the property
275
+ // marked as non-enumerable.
276
+ // That means it doesn't show up in the Debug sidebar.
277
+ // This redefines the property causing it to be evaluated *and*
278
+ // marked as enumerable again.
279
+ Object.defineProperty(req, 'headers', {
280
+ value: req.headers,
281
+ enumerable: true
282
+ });
283
+ var msg = {_msgid:msgid, req:req, res:createResponseWrapper(node,res)};
284
+ if (user !== undefined) { msg.user = user; }
285
+ if (node.method.match(/^(post|delete|put|options|patch)$/)) {
286
+ msg.payload = req.body;
287
+ } else if (node.method == "get") {
288
+ msg.payload = req.query;
289
+ }
290
+ if (req._uploadedFiles) { msg.files = req._uploadedFiles; }
291
+
292
+ // ── Zod validation ─────────────────────────────────────
293
+ if (compiledSchema) {
294
+ var validationInput = {
295
+ body: msg.payload,
296
+ query: req.query,
297
+ params: req.params,
298
+ files: msg.files
299
+ };
300
+ try {
301
+ var parsed = compiledSchema.parse(validationInput);
302
+ msg.validated = parsed;
303
+ } catch(validationErr) {
304
+ res.status(400).json({
305
+ error: "invalid_request",
306
+ details: validationErr.errors
307
+ });
308
+ return;
309
+ }
310
+ }
311
+
312
+ node.send(msg);
313
+ }
314
+
315
+ // ── No auth config or authType "none" ──────────────────────
316
+ if (!authConfig || authConfig.authType === 'none') {
317
+ return dispatchMsg();
318
+ }
319
+
320
+ // ── Basic Auth ─────────────────────────────────────────────
321
+ if (authConfig.authType === 'basic') {
322
+ var basicHeader = req.headers['authorization'] || '';
323
+ if (!basicHeader.startsWith('Basic ')) {
324
+ res.set('WWW-Authenticate', 'Basic realm="Restricted"');
325
+ return res.status(401).json({error: "unauthorized"});
326
+ }
327
+ var decoded = Buffer.from(basicHeader.slice(6), 'base64').toString('utf8');
328
+ var colonIdx = decoded.indexOf(':');
329
+ var reqUser = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
330
+ var reqPass = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : '';
331
+ var authed = false;
332
+ if (authConfig.useJsonUsers) {
333
+ try {
334
+ var users = JSON.parse(authConfig.usersJson || '{}');
335
+ authed = Object.prototype.hasOwnProperty.call(users, reqUser) &&
336
+ users[reqUser] === reqPass;
337
+ } catch(e) {
338
+ node.error('http.in+: invalid usersJson — ' + e.message);
339
+ }
340
+ } else {
341
+ var creds = authConfig.credentials || {};
342
+ authed = reqUser === creds.username && reqPass === creds.password;
343
+ }
344
+ if (authed) {
345
+ if (req.headers && req.headers.authorization) {
346
+ delete req.headers.authorization;
347
+ }
348
+ return dispatchMsg(reqUser);
349
+ }
350
+ res.set('WWW-Authenticate', 'Basic realm="Restricted"');
351
+ return res.status(401).json({error: "unauthorized"});
352
+ }
353
+
354
+ // ── Cognito JWT ────────────────────────────────────────────
355
+ if (authConfig.authType === 'cognito') {
356
+ if (!jwksClient) {
357
+ node.warn('http.in+: cognito auth is configured but jwksUrl is missing');
358
+ return res.status(401).json({error: "unauthorized"});
359
+ }
360
+
361
+ // Extract token: Bearer header first, then ?token= query param
362
+ var bearerHeader = req.headers['authorization'] || '';
363
+ var token = null;
364
+ if (bearerHeader.startsWith('Bearer ')) {
365
+ token = bearerHeader.slice(7);
366
+ } else if (req.query && req.query.token) {
367
+ token = req.query.token;
368
+ }
369
+ if (!token) {
370
+ return res.status(401).json({error: "unauthorized"});
371
+ }
372
+
373
+ var verifyOptions = {};
374
+ if (authConfig.audience) { verifyOptions.audience = authConfig.audience; }
375
+ if (authConfig.issuer) { verifyOptions.issuer = authConfig.issuer; }
376
+
377
+ function getKey(header, callback) {
378
+ jwksClient.getSigningKey(header.kid, function(err, key) {
379
+ if (err) { return callback(err); }
380
+ var signingKey = key.getPublicKey
381
+ ? key.getPublicKey()
382
+ : (key.publicKey || key.rsaPublicKey);
383
+ callback(null, signingKey);
384
+ });
385
+ }
386
+
387
+ jwt.verify(token, getKey, verifyOptions, function(err, jwtPayload) {
388
+ if (err) {
389
+ return res.status(401).json({error: "unauthorized"});
390
+ }
391
+ if (req.headers && req.headers.authorization) {
392
+ delete req.headers.authorization;
393
+ }
394
+ if (req.query && req.query.token) {
395
+ delete req.query.token;
396
+ }
397
+ if (!authConfig.exposeUser) {
398
+ return dispatchMsg();
399
+ }
400
+ var mappingStr = (authConfig.userMapping || '').trim();
401
+ var mapping = null;
402
+ try {
403
+ mapping = JSON.parse(mappingStr);
404
+ } catch(e) {
405
+ node.warn('http.in+: invalid userMapping JSON — ' + e.message + '. msg.user will not be set.');
406
+ return dispatchMsg();
407
+ }
408
+ var mappedUser = {};
409
+ for (var key in mapping) {
410
+ if (Object.prototype.hasOwnProperty.call(mapping, key)) {
411
+ mappedUser[key] = jwtPayload[mapping[key]];
412
+ }
413
+ }
414
+ dispatchMsg(mappedUser);
415
+ });
416
+ return; // async path — dispatchMsg called in jwt.verify callback
417
+ }
418
+
419
+ // ── Unknown authType — allow through ───────────────────────
420
+ dispatchMsg();
421
+ };
422
+
423
+ var httpMiddleware = function(req,res,next) { next(); }
424
+
425
+ if (RED.settings.httpNodeMiddleware) {
426
+ if (typeof RED.settings.httpNodeMiddleware === "function" || Array.isArray(RED.settings.httpNodeMiddleware)) {
427
+ httpMiddleware = RED.settings.httpNodeMiddleware;
428
+ }
429
+ }
430
+
431
+ var maxApiRequestSize = RED.settings.apiMaxLength || '5mb';
432
+ var jsonParser = bodyParser.json({limit:maxApiRequestSize});
433
+ var urlencParser = bodyParser.urlencoded({limit:maxApiRequestSize,extended:true});
434
+
435
+ var metricsHandler = function(req,res,next) { next(); }
436
+ if (this.metric()) {
437
+ metricsHandler = function(req, res, next) {
438
+ var startAt = process.hrtime();
439
+ onHeaders(res, function() {
440
+ if (res._msgid) {
441
+ var diff = process.hrtime(startAt);
442
+ var ms = diff[0] * 1e3 + diff[1] * 1e-6;
443
+ var metricResponseTime = ms.toFixed(3);
444
+ var metricContentLength = res.getHeader("content-length");
445
+ //assuming that _id has been set for res._metrics in HttpOut node!
446
+ node.metric("response.time.millis", {_msgid:res._msgid} , metricResponseTime);
447
+ node.metric("response.content-length.bytes", {_msgid:res._msgid} , metricContentLength);
448
+ }
449
+ });
450
+ next();
451
+ };
452
+ }
453
+
454
+ var multipartParser = function(req,res,next) { next(); }
455
+ if (this.enableUpload) {
456
+ var storageEngine;
457
+ if (this.uploadStorage === 'disk') {
458
+ var uploadDest = this.uploadDir || os.tmpdir();
459
+ storageEngine = multer.diskStorage({
460
+ destination: uploadDest,
461
+ filename: function(req, file, cb) {
462
+ var name = Date.now() + '-' + Math.round(Math.random() * 1e9) + '-' + file.originalname;
463
+ cb(null, name);
464
+ }
465
+ });
466
+ } else {
467
+ storageEngine = multer.memoryStorage();
468
+ }
469
+ var uploadInstance = multer({
470
+ storage: storageEngine,
471
+ limits: { fileSize: (node.maxFileSize || 5) * 1024 * 1024 }
472
+ });
473
+ var uploadMiddleware = uploadInstance.any();
474
+ var uploadStorageLabel = node.uploadStorage || 'memory';
475
+ multipartParser = function(req, res, next) {
476
+ uploadMiddleware(req, res, function(err) {
477
+ if (err) {
478
+ if (err.code === 'LIMIT_FILE_SIZE') {
479
+ return res.status(413).json({ error: 'file_too_large' });
480
+ }
481
+ return next(err);
482
+ }
483
+ req._body = true;
484
+ if (req.files && req.files.length > 0) {
485
+ req._uploadedFiles = req.files.map(function(file) {
486
+ return {
487
+ fieldname: file.fieldname,
488
+ originalname: file.originalname,
489
+ mimetype: file.mimetype,
490
+ size: file.size,
491
+ storage: uploadStorageLabel,
492
+ buffer: uploadStorageLabel === 'memory' ? file.buffer : undefined,
493
+ path: uploadStorageLabel === 'disk' ? file.path : undefined
494
+ };
495
+ });
496
+ }
497
+ next();
498
+ });
499
+ };
500
+ }
501
+
502
+ if (this.method == "get") {
503
+ RED.httpNode.get(this.url,cookieParser(),httpMiddleware,corsHandler,metricsHandler,this.callback,this.errorHandler);
504
+ } else if (this.method == "post") {
505
+ RED.httpNode.post(this.url,cookieParser(),httpMiddleware,corsHandler,metricsHandler,jsonParser,urlencParser,multipartParser,rawBodyParser,this.callback,this.errorHandler);
506
+ } else if (this.method == "put") {
507
+ RED.httpNode.put(this.url,cookieParser(),httpMiddleware,corsHandler,metricsHandler,jsonParser,urlencParser,rawBodyParser,this.callback,this.errorHandler);
508
+ } else if (this.method == "patch") {
509
+ RED.httpNode.patch(this.url,cookieParser(),httpMiddleware,corsHandler,metricsHandler,jsonParser,urlencParser,rawBodyParser,this.callback,this.errorHandler);
510
+ } else if (this.method == "delete") {
511
+ RED.httpNode.delete(this.url,cookieParser(),httpMiddleware,corsHandler,metricsHandler,jsonParser,urlencParser,rawBodyParser,this.callback,this.errorHandler);
512
+ }
513
+
514
+ this.on("close",function() {
515
+ swagger.unregisterEndpoint(node.id);
516
+ RED.httpNode._router.stack.forEach(function(route,i,routes) {
517
+ if (route.route && route.route.path === node.url && route.route.methods[node.method]) {
518
+ routes.splice(i,1);
519
+ }
520
+ });
521
+ });
522
+ } else {
523
+ this.warn(RED._("httpin.errors.not-created"));
524
+ }
525
+ }
526
+ RED.nodes.registerType("http.in+",HTTPIn);
527
+
528
+
529
+ function HTTPOut(n) {
530
+ RED.nodes.createNode(this,n);
531
+ var node = this;
532
+ this.headers = n.headers||{};
533
+ this.statusCode = parseInt(n.statusCode);
534
+ this.on("input",function(msg,_send,done) {
535
+ if (msg.res) {
536
+ var headers = RED.util.cloneMessage(node.headers);
537
+ if (msg.headers) {
538
+ if (msg.headers.hasOwnProperty('x-node-red-request-node')) {
539
+ var headerHash = msg.headers['x-node-red-request-node'];
540
+ delete msg.headers['x-node-red-request-node'];
541
+ var hash = hashSum(msg.headers);
542
+ if (hash === headerHash) {
543
+ delete msg.headers;
544
+ }
545
+ }
546
+ if (msg.headers) {
547
+ for (var h in msg.headers) {
548
+ if (msg.headers.hasOwnProperty(h) && !headers.hasOwnProperty(h)) {
549
+ headers[h] = msg.headers[h];
550
+ }
551
+ }
552
+ }
553
+ }
554
+ // ── Streaming mode ─────────────────────────────────────────
555
+ var stream = msg.stream || (msg.payload && typeof msg.payload.pipe === 'function' && !Buffer.isBuffer(msg.payload) ? msg.payload : null);
556
+ if (stream) {
557
+ var rawRes = msg.res._res;
558
+ if (rawRes.headersSent) { return done(); }
559
+ rawRes.statusCode = msg.statusCode || 200;
560
+ for (var sh in headers) {
561
+ if (headers.hasOwnProperty(sh)) { rawRes.setHeader(sh, headers[sh]); }
562
+ }
563
+ stream.on('error', function(err) {
564
+ if (!rawRes.headersSent) {
565
+ rawRes.statusCode = 500;
566
+ }
567
+ rawRes.end('Stream error');
568
+ done();
569
+ });
570
+ stream.on('end', function() { done(); });
571
+ stream.pipe(rawRes);
572
+ return;
573
+ }
574
+
575
+ if (Object.keys(headers).length > 0) {
576
+ msg.res._res.set(headers);
577
+ }
578
+
579
+ if (msg.cookies) {
580
+ for (var name in msg.cookies) {
581
+ if (msg.cookies.hasOwnProperty(name)) {
582
+ if (msg.cookies[name] === null || msg.cookies[name].value === null) {
583
+ if (msg.cookies[name]!==null) {
584
+ msg.res._res.clearCookie(name,msg.cookies[name]);
585
+ } else {
586
+ msg.res._res.clearCookie(name);
587
+ }
588
+ } else if (typeof msg.cookies[name] === 'object') {
589
+ msg.res._res.cookie(name,msg.cookies[name].value,msg.cookies[name]);
590
+ } else {
591
+ msg.res._res.cookie(name,msg.cookies[name]);
592
+ }
593
+ }
594
+ }
595
+ }
596
+ var statusCode = node.statusCode || parseInt(msg.statusCode) || 200;
597
+ if (typeof msg.payload == "object" && !Buffer.isBuffer(msg.payload)) {
598
+ msg.res._res.status(statusCode).jsonp(msg.payload);
599
+ } else {
600
+ if (msg.res._res.get('content-length') == null) {
601
+ var len;
602
+ if (msg.payload == null) {
603
+ len = 0;
604
+ } else if (Buffer.isBuffer(msg.payload)) {
605
+ len = msg.payload.length;
606
+ } else if (typeof msg.payload == "number") {
607
+ len = Buffer.byteLength(""+msg.payload);
608
+ } else {
609
+ len = Buffer.byteLength(msg.payload);
610
+ }
611
+ msg.res._res.set('content-length', len);
612
+ }
613
+
614
+ if (typeof msg.payload === "number") {
615
+ msg.payload = ""+msg.payload;
616
+ }
617
+ msg.res._res.status(statusCode).send(msg.payload);
618
+ }
619
+ } else {
620
+ node.warn(RED._("httpin.errors.no-response"));
621
+ }
622
+ done();
623
+ });
624
+ }
625
+ RED.nodes.registerType("http.out+",HTTPOut);
626
+ }