@isaac-platform/isaac-node-red
Version:
Set of Node-RED nodes to communicate with an ISAAC system
330 lines (326 loc) • 20.2 kB
JavaScript
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;
}
};