| 1 | /** |
| 2 | * @description MeshCentral Common Library |
| 3 | * @author Ylian Saint-Hilaire |
| 4 | * @copyright Intel Corporation 2018-2022 |
| 5 | * @license Apache-2.0 |
| 6 | * @version v0.0.1 |
| 7 | */ |
| 8 | |
| 9 | /*xjslint node: true */ |
| 10 | /*xjslint plusplus: true */ |
| 11 | /*xjslint maxlen: 256 */ |
| 12 | /*jshint node: true */ |
| 13 | /*jshint strict: false */ |
| 14 | /*jshint esversion: 6 */ |
| 15 | 'use strict'; |
| 16 | |
| 17 | const fs = require('fs'); |
| 18 | const crypto = require('crypto'); |
| 19 | const path = require('path'); |
| 20 | const { URL } = require('url'); |
| 21 | |
| 22 | // Binary encoding and decoding functions |
| 23 | module.exports.ReadShort = function (v, p) { return (v.charCodeAt(p) << 8) + v.charCodeAt(p + 1); }; |
| 24 | module.exports.ReadShortX = function (v, p) { return (v.charCodeAt(p + 1) << 8) + v.charCodeAt(p); }; |
| 25 | module.exports.ReadInt = function (v, p) { return (v.charCodeAt(p) * 0x1000000) + (v.charCodeAt(p + 1) << 16) + (v.charCodeAt(p + 2) << 8) + v.charCodeAt(p + 3); }; // We use "*0x1000000" instead of "<<24" because the shift converts the number to signed int32. |
| 26 | module.exports.ReadIntX = function (v, p) { return (v.charCodeAt(p + 3) * 0x1000000) + (v.charCodeAt(p + 2) << 16) + (v.charCodeAt(p + 1) << 8) + v.charCodeAt(p); }; |
| 27 | module.exports.ShortToStr = function (v) { return String.fromCharCode((v >> 8) & 0xFF, v & 0xFF); }; |
| 28 | module.exports.ShortToStrX = function (v) { return String.fromCharCode(v & 0xFF, (v >> 8) & 0xFF); }; |
| 29 | module.exports.IntToStr = function (v) { return String.fromCharCode((v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF); }; |
| 30 | module.exports.IntToStrX = function (v) { return String.fromCharCode(v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF); }; |
| 31 | module.exports.MakeToArray = function (v) { if (!v || v == null || typeof v == 'object') return v; return [v]; }; |
| 32 | module.exports.SplitArray = function (v) { return v.split(','); }; |
| 33 | module.exports.Clone = function (v) { return JSON.parse(JSON.stringify(v)); }; |
| 34 | module.exports.IsFilenameValid = (function () { var x1 = /^[^\\/:\*\?"<>\|]+$/, x2 = /^\./, x3 = /^(nul|prn|con|lpt[0-9]|com[0-9])(\.|$)/i; return function isFilenameValid(fname) { return module.exports.validateString(fname, 1, 4096) && x1.test(fname) && !x2.test(fname) && !x3.test(fname) && (fname[0] != '.'); }; })(); |
| 35 | module.exports.makeFilename = function (v) { return v.split('\\').join('').split('/').join('').split(':').join('').split('*').join('').split('?').join('').split('"').join('').split('<').join('').split('>').join('').split('|').join('').split(' ').join('').split('\'').join(''); } |
| 36 | module.exports.joinPath = function (base, path_) { return path.isAbsolute(path_) ? path_ : path.join(base, path_); } |
| 37 | |
| 38 | // Move an element from one position in an array to a new position |
| 39 | module.exports.ArrayElementMove = function(arr, from, to) { arr.splice(to, 0, arr.splice(from, 1)[0]); }; |
| 40 | |
| 41 | // Format a string with arguments, "replaces {0} and {1}..." |
| 42 | module.exports.format = function (format) { var args = Array.prototype.slice.call(arguments, 1); return format.replace(/{(\d+)}/g, function (match, number) { return typeof args[number] != 'undefined' ? args[number] : match; }); }; |
| 43 | |
| 44 | // Print object for HTML |
| 45 | module.exports.ObjectToStringEx = function (x, c) { |
| 46 | var r = '', i; |
| 47 | if (x != 0 && (!x || x == null)) return "(Null)"; |
| 48 | if (x instanceof Array) { for (i in x) { r += '<br />' + gap(c) + "Item #" + i + ": " + module.exports.ObjectToStringEx(x[i], c + 1); } } |
| 49 | else if (x instanceof Object) { for (i in x) { r += '<br />' + gap(c) + i + " = " + module.exports.ObjectToStringEx(x[i], c + 1); } } |
| 50 | else { r += x; } |
| 51 | return r; |
| 52 | }; |
| 53 | |
| 54 | // Print object for console |
| 55 | module.exports.ObjectToStringEx2 = function (x, c) { |
| 56 | var r = '', i; |
| 57 | if (x != 0 && (!x || x == null)) return "(Null)"; |
| 58 | if (x instanceof Array) { for (i in x) { r += '\r\n' + gap2(c) + "Item #" + i + ": " + module.exports.ObjectToStringEx2(x[i], c + 1); } } |
| 59 | else if (x instanceof Object) { for (i in x) { r += '\r\n' + gap2(c) + i + " = " + module.exports.ObjectToStringEx2(x[i], c + 1); } } |
| 60 | else { r += x; } |
| 61 | return r; |
| 62 | }; |
| 63 | |
| 64 | // Create an ident gap |
| 65 | module.exports.gap = function (c) { var x = ''; for (var i = 0; i < (c * 4); i++) { x += ' '; } return x; }; |
| 66 | module.exports.gap2 = function (c) { var x = ''; for (var i = 0; i < (c * 4); i++) { x += ' '; } return x; }; |
| 67 | |
| 68 | // Print an object in html |
| 69 | module.exports.ObjectToString = function (x) { return module.exports.ObjectToStringEx(x, 0); }; |
| 70 | module.exports.ObjectToString2 = function (x) { return module.exports.ObjectToStringEx2(x, 0); }; |
| 71 | |
| 72 | // Convert a hex string to a raw string |
| 73 | module.exports.hex2rstr = function (d) { |
| 74 | var r = '', m = ('' + d).match(/../g), t; |
| 75 | while (t = m.shift()) { r += String.fromCharCode('0x' + t); } |
| 76 | return r; |
| 77 | }; |
| 78 | |
| 79 | // Convert decimal to hex |
| 80 | module.exports.char2hex = function (i) { return (i + 0x100).toString(16).substr(-2).toUpperCase(); }; |
| 81 | |
| 82 | // Convert a raw string to a hex string |
| 83 | module.exports.rstr2hex = function (input) { |
| 84 | var r = '', i; |
| 85 | for (i = 0; i < input.length; i++) { r += module.exports.char2hex(input.charCodeAt(i)); } |
| 86 | return r; |
| 87 | }; |
| 88 | |
| 89 | // UTF-8 encoding & decoding functions |
| 90 | module.exports.encode_utf8 = function (s) { return unescape(encodeURIComponent(s)); }; |
| 91 | module.exports.decode_utf8 = function (s) { return decodeURIComponent(escape(s)); }; |
| 92 | |
| 93 | // Convert a string into a blob |
| 94 | module.exports.data2blob = function (data) { |
| 95 | var bytes = new Array(data.length); |
| 96 | for (var i = 0; i < data.length; i++) bytes[i] = data.charCodeAt(i); |
| 97 | var blob = new Blob([new Uint8Array(bytes)]); |
| 98 | return blob; |
| 99 | }; |
| 100 | |
| 101 | // Generate random numbers between 0 and max without bias. |
| 102 | module.exports.random = function (max) { |
| 103 | const crypto = require('crypto'); |
| 104 | var maxmask = 1, r; |
| 105 | while (maxmask < max) { maxmask = (maxmask << 1) + 1; } |
| 106 | do { r = (crypto.randomBytes(4).readUInt32BE(0) & maxmask); } while (r > max); |
| 107 | return r; |
| 108 | }; |
| 109 | |
| 110 | // Split a comma separated string, ignoring commas in quotes. |
| 111 | module.exports.quoteSplit = function (str) { |
| 112 | var tmp = '', quote = 0, result = []; |
| 113 | for (var i in str) { if (str[i] == '"') { quote = (quote + 1) % 2; } if ((str[i] == ',') && (quote == 0)) { tmp = tmp.trim(); result.push(tmp); tmp = ''; } else { tmp += str[i]; } } |
| 114 | if (tmp.length > 0) result.push(tmp.trim()); |
| 115 | return result; |
| 116 | }; |
| 117 | |
| 118 | // Convert list of "name = value" into object |
| 119 | module.exports.parseNameValueList = function (list) { |
| 120 | var result = []; |
| 121 | for (var i in list) { |
| 122 | var j = list[i].indexOf('='); |
| 123 | if (j > 0) { |
| 124 | var v = list[i].substring(j + 1).trim(); |
| 125 | if ((v[0] == '"') && (v[v.length - 1] == '"')) { v = v.substring(1, v.length - 1); } |
| 126 | result[list[i].substring(0, j).trim()] = v; |
| 127 | } |
| 128 | } |
| 129 | return result; |
| 130 | }; |
| 131 | |
| 132 | // Compute the MD5 digest hash for a set of values |
| 133 | module.exports.ComputeDigesthash = function (username, password, realm, method, path, qop, nonce, nc, cnonce) { |
| 134 | var ha1 = crypto.createHash('md5').update(username + ":" + realm + ":" + password).digest('hex'); |
| 135 | var ha2 = crypto.createHash('md5').update(method + ":" + path).digest('hex'); |
| 136 | return crypto.createHash('md5').update(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2).digest("hex"); |
| 137 | }; |
| 138 | |
| 139 | module.exports.toNumber = function (str) { var x = parseInt(str); if (x == str) return x; return str; }; |
| 140 | module.exports.escapeHtml = function (string) { return String(string).replace(/[&<>"'`=\/]/g, function (s) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=' }[s]; }); }; |
| 141 | module.exports.escapeHtmlBreaks = function (string) { return String(string).replace(/[&<>"'`=\/]/g, function (s) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=', '\r': '<br />', '\n': '' }[s]; }); }; |
| 142 | module.exports.zeroPad = function(num, c) { if (c == null) { c = 2; } var s = '000000' + num; return s.substr(s.length - c); } |
| 143 | |
| 144 | // Lowercase all the names in a object recursively |
| 145 | // Allow for exception keys, child of exceptions will not get lower-cased. |
| 146 | // Exceptions is an array of "keyname" or "parent\keyname" |
| 147 | module.exports.objKeysToLower = function (obj, exceptions, parent) { |
| 148 | for (var i in obj) { |
| 149 | if ((typeof obj[i] == 'object') && |
| 150 | ((exceptions == null) || (exceptions.indexOf(i.toLowerCase()) == -1) && ((parent == null) || (exceptions.indexOf(parent.toLowerCase() + '/' + i.toLowerCase()) == -1))) |
| 151 | ) { |
| 152 | module.exports.objKeysToLower(obj[i], exceptions, i); // LowerCase all key names in the child object |
| 153 | } |
| 154 | if (i.toLowerCase() !== i) { obj[i.toLowerCase()] = obj[i]; delete obj[i]; } // LowerCase all key names |
| 155 | } |
| 156 | return obj; |
| 157 | }; |
| 158 | |
| 159 | // Escape and unescape field names so there are no invalid characters for MongoDB/NeDB ("$", ",", ".", see https://github.com/seald/nedb/tree/master?tab=readme-ov-file#inserting-documents) |
| 160 | module.exports.escapeFieldName = function (name) { if ((name.indexOf(',') == -1) && (name.indexOf('%') == -1) && (name.indexOf('.') == -1) && (name.indexOf('$') == -1)) return name; return name.split('%').join('%25').split('.').join('%2E').split('$').join('%24').split(',').join('%2C'); }; |
| 161 | module.exports.unEscapeFieldName = function (name) { if (name.indexOf('%') == -1) return name; return name.split('%2C').join(',').split('%2E').join('.').split('%24').join('$').split('%25').join('%'); }; |
| 162 | |
| 163 | // Escape all links, SSH and RDP usernames |
| 164 | // This is required for databases like NeDB that don't accept "." or "," as part of a field name. |
| 165 | module.exports.escapeLinksFieldNameEx = function (docx) { if ((docx.links == null) && (docx.ssh == null) && (docx.rdp == null)) { return docx; } return module.exports.escapeLinksFieldName(docx); }; |
| 166 | module.exports.escapeLinksFieldName = function (docx) { |
| 167 | var doc = Object.assign({}, docx); |
| 168 | if (doc.links != null) { doc.links = Object.assign({}, doc.links); for (var i in doc.links) { var ue = module.exports.escapeFieldName(i); if (ue !== i) { doc.links[ue] = doc.links[i]; delete doc.links[i]; } } } |
| 169 | if (doc.ssh != null) { doc.ssh = Object.assign({}, doc.ssh); for (var i in doc.ssh) { var ue = module.exports.escapeFieldName(i); if (ue !== i) { doc.ssh[ue] = doc.ssh[i]; delete doc.ssh[i]; } } } |
| 170 | if (doc.rdp != null) { doc.rdp = Object.assign({}, doc.rdp); for (var i in doc.rdp) { var ue = module.exports.escapeFieldName(i); if (ue !== i) { doc.rdp[ue] = doc.rdp[i]; delete doc.rdp[i]; } } } |
| 171 | return doc; |
| 172 | }; |
| 173 | module.exports.unEscapeLinksFieldName = function (doc) { |
| 174 | if (doc.links != null) { for (var j in doc.links) { var ue = module.exports.unEscapeFieldName(j); if (ue !== j) { doc.links[ue] = doc.links[j]; delete doc.links[j]; } } } |
| 175 | if (doc.ssh != null) { for (var j in doc.ssh) { var ue = module.exports.unEscapeFieldName(j); if (ue !== j) { doc.ssh[ue] = doc.ssh[j]; delete doc.ssh[j]; } } } |
| 176 | if (doc.rdp != null) { for (var j in doc.rdp) { var ue = module.exports.unEscapeFieldName(j); if (ue !== j) { doc.rdp[ue] = doc.rdp[j]; delete doc.rdp[j]; } } } |
| 177 | return doc; |
| 178 | }; |
| 179 | //module.exports.escapeAllLinksFieldName = function (docs) { for (var i in docs) { module.exports.escapeLinksFieldName(docs[i]); } return docs; }; |
| 180 | module.exports.unEscapeAllLinksFieldName = function (docs) { for (var i in docs) { docs[i] = module.exports.unEscapeLinksFieldName(docs[i]); } return docs; }; |
| 181 | |
| 182 | // Escape field names for aceBase |
| 183 | var aceEscFields = ['links', 'ssh', 'rdp', 'notify']; |
| 184 | module.exports.aceEscapeFieldNames = function (docx) { var doc = Object.assign({}, docx); for (var k in aceEscFields) { if (typeof doc[aceEscFields[k]] == 'object') { doc[aceEscFields[k]] = Object.assign({}, doc[aceEscFields[k]]); for (var i in doc[aceEscFields[k]]) { var ue = encodeURIComponent(i); if (ue !== i) { doc[aceEscFields[k]][ue] = doc[aceEscFields[k]][i]; delete doc[aceEscFields[k]][i]; } } } } return doc; }; |
| 185 | module.exports.aceUnEscapeFieldNames = function (doc) { for (var k in aceEscFields) { if (typeof doc[aceEscFields[k]] == 'object') { for (var j in doc[aceEscFields[k]]) { var ue = decodeURIComponent(j); if (ue !== j) { doc[aceEscFields[k]][ue] = doc[aceEscFields[k]][j]; delete doc[aceEscFields[k]][j]; } } } } return doc; }; |
| 186 | module.exports.aceUnEscapeAllFieldNames = function (docs) { for (var i in docs) { docs[i] = module.exports.aceUnEscapeFieldNames(docs[i]); } return docs; }; |
| 187 | |
| 188 | // Validation methods |
| 189 | module.exports.validateString = function (str, minlen, maxlen) { return ((str != null) && (typeof str == 'string') && ((minlen == null) || (str.length >= minlen)) && ((maxlen == null) || (str.length <= maxlen))); }; |
| 190 | module.exports.validateInt = function (int, minval, maxval) { return ((int != null) && (typeof int == 'number') && ((minval == null) || (int >= minval)) && ((maxval == null) || (int <= maxval))); }; |
| 191 | module.exports.validateArray = function (array, minlen, maxlen) { return ((array != null) && Array.isArray(array) && ((minlen == null) || (array.length >= minlen)) && ((maxlen == null) || (array.length <= maxlen))); }; |
| 192 | module.exports.validateStrArray = function (array, minlen, maxlen) { if (((array != null) && Array.isArray(array)) == false) return false; for (var i in array) { if ( (typeof array[i] != 'string') || ((minlen != null) && (array[i].length < minlen)) || ((maxlen != null) && (array[i].length > maxlen))) return false; } return true; }; |
| 193 | module.exports.validateObject = function (obj) { return ((obj != null) && (typeof obj == 'object')); }; |
| 194 | module.exports.validateEmail = function (email, minlen, maxlen) { if (module.exports.validateString(email, minlen, maxlen) == false) return false; var emailReg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return emailReg.test(email); }; |
| 195 | module.exports.validateUsername = function (username, minlen, maxlen) { return (module.exports.validateString(username, minlen, maxlen) && (username.indexOf(' ') == -1) && (username.indexOf('"') == -1) && (username.indexOf(',') == -1)); }; |
| 196 | module.exports.isAlphaNumeric = function (str) { return (str.match(/^[A-Za-z0-9]+$/) != null); }; |
| 197 | module.exports.validateAlphaNumericArray = function (array, minlen, maxlen) { if (((array != null) && Array.isArray(array)) == false) return false; for (var i in array) { if ((typeof array[i] != 'string') || (module.exports.isAlphaNumeric(array[i]) == false) || ((minlen != null) && (array[i].length < minlen)) || ((maxlen != null) && (array[i].length > maxlen)) ) return false; } return true; }; |
| 198 | module.exports.getEmailDomain = function(email) { |
| 199 | if (!module.exports.validateEmail(email, 1, 1024)) { |
| 200 | return ''; |
| 201 | } |
| 202 | const i = email.indexOf('@'); |
| 203 | return email.substring(i + 1).toLowerCase(); |
| 204 | } |
| 205 | |
| 206 | module.exports.validateEmailDomain = function(email, allowedDomains) { |
| 207 | // Check if this request is for an allows email domain |
| 208 | if ((allowedDomains != null) && Array.isArray(allowedDomains)) { |
| 209 | const emaildomain = module.exports.getEmailDomain(email); |
| 210 | if (emaildomain === '') { |
| 211 | return false; |
| 212 | } |
| 213 | var emailok = false; |
| 214 | for (var i in allowedDomains) { if (emaildomain == allowedDomains[i].toLowerCase()) { emailok = true; } } |
| 215 | return emailok; |
| 216 | } |
| 217 | |
| 218 | return true; |
| 219 | } |
| 220 | |
| 221 | // Validate that a string is a parseable http or https URL with a hostname |
| 222 | module.exports.validateUrl = function (url) { |
| 223 | if (!module.exports.validateString(url, 1, 4096)) return false; |
| 224 | try { |
| 225 | const u = new URL(url); |
| 226 | const scheme = (u.protocol || '').replace(/:$/, '').toLowerCase(); |
| 227 | if (scheme !== 'http' && scheme !== 'https') return false; |
| 228 | if (!u.hostname || u.hostname.length === 0) return false; |
| 229 | return true; |
| 230 | } catch (ex) { |
| 231 | return false; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // Validate a remote image URL by checking headers and magic bytes (PNG/JPEG/GIF/WEBP/ICO) |
| 236 | // Returns a Promise<boolean> that always resolves to true (valid image) or false (invalid/error) and never rejects. |
| 237 | module.exports.validateRemoteImage = function (url, options) { |
| 238 | options = options || {}; |
| 239 | const timeoutMs = (typeof options.timeoutMs === 'number') ? options.timeoutMs : 5000; |
| 240 | const maxHeadBytes = (typeof options.maxHeadBytes === 'number') ? options.maxHeadBytes : 16384; // 16 KB |
| 241 | const maxContentLength = (typeof options.maxContentLength === 'number') ? options.maxContentLength : (5 * 1024 * 1024); // 5 MB |
| 242 | const allowedMimes = options.allowedMimes || ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/svg+xml']; |
| 243 | const agent = options.agent || undefined; |
| 244 | // This function MUST always resolve to boolean true/false and never reject. |
| 245 | return new Promise((resolve) => { |
| 246 | try { |
| 247 | if (!module.exports.validateUrl(url)) return resolve(false); |
| 248 | |
| 249 | const http = require('http'); |
| 250 | const https = require('https'); |
| 251 | |
| 252 | function doRequest(method, reqUrl, headers, redirectsLeft, cb) { |
| 253 | try { |
| 254 | const u = new URL(reqUrl); |
| 255 | const lib = (u.protocol === 'https:') ? https : http; |
| 256 | const reqOpts = { method: method, headers: headers || {}, timeout: timeoutMs }; |
| 257 | if (agent) { reqOpts.agent = agent; } |
| 258 | const req = lib.request(u, reqOpts, (res) => { |
| 259 | // Follow redirects (3xx) |
| 260 | if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) { |
| 261 | res.resume(); |
| 262 | const redirectUrl = new URL(res.headers.location, u).toString(); |
| 263 | // Re-validate each redirect target |
| 264 | if (!module.exports.validateUrl(redirectUrl)) { |
| 265 | return cb(new Error('Invalid redirect URL')); |
| 266 | } |
| 267 | return doRequest(method, redirectUrl, headers, redirectsLeft - 1, cb); |
| 268 | } |
| 269 | cb(null, res, u.toString()); |
| 270 | }); |
| 271 | req.on('error', (e) => cb(e)); |
| 272 | req.on('timeout', () => { req.destroy(new Error('timeout')); }); |
| 273 | req.end(); |
| 274 | } catch (ex) { cb(ex); } |
| 275 | } |
| 276 | |
| 277 | // Centralized magic-byte detector |
| 278 | function detectBuffer(b) { |
| 279 | if (!b || b.length < 4) return null; |
| 280 | // PNG |
| 281 | if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47 && b[4] === 0x0D && b[5] === 0x0A && b[6] === 0x1A && b[7] === 0x0A) return { mime: 'image/png', ext: 'png' }; |
| 282 | // JPEG |
| 283 | if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return { mime: 'image/jpeg', ext: 'jpg' }; |
| 284 | // GIF |
| 285 | if (b.length >= 6 && b.slice(0, 3).toString('ascii') === 'GIF') return { mime: 'image/gif', ext: 'gif' }; |
| 286 | // WEBP (RIFF....WEBP) |
| 287 | if (b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WEBP') return { mime: 'image/webp', ext: 'webp' }; |
| 288 | // ICO (00 00 01 00) |
| 289 | if (b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00) return { mime: 'image/x-icon', ext: 'ico' }; |
| 290 | // SVG (text-based XML; look for '<svg' or '<?xml' after optional whitespace/BOM) |
| 291 | try { |
| 292 | var svgHead = b.slice(0, Math.min(b.length, 256)).toString('utf8').trimStart(); |
| 293 | if (svgHead.charCodeAt(0) === 0xFEFF) { svgHead = svgHead.slice(1); } // strip BOM |
| 294 | if (svgHead.startsWith('<svg') || svgHead.startsWith('<?xml')) return { mime: 'image/svg+xml', ext: 'svg' }; |
| 295 | } catch (e) {} |
| 296 | return null; |
| 297 | } |
| 298 | |
| 299 | // Inspect an incoming GET response stream for image magic bytes. |
| 300 | function inspectStreamForImage(getRes) { |
| 301 | return new Promise((resolveInspect) => { |
| 302 | try { |
| 303 | const chunks = []; |
| 304 | let length = 0; |
| 305 | let timedOut = false; |
| 306 | let finished = false; |
| 307 | const to = setTimeout(() => { timedOut = true; try { getRes.destroy(new Error('timeout')); } catch (e) {} }, timeoutMs + 1000); |
| 308 | |
| 309 | function finalize(result) { |
| 310 | if (finished) return; |
| 311 | finished = true; |
| 312 | clearTimeout(to); |
| 313 | try { getRes.removeAllListeners('data'); getRes.removeAllListeners('end'); getRes.removeAllListeners('close'); getRes.removeAllListeners('error'); } catch (e) {} |
| 314 | try { return resolveInspect(result); } catch (e) { return; } |
| 315 | } |
| 316 | |
| 317 | getRes.on('data', (chunk) => { |
| 318 | if (timedOut || finished) return; |
| 319 | if (length < maxHeadBytes) { |
| 320 | const remaining = maxHeadBytes - length; |
| 321 | if (chunk.length <= remaining) { |
| 322 | chunks.push(chunk); |
| 323 | length += chunk.length; |
| 324 | } else { |
| 325 | chunks.push(chunk.slice(0, remaining)); |
| 326 | length += remaining; |
| 327 | } |
| 328 | } |
| 329 | if (length >= maxHeadBytes) { |
| 330 | try { |
| 331 | const buf = Buffer.concat(chunks, Math.min(length, maxHeadBytes)); |
| 332 | const det = detectBuffer(buf); |
| 333 | if (!det) finalize(false); |
| 334 | else if (allowedMimes.indexOf(det.mime) === -1) finalize(false); |
| 335 | else finalize(true); |
| 336 | } catch (ex) { finalize(false); } |
| 337 | try { getRes.destroy(); } catch (e) {} |
| 338 | } |
| 339 | }); |
| 340 | |
| 341 | getRes.on('end', () => { |
| 342 | if (finished) return; |
| 343 | try { |
| 344 | const buf = Buffer.concat(chunks, Math.min(length, maxHeadBytes)); |
| 345 | const det = detectBuffer(buf); |
| 346 | if (!det) finalize(false); |
| 347 | else if (allowedMimes.indexOf(det.mime) === -1) finalize(false); |
| 348 | else finalize(true); |
| 349 | } catch (ex) { finalize(false); } |
| 350 | }); |
| 351 | |
| 352 | getRes.on('close', () => { if (!finished) finalize(false); }); |
| 353 | getRes.on('error', (e) => { if (!finished) finalize(false); }); |
| 354 | } catch (ex) { try { return resolveInspect(false); } catch (e) {} } |
| 355 | }); |
| 356 | } |
| 357 | |
| 358 | // First do a HEAD to validate content-type/length. Some hosts/CDNs don't |
| 359 | // support HEAD (returning 405/403/501) even though GET would work. In |
| 360 | // that case, fall back to a small ranged GET to validate magic bytes. |
| 361 | doRequest('HEAD', url, { 'User-Agent': 'MeshCentral/validateRemoteImage' }, 5, (err, headRes, finalUrl) => { |
| 362 | // If doRequest failed to produce a finalUrl (network error), |
| 363 | // fall back to the original requested URL to allow GET to be |
| 364 | // attempted when HEAD fails with network/DNS issues. |
| 365 | const effectiveUrl = finalUrl || url; |
| 366 | try { |
| 367 | // If HEAD errored or returned a client/server error |
| 368 | if (err || (headRes && headRes.statusCode >= 400)) { |
| 369 | // If the status suggests HEAD is not allowed/implemented or forbidden, |
| 370 | // attempt a ranged GET fallback. For other 4xx/5xx responses, fail fast. |
| 371 | const status = headRes && headRes.statusCode ? headRes.statusCode : 0; |
| 372 | if (err || status === 405 || status === 501 || status === 403) { |
| 373 | try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {} |
| 374 | const headers = { 'Range': 'bytes=0-' + (maxHeadBytes - 1), 'User-Agent': 'MeshCentral/validateRemoteImage' }; |
| 375 | // Ranged GET fallback: reuse the same GET inspection logic. |
| 376 | // Use the effectiveUrl (finalUrl or original url) in case |
| 377 | // the HEAD redirect wasn't available. |
| 378 | doRequest('GET', effectiveUrl, headers, 5, (err2, getRes) => { |
| 379 | try { |
| 380 | if (err2) return resolve(false); |
| 381 | if (getRes.statusCode !== 200 && getRes.statusCode !== 206) { getRes.resume(); return resolve(false); } |
| 382 | inspectStreamForImage(getRes).then((r) => resolve(r)).catch(() => resolve(false)); |
| 383 | } catch (ex) { return resolve(false); } |
| 384 | }); |
| 385 | return; |
| 386 | } |
| 387 | // Other HTTP errors from HEAD should fail fast |
| 388 | try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {} |
| 389 | return resolve(false); |
| 390 | } |
| 391 | const ct = (headRes.headers['content-type'] || '').toLowerCase(); |
| 392 | const clen = parseInt(headRes.headers['content-length'] || '0', 10) || 0; |
| 393 | if (clen > 0 && clen > maxContentLength) { headRes.resume(); return resolve(false); } |
| 394 | if (ct && (allowedMimes.indexOf(ct.split(';')[0].trim()) === -1)) { headRes.resume(); return resolve(false); } |
| 395 | // Now fetch a partial range to inspect magic bytes |
| 396 | const headers = { 'Range': 'bytes=0-' + (maxHeadBytes - 1), 'User-Agent': 'MeshCentral/validateRemoteImage' }; |
| 397 | // Drain any potential body from the HEAD response so the |
| 398 | // socket can be reused and not held open when issuing the GET. |
| 399 | try { if (headRes && headRes.resume) headRes.resume(); } catch (e) {} |
| 400 | doRequest('GET', effectiveUrl, headers, 5, (err2, getRes) => { |
| 401 | try { |
| 402 | if (err2) return resolve(false); |
| 403 | if (getRes.statusCode !== 200 && getRes.statusCode !== 206) { getRes.resume(); return resolve(false); } |
| 404 | inspectStreamForImage(getRes).then((r) => resolve(r)).catch(() => resolve(false)); |
| 405 | } catch (ex) { return resolve(false); } |
| 406 | }); |
| 407 | } catch (ex) { return resolve(false); } |
| 408 | }); |
| 409 | } catch (ex) { |
| 410 | return resolve(false); |
| 411 | } |
| 412 | }); |
| 413 | } |
| 414 | |
| 415 | // Check password requirements |
| 416 | module.exports.checkPasswordRequirements = function(password, requirements) { |
| 417 | if ((requirements == null) || (requirements == '') || (typeof requirements != 'object')) return true; |
| 418 | if (requirements.min) { if (password.length < requirements.min) return false; } |
| 419 | if (requirements.max) { if (password.length > requirements.max) return false; } |
| 420 | var numeric = 0, lower = 0, upper = 0, nonalpha = 0; |
| 421 | for (var i = 0; i < password.length; i++) { |
| 422 | if (/\d/.test(password[i])) { numeric++; } |
| 423 | if (/[a-z]/.test(password[i])) { lower++; } |
| 424 | if (/[A-Z]/.test(password[i])) { upper++; } |
| 425 | if (/\W/.test(password[i])) { nonalpha++; } |
| 426 | } |
| 427 | if (requirements.numeric && (numeric < requirements.numeric)) return false; |
| 428 | if (requirements.lower && (lower < requirements.lower)) return false; |
| 429 | if (requirements.upper && (upper < requirements.upper)) return false; |
| 430 | if (requirements.nonalpha && (nonalpha < requirements.nonalpha)) return false; |
| 431 | return true; |
| 432 | } |
| 433 | |
| 434 | |
| 435 | // Limits the number of tasks running to a fixed limit placing the rest in a pending queue. |
| 436 | // This is useful to limit the number of agents upgrading at the same time, to not swamp |
| 437 | // the network with traffic. |
| 438 | module.exports.createTaskLimiterQueue = function (maxTasks, maxTaskTime, cleaningInterval) { |
| 439 | var obj = { maxTasks: maxTasks, maxTaskTime: (maxTaskTime * 1000), nextTaskId: 0, currentCount: 0, current: {}, pending: [[], [], []], timer: null }; |
| 440 | |
| 441 | // Add a task to the super queue |
| 442 | // Priority: 0 = High, 1 = Medium, 2 = Low |
| 443 | obj.launch = function (func, arg, pri) { |
| 444 | if (typeof pri != 'number') { pri = 2; } |
| 445 | if (obj.currentCount < obj.maxTasks) { |
| 446 | // Run this task now |
| 447 | const id = obj.nextTaskId++; |
| 448 | obj.current[id] = Date.now() + obj.maxTaskTime; |
| 449 | obj.currentCount++; |
| 450 | //console.log('ImmidiateLaunch ' + id); |
| 451 | func(arg, id, obj); // Start the task |
| 452 | if (obj.timer == null) { obj.timer = setInterval(obj.clean, cleaningInterval * 1000); } |
| 453 | } else { |
| 454 | // Hold this task |
| 455 | //console.log('Holding'); |
| 456 | obj.pending[pri].push({ func: func, arg: arg }); |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | // Called when a task is completed |
| 461 | obj.completed = function (taskid) { |
| 462 | //console.log('Completed ' + taskid); |
| 463 | if (obj.current[taskid]) { delete obj.current[taskid]; obj.currentCount--; } else { return; } |
| 464 | while ((obj.currentCount < obj.maxTasks) && ((obj.pending[0].length > 0) || (obj.pending[1].length > 0) || (obj.pending[2].length > 0))) { |
| 465 | // Run this task now |
| 466 | var t = null; |
| 467 | if (obj.pending[0].length > 0) { t = obj.pending[0].shift(); } |
| 468 | else if (obj.pending[1].length > 0) { t = obj.pending[1].shift(); } |
| 469 | else if (obj.pending[2].length > 0) { t = obj.pending[2].shift(); } |
| 470 | const id = obj.nextTaskId++; |
| 471 | obj.current[id] = Date.now() + obj.maxTaskTime; |
| 472 | obj.currentCount++; |
| 473 | //console.log('PendingLaunch ' + id); |
| 474 | t.func(t.arg, id, obj); // Start the task |
| 475 | } |
| 476 | if ((obj.currentCount == 0) && (obj.pending[0].length == 0) && (obj.pending[1].length == 0) && (obj.pending[2].length == 0) && (obj.timer != null)) { |
| 477 | // All done, clear the timer |
| 478 | clearInterval(obj.timer); obj.timer = null; |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | // Look for long standing tasks and clean them up |
| 483 | obj.clean = function () { |
| 484 | const t = Date.now(); |
| 485 | for (var i in obj.current) { if (obj.current[i] < t) { obj.completed(parseInt(i)); } } |
| 486 | } |
| 487 | |
| 488 | return obj; |
| 489 | } |
| 490 | |
| 491 | // Convert string translations to a standardized JSON we can use in GitHub |
| 492 | // Strings are sorder by english source and object keys are sorted |
| 493 | module.exports.translationsToJson = function(t) { |
| 494 | var arr2 = [], arr = t.strings; |
| 495 | for (var i in arr) { |
| 496 | var names = [], el = arr[i], el2 = {}; |
| 497 | for (var j in el) { names.push(j); } |
| 498 | names.sort(function (a, b) { if (a == b) { return 0; } if (a == 'xloc') { return 1; } if (b == 'xloc') { return -1; } return a - b }); |
| 499 | for (var j in names) { el2[names[j]] = el[names[j]]; } |
| 500 | if (el2.xloc != null) { el2.xloc.sort(); } |
| 501 | arr2.push(el2); |
| 502 | } |
| 503 | arr2.sort(function (a, b) { if (a.en > b.en) return 1; if (a.en < b.en) return -1; return 0; }); |
| 504 | return JSON.stringify({ strings: arr2 }, null, ' '); |
| 505 | } |
| 506 | |
| 507 | module.exports.copyFile = function(source, target, cb) { |
| 508 | var cbCalled = false, rd = fs.createReadStream(source); |
| 509 | rd.on('error', function (err) { done(err); }); |
| 510 | var wr = fs.createWriteStream(target); |
| 511 | wr.on('error', function (err) { done(err); }); |
| 512 | wr.on('close', function (ex) { done(); }); |
| 513 | rd.pipe(wr); |
| 514 | function done(err) { if (!cbCalled) { cb(err); cbCalled = true; } } |
| 515 | } |
| 516 | |
| 517 | module.exports.meshServerRightsArrayToNumber = function (val) { |
| 518 | if (val == null) return null; |
| 519 | if (typeof val == 'number') return val; |
| 520 | if (Array.isArray(val)) { |
| 521 | var newAccRights = 0; |
| 522 | for (var j in val) { |
| 523 | var r = val[j].toLowerCase(); |
| 524 | if (r == 'fulladmin') { newAccRights = 4294967295; } // 0xFFFFFFFF |
| 525 | if (r == 'serverbackup') { newAccRights |= 1; } |
| 526 | if (r == 'manageusers') { newAccRights |= 2; } |
| 527 | if (r == 'serverrestore') { newAccRights |= 4; } |
| 528 | if (r == 'fileaccess') { newAccRights |= 8; } |
| 529 | if (r == 'serverupdate') { newAccRights |= 16; } |
| 530 | if (r == 'locked') { newAccRights |= 32; } |
| 531 | if (r == 'nonewgroups') { newAccRights |= 64; } |
| 532 | if (r == 'notools') { newAccRights |= 128; } |
| 533 | if (r == 'usergroups') { newAccRights |= 256; } |
| 534 | if (r == 'recordings') { newAccRights |= 512; } |
| 535 | if (r == 'locksettings') { newAccRights |= 1024; } |
| 536 | if (r == 'allevents') { newAccRights |= 2048; } |
| 537 | if (r == 'nonewdevices') { newAccRights |= 4096; } |
| 538 | } |
| 539 | return newAccRights; |
| 540 | } |
| 541 | return null; |
| 542 | } |
| 543 | |
| 544 | // Sort an object by key |
| 545 | module.exports.sortObj = function (obj) { return Object.keys(obj).sort().reduce(function (result, key) { result[key] = obj[key]; return result; }, {}); } |
| 546 | |
| 547 | // Validate an object to make sure it can be stored in MongoDB |
| 548 | module.exports.validateObjectForMongo = function (obj, maxStrLen) { |
| 549 | return validateObjectForMongoRec(obj, maxStrLen); |
| 550 | } |
| 551 | |
| 552 | function validateObjectForMongoRec(obj, maxStrLen) { |
| 553 | if (typeof obj != 'object') return false; |
| 554 | for (var i in obj) { |
| 555 | // Check the key name is not too long |
| 556 | if (i.length > 100) return false; |
| 557 | // Check if all chars are alpha-numeric or underscore. |
| 558 | for (var j in i) { const c = i.charCodeAt(j); if ((c < 48) || ((c > 57) && (c < 65)) || ((c > 90) && (c < 97) && (c != 95)) || (c > 122)) return false; } |
| 559 | // If the value is a string, check it's not too long |
| 560 | if ((typeof obj[i] == 'string') && (obj[i].length > maxStrLen)) return false; |
| 561 | // If the value is an object, check it. |
| 562 | if ((typeof obj[i] == 'object') && (Array.isArray(obj[i]) == false) && (validateObjectForMongoRec(obj[i], maxStrLen) == false)) return false; |
| 563 | } |
| 564 | return true; |
| 565 | } |
| 566 | |
| 567 | // Parse a version string of the type n.n.n.n |
| 568 | module.exports.parseVersion = function (verstr) { |
| 569 | if (typeof verstr != 'string') return null; |
| 570 | const r = [], verstrsplit = verstr.split('.'); |
| 571 | if (verstrsplit.length != 4) return null; |
| 572 | for (var i in verstrsplit) { |
| 573 | var n = parseInt(verstrsplit[i]); |
| 574 | if (isNaN(n) || (n < 0) || (n > 65535)) return null; |
| 575 | r.push(n); |
| 576 | } |
| 577 | return r; |
| 578 | } |
| 579 | |
| 580 | // Move old files. If we are about to overwrite a file, we can move if first just in case the change needs to be reverted |
| 581 | module.exports.moveOldFiles = function (filelist) { |
| 582 | // Fine an old extension that works for all files in the file list |
| 583 | var oldFileExt, oldFileExtCount = 0, extOk; |
| 584 | do { |
| 585 | extOk = true; |
| 586 | if (++oldFileExtCount == 1) { oldFileExt = '-old'; } else { oldFileExt = '-old' + oldFileExtCount; } |
| 587 | for (var i in filelist) { if (fs.existsSync(filelist[i] + oldFileExt) == true) { extOk = false; } } |
| 588 | } while (extOk == false); |
| 589 | for (var i in filelist) { try { fs.renameSync(filelist[i], filelist[i] + oldFileExt); } catch (ex) { } } |
| 590 | } |
| 591 | |
| 592 | // Convert strArray to Array, returns array if strArray or null if any other type |
| 593 | module.exports.convertStrArray = function (object, split) { |
| 594 | if (split && typeof object === 'string') { |
| 595 | return object.split(split) |
| 596 | } else if (typeof object === 'string') { |
| 597 | return Array(object); |
| 598 | } else if (Array.isArray(object)) { |
| 599 | return object |
| 600 | } else { |
| 601 | return [] |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | module.exports.uniqueArray = function (a) { |
| 606 | var seen = {}; |
| 607 | var out = []; |
| 608 | var len = a.length; |
| 609 | var j = 0; |
| 610 | for(var i = 0; i < len; i++) { |
| 611 | var item = a[i]; |
| 612 | if(seen[item] !== 1) { |
| 613 | seen[item] = 1; |
| 614 | out[j++] = item; |
| 615 | } |
| 616 | } |
| 617 | return out; |
| 618 | } |
| 619 | |
| 620 | // Replace placeholders in a string with values from an object or a function |
| 621 | module.exports.replacePlaceholders = function (template, values) { |
| 622 | return template.replace(/\{(\w+)\}/g, (match, key) => { |
| 623 | if (typeof values === 'function') { |
| 624 | return values(key); |
| 625 | } |
| 626 | else if (values && typeof values === 'object') { |
| 627 | return values[key] !== undefined ? values[key] : match; |
| 628 | } |
| 629 | else { |
| 630 | return values !== undefined ? values : match; |
| 631 | } |
| 632 | }); |
| 633 | } |