UNPKG

@isaac-platform/isaac-node-red

Version:

Set of Node-RED nodes to communicate with an ISAAC system

330 lines (326 loc) 20.2 kB
module.exports = (RED) => { const mustache = require("mustache"), got = require("got"), { CookieJar } = require("tough-cookie"), { HttpProxyAgent, HttpsProxyAgent } = require("hpagent"), FormData = require("form-data"), { v4: uuid } = require("uuid"), crypto = require("crypto"), { URL } = require("url"), querystring = require("querystring"), cookie = require("cookie"), hashSum = require("hash-sum"), HTTPS_MODULE = require("https"), utils = require("../../utils.js"), HTTPS_REQUEST = HTTPS_MODULE.request; function checkNodeAgentPatch() { HTTPS_MODULE.request !== HTTPS_REQUEST && HTTPS_MODULE.request.length === 2 && (RED.log.warn(` --------------------------------------------------------------------- Patched https.request function detected. This will break the HTTP Request node. The original code has now been restored. This is likely caused by a contrib node including an old version of the 'agent-base@<5.0.0' module. You can identify what node is at fault by running: npm list agent-base in your Node-RED user directory (${RED.settings.userDir}). --------------------------------------------------------------------- `), HTTPS_MODULE.request = HTTPS_REQUEST); } function GenericRequestNode(config) { RED.nodes.createNode(this, config), checkNodeAgentPatch(); const node = this, server = RED.nodes.getNode(config.isaacConnection), didAddUsedConnection = utils.incrementConnectionUsage(node, server), nodeUrl = config.url, isTemplatedUrl = (nodeUrl || "").indexOf("{{") != -1, nodeMethod = config.method || "GET"; let paytoqs = !1, paytobody = !1, redirectList = []; const sendErrorsToCatch = config.senderr; node.headers = config.headers || []; const nodeHTTPPersistent = config.persist; if (config.tls) var tlsNode = RED.nodes.getNode(config.tls); this.ret = config.ret || "txt", this.authType = config.authType || "basic", RED.settings.httpRequestTimeout ? this.reqTimeout = parseInt(RED.settings.httpRequestTimeout, 10) || 12e4 : this.reqTimeout = 12e4, config.paytoqs === !0 || config.paytoqs === "query" ? paytoqs = !0 : config.paytoqs === "body" && (paytobody = !0), node.insecureHTTPParser = config.insecureHTTPParser; let prox, noprox; process.env.http_proxy && (prox = process.env.http_proxy), process.env.HTTP_PROXY && (prox = process.env.HTTP_PROXY), process.env.no_proxy && (noprox = process.env.no_proxy.split(",")), process.env.NO_PROXY && (noprox = process.env.NO_PROXY.split(",")); let proxyConfig = null; config.proxy && (proxyConfig = RED.nodes.getNode(config.proxy), prox = proxyConfig.url, noprox = proxyConfig.noproxy); let timingLog = !1; RED.settings.hasOwnProperty("httpRequestTimingLog") && (timingLog = RED.settings.httpRequestTimingLog); const updateHeader = function(headersObject, name, value) { const hn = name.toLowerCase(), matchingKeys = Object.keys(headersObject).filter((e) => e.toLowerCase() == hn), updateKey = (k, v) => { delete headersObject[k], v && (headersObject[name] = v); }; matchingKeys.length == 0 ? updateKey(name, value) : matchingKeys.forEach((k) => { updateKey(k, value); }); }, getHeaderValue = (headersObject, name) => { const asLowercase = name.toLowercase(); return headersObject[Object.keys(headersObject).find((k) => k.toLowerCase() === asLowercase)]; }; this.on("input", function(msg, nodeSend, nodeDone) { if (!server) { node.error("No ISAAC connection configured"); return; } checkNodeAgentPatch(), redirectList = []; const preRequestTimestamp = process.hrtime(); node.status({ fill: "blue", shape: "dot", text: "requesting" }); let url = nodeUrl || msg.url; const apiUrl = `${utils.getApiUrl(server)}`; if (url.startsWith("/") ? url = `${apiUrl}${url}` : url = `${apiUrl}/${url}`, msg.url && nodeUrl && nodeUrl !== msg.url && node.warn( "Warning: msg properties can no longer override set node properties. See bit.ly/nr-override-msg-props" ), isTemplatedUrl && (url = mustache.render(nodeUrl, msg)), !url) { node.error("No url specified", msg), nodeDone(); return; } if (url.indexOf("://") !== -1 && url.indexOf("http") !== 0) { node.warn("non-http transport requested"), node.status({ fill: "red", shape: "ring", text: "non-http transport requested" }), nodeDone(); return; } if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0 || (tlsNode ? url = `https://${url}` : url = `http://${url}`), url.indexOf("?") > -1) { const [hostPath, ...queryString] = url.split("?"), query = queryString.join("?"); if (query) { const escapedQueryString = query.replace(/(%.?.?)/g, (v) => /^%[a-f0-9]{2}/i.test(v) ? v : v.replace(/%/, "%25")); url = `${hostPath}?${escapedQueryString}`; } } let method = nodeMethod.toUpperCase() || "GET"; msg.method && config.method && config.method !== "use" && node.warn( "Warning: msg properties can no longer override set node properties. See bit.ly/nr-override-msg-props" ), msg.method && config.method && config.method === "use" && (method = msg.method.toUpperCase()); const opts = {}; opts.timeout = node.reqTimeout, opts.throwHttpErrors = !1, opts.decompress = !1, opts.method = method, opts.retry = 0, opts.responseType = "buffer", opts.maxRedirects = 21, opts.cookieJar = new CookieJar(), opts.ignoreInvalidCookies = !0, opts.forever = nodeHTTPPersistent, msg.requestTimeout !== void 0 && (isNaN(msg.requestTimeout) ? node.warn("Timeout value is not a valid number, ignoring") : msg.requestTimeout < 1 ? node.warn("Timeout value is negative, ignoring") : opts.timeout = msg.requestTimeout); const originalHeaderMap = {}; opts.hooks = { beforeRequest: [ (options) => { Object.keys(options.headers).forEach((h) => { originalHeaderMap[h] && originalHeaderMap[h] !== h && (options.headers[originalHeaderMap[h]] = options.headers[h], delete options.headers[h]); }), node.insecureHTTPParser && (options.insecureHTTPParser = !0); } ], beforeRedirect: [ (options, response) => { const redirectInfo = { location: response.headers.location }; response.headers.hasOwnProperty("set-cookie") && (redirectInfo.cookies = extractCookies(response.headers["set-cookie"])), redirectList.push(redirectInfo); } ] }; let ctSet = "Content-Type", clSet = "Content-Length"; const normaliseKnownHeader = function(name2) { const _name = name2.toLowerCase(); switch (_name) { case "content-type": ctSet = name2, name2 = _name; break; case "content-length": clSet = name2, name2 = _name; break; } return name2; }; if (opts.headers = { "isaac-token": server.accessToken }, msg.headers) { if (msg.headers.hasOwnProperty("x-node-red-request-node")) { const headerHash = msg.headers["x-node-red-request-node"]; delete msg.headers["x-node-red-request-node"], hashSum(msg.headers) === headerHash && delete msg.headers; } if (msg.headers) for (const hn in msg.headers) { const name2 = normaliseKnownHeader(hn); updateHeader(opts.headers, name2, msg.headers[hn]); } } if (node.headers.length) for (let index = 0; index < node.headers.length; index++) { const header = node.headers[index]; let headerName, headerValue; if (header.keyType === "other" ? headerName = header.keyValue : header.keyType === "msg" ? RED.util.evaluateNodeProperty(header.keyValue, header.keyType, node, msg, (err, value) => { err || (headerName = value); }) : headerName = header.keyType, !headerName) continue; header.valueType === "other" ? headerValue = header.valueValue : header.valueType === "msg" ? RED.util.evaluateNodeProperty(header.valueValue, header.valueType, node, msg, (err, value) => { err || (headerValue = value); }) : headerValue = header.valueType; const hn = normaliseKnownHeader(headerName); updateHeader(opts.headers, hn, headerValue); } if (msg.hasOwnProperty("followRedirects") && (opts.followRedirect = !!msg.followRedirects), opts.headers.hasOwnProperty("cookie")) { const cookies = cookie.parse(opts.headers.cookie, { decode: String }); for (var name in cookies) opts.cookieJar.setCookieSync(cookie.serialize(name, cookies[name], { encode: String }), url, { ignoreError: !0 }); delete opts.headers.cookie; } if (msg.cookies) for (var name in msg.cookies) msg.cookies.hasOwnProperty(name) && (msg.cookies[name] === null || msg.cookies[name].value === null || (typeof msg.cookies[name] == "object" ? msg.cookies[name].encode === !1 ? opts.cookieJar.setCookieSync(cookie.serialize(name, msg.cookies[name].value, { encode: String }), url, { ignoreError: !0 }) : opts.cookieJar.setCookieSync(cookie.serialize(name, msg.cookies[name].value), url, { ignoreError: !0 }) : opts.cookieJar.setCookieSync(cookie.serialize(name, msg.cookies[name]), url, { ignoreError: !0 }))); const parsedURL = new URL(url); if (this.credentials = this.credentials || {}, parsedURL.username && !this.credentials.user && (this.credentials.user = parsedURL.username), parsedURL.password && !this.credentials.password && (this.credentials.password = parsedURL.password), Object.keys(this.credentials).length != 0) if (this.authType === "basic") (this.credentials.user || this.credentials.password) && (this.credentials.user === void 0 && (this.credentials.user = ""), this.credentials.password === void 0 && (this.credentials.password = ""), opts.headers.Authorization = `Basic ${Buffer.from( `${this.credentials.user}:${this.credentials.password}` ).toString("base64")}`); else if (this.authType === "digest") { const digestCreds = this.credentials; let sentCreds = !1; opts.hooks.afterResponse = [ (response, retry) => { if (response.statusCode === 401) { if (sentCreds) return response; const requestUrl = new URL(response.request.requestUrl), { options } = response.request, normalisedHeaders = {}; if (Object.keys(response.headers).forEach((k) => { normalisedHeaders[k.toLowerCase()] = response.headers[k]; }), normalisedHeaders["www-authenticate"]) { const authHeader = buildDigestHeader( digestCreds.user, digestCreds.password, options.method, requestUrl.pathname, normalisedHeaders["www-authenticate"] ); options.headers.Authorization = authHeader; } return sentCreds = !0, retry(options); } return response; } ]; } else this.authType === "bearer" && (opts.headers.Authorization = `Bearer ${this.credentials.password || ""}`); let payload = null; if (method !== "GET" && method !== "HEAD" && typeof msg.payload < "u") if (opts.headers["content-type"] == "multipart/form-data" && typeof msg.payload == "object") { const formData = new FormData(); for (const opt in msg.payload) if (msg.payload.hasOwnProperty(opt)) { const val = msg.payload[opt]; if (val != null) if (typeof val == "string" || Buffer.isBuffer(val)) formData.append(opt, val); else if (typeof val == "object" && val.hasOwnProperty("value")) formData.append(opt, val.value, val.options || {}); else if (Array.isArray(val)) for (var i = 0; i < val.length; i++) formData.append(opt, val[i]); else formData.append(opt, JSON.stringify(val)); } delete opts.headers["content-type"], opts.body = formData; } else typeof msg.payload == "string" || Buffer.isBuffer(msg.payload) ? payload = msg.payload : typeof msg.payload == "number" ? payload = `${msg.payload}` : opts.headers["content-type"] == "application/x-www-form-urlencoded" ? payload = querystring.stringify(msg.payload) : (payload = JSON.stringify(msg.payload), opts.headers["content-type"] == null && (opts.headers[ctSet] = "application/json")), opts.headers["content-length"] == null && (Buffer.isBuffer(payload) ? opts.headers[clSet] = payload.length : opts.headers[clSet] = Buffer.byteLength(payload)), opts.body = payload; if (method == "GET" && typeof msg.payload < "u" && paytoqs) if (typeof msg.payload == "object") try { url.indexOf("?") !== -1 ? url += (url.endsWith("?") ? "" : "&") + querystring.stringify(msg.payload) : url += `?${querystring.stringify(msg.payload)}`; } catch { node.error("Invalid payload", msg), nodeDone(); return; } else { node.error("Invalid payload", msg), nodeDone(); return; } else method == "GET" && typeof msg.payload < "u" && paytobody && (opts.allowGetBody = !0, typeof msg.payload == "object" ? opts.body = JSON.stringify(msg.payload) : typeof msg.payload == "number" ? opts.body = `${msg.payload}` : (typeof msg.payload == "string" || Buffer.isBuffer(msg.payload)) && (opts.body = msg.payload)); opts.headers.hasOwnProperty("content-type") && ctSet !== "content-type" && (opts.headers[ctSet] = opts.headers["content-type"], delete opts.headers["content-type"]), opts.headers.hasOwnProperty("content-length") && clSet !== "content-length" && (opts.headers[clSet] = opts.headers["content-length"], delete opts.headers["content-length"]); let noproxy; if (noprox) for (var i = 0; i < noprox.length; i += 1) url.indexOf(noprox[i]) !== -1 && (noproxy = !0); if (prox && !noproxy) if (prox.match(/^(https?:\/\/)?(.+)?:([0-9]+)?/i)) { let proxyAgent; const proxyURL = new URL(prox), proxyOptions = { proxy: { protocol: proxyURL.protocol, hostname: proxyURL.hostname, port: proxyURL.port, username: null, password: null }, maxFreeSockets: 256, maxSockets: 256, keepAlive: !0 }; if (proxyConfig && proxyConfig.credentials) { const proxyUsername = proxyConfig.credentials.username || "", proxyPassword = proxyConfig.credentials.password || ""; (proxyUsername || proxyPassword) && (proxyOptions.proxy.username = proxyUsername, proxyOptions.proxy.password = proxyPassword); } else (proxyURL.username || proxyURL.password) && (proxyOptions.proxy.username = proxyURL.username, proxyOptions.proxy.password = proxyURL.password); opts.agent = { http: new HttpProxyAgent(proxyOptions), https: new HttpsProxyAgent(proxyOptions) }; } else node.warn(`Bad proxy url: ${prox}`); tlsNode ? (opts.https = {}, tlsNode.addTLSOptions(opts.https), opts.https.ca && (opts.https.certificateAuthority = opts.https.ca, delete opts.https.ca), opts.https.cert && (opts.https.certificate = opts.https.cert, delete opts.https.cert)) : msg.hasOwnProperty("rejectUnauthorized") && (opts.https = { rejectUnauthorized: msg.rejectUnauthorized }), opts.headers && Object.keys(opts.headers).forEach((h) => { originalHeaderMap[h.toLowerCase()] = h; }), got(url, opts).then((res) => { if (msg.statusCode = res.statusCode, msg.headers = res.headers, msg.responseUrl = res.url, msg.payload = res.body, msg.redirectList = redirectList, msg.retry = 0, msg.headers.hasOwnProperty("set-cookie") && (msg.responseCookies = extractCookies(msg.headers["set-cookie"])), msg.headers["x-node-red-request-node"] = hashSum(msg.headers), node.metric()) { const diff = process.hrtime(preRequestTimestamp), metricRequestDurationMillis = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(3); node.metric("duration.millis", msg, metricRequestDurationMillis), res.client && res.client.bytesRead && node.metric("size.bytes", msg, res.client.bytesRead), timingLog && emitTimingMetricLog(res.timings, msg); } if (node.ret !== "bin" && (msg.payload = msg.payload.toString("utf8"), node.ret === "obj")) try { msg.payload = JSON.parse(msg.payload); } catch { node.warn("JSON parse error"); } node.status({}), nodeSend(msg), nodeDone(); }).catch((err) => { err.code === "ETIMEDOUT" || err.code === "ESOCKETTIMEDOUT" ? (node.error("no response from server", msg), node.status({ fill: "red", shape: "ring", text: "no response from server" })) : (node.error(err, msg), node.status({ fill: "red", shape: "ring", text: err.code })), msg.payload = `${err.toString()} : ${url}`, msg.statusCode = err.code || (err.response ? err.response.statusCode : void 0), node.metric() && timingLog && emitTimingMetricLog(err.timings, msg), sendErrorsToCatch || nodeSend(msg), nodeDone(); }); }), this.on("close", () => { didAddUsedConnection && utils.decrementConnectionUsage(node, server), node.status({}); }); function emitTimingMetricLog(timings, msg) { timings && [ "start", "socket", "lookup", "connect", "secureConnect", "upload", "response", "end", "error", "abort" ].forEach((p) => { timings[p] && node.metric(`timings.${p}`, msg, timings[p]); }); } function extractCookies(setCookie) { const cookies = {}; return setCookie.forEach((c) => { const parsedCookie = cookie.parse(c), eq_idx = c.indexOf("="), key = c.substr(0, eq_idx).trim(); parsedCookie.value = parsedCookie[key], delete parsedCookie[key], cookies[key] = parsedCookie; }), cookies; } } RED.nodes.registerType("isaac generic request", GenericRequestNode, { credentials: { user: { type: "text" }, password: { type: "password" } } }); const md5 = (value) => crypto.createHash("md5").update(value).digest("hex"); function ha1Compute(algorithm, user, realm, pass, nonce, cnonce) { const ha1 = md5(`${user}:${realm}:${pass}`); return algorithm && algorithm.toLowerCase() === "md5-sess" ? md5(`${ha1}:${nonce}:${cnonce}`) : ha1; } function buildDigestHeader(user, pass, method, path, authHeader) { const challenge = {}, re = /([a-z0-9_-]+)=(?:"([^"]+)"|([a-z0-9_-]+))/gi; for (; ; ) { const match = re.exec(authHeader); if (!match) break; challenge[match[1]] = match[2] || match[3]; } const qop = /(^|,)\s*auth\s*($|,)/.test(challenge.qop) && "auth", nc = qop && "00000001", cnonce = qop && uuid().replace(/-/g, ""), ha1 = ha1Compute(challenge.algorithm, user, challenge.realm, pass, challenge.nonce, cnonce), ha2 = md5(`${method}:${path}`), digestResponse = md5(qop ? `${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}` : `${ha1}:${challenge.nonce}:${ha2}`), authValues = { username: user, realm: challenge.realm, nonce: challenge.nonce, uri: path, qop, response: digestResponse, nc, cnonce, algorithm: challenge.algorithm, opaque: challenge.opaque }; authHeader = []; for (const k in authValues) authValues[k] && (k === "qop" || k === "nc" || k === "algorithm" ? authHeader.push(`${k}=${authValues[k]}`) : authHeader.push(`${k}="${authValues[k]}"`)); return authHeader = `Digest ${authHeader.join(", ")}`, authHeader; } };