/**
* Shared functions across all classes and apps
*/
const CryptoJS = require('crypto-js');
const WTVShenanigans = require('./WTVShenanigans.js');
class WTVShared {
path = require('path');
fs = require('fs');
readline = require('readline');
v8 = require('v8');
zlib = require('zlib');
html_entities = require('html-entities'); // used externally by service scripts
sanitizeHtml = require('sanitize-html');
iconv = require('iconv-lite');
parentDirectory = process.cwd()
extend = require('util')._extend;
debug = require('debug')('WTVShared')
process = require('process');
shenanigans = null;
appdir = this.path.resolve(__dirname + this.path.sep + ".." + this.path.sep + "..");
minisrv_config = [];
constructor(minisrv_config, quiet = false) {
if (minisrv_config == null) this.minisrv_config = this.readMiniSrvConfig(true, !quiet);
else this.minisrv_config = minisrv_config;
this.shenanigans = new WTVShenanigans(this.minisrv_config);
if (!String.prototype.reverse) {
String.prototype.reverse = function () {
var splitString = this.split("");
var reverseArray = splitString.reverse();
var joinArray = reverseArray.join("");
return joinArray;
}
}
if (!String.prototype.hexEncode) {
String.prototype.hexEncode = function () {
var result = '';
for (var i = 0; i < this.length; i++) {
result += this.charCodeAt(i).toString(16);
}
return result;
}
}
}
/**
* Calculates the CRC of an SSID, WNI Style
* @param {string} ssid
* @returns {string} CRC8 result as hex string
*/
getSSIDCRC(ssid) {
let crc = 0;
for (let i = 0; i < 14; i += 2) {
let inbyte = parseInt(ssid.substring(i, i + 2), 16);
if (isNaN(inbyte)) return '00';
for (let ii = 0; ii < 8; ii++) {
let mix = (crc ^ inbyte) & 1;
crc >>= 1;
if (mix) crc ^= 0x8C;
inbyte >>= 1;
}
}
return crc.toString(16).padStart(2, '0');
}
// check if the SSID has a valid checksum
checkSSID(ssid) {
if (ssid.slice(-2) == this.getSSIDCRC(ssid))
return true;
return false;
}
parseConfigVars(s) {
if (s.indexOf("%ServiceDeps%") >= 0) {
return this.getServiceDep(s.replace("%ServiceDeps%", ""), true);
} else {
return s;
}
}
/**
* CryptoJS implmentation of Base64 Encoder
* @param {string} b String to encode
* @returns {string} Base64 encoded string
*/
atob(a) {
const CryptoJS = require('crypto-js');
const enc = CryptoJS.enc.Utf8.parse(a); // encodedWord Array object
return CryptoJS.enc.Base64.stringify(enc);
}
/**
* CryptoJS implmentation of Base64 Decoder
* @param {string} a Base64 String
* @return {string} Decoded string
*/
btoa(b) {
const CryptoJS = require('crypto-js');
const enc = CryptoJS.enc.Base64.parse(b);
return CryptoJS.enc.Utf8.stringify(enc)
}
/**
* Clone an object without any reference to the original (why is this so hard in JS)
* @param {object} src Soruce object
* @returns {object} Clone object that can be updated without modifing the original
*/
cloneObj(src) {
if (src instanceof RegExp) {
return new RegExp(src);
} else if (src instanceof Date) {
return new Date(src.getTime());
} else if (Array.isArray(src)) {
return src.map(item => this.cloneObj(item));
} else if (typeof src === 'object' && src !== null) {
const clone = {};
Object.keys(src).forEach(k => {
clone[k] = this.cloneObj(src[k]);
});
return clone;
}
return src;
}
/**
* Checks if the user has been whitelisted for wtv-admin
* @param {object} wtvclient the clientSessionData object for the user
* @param {string} service_name (optional) Service to check
*/
isAdmin(wtvclient, service_name = "wtv-admin") {
var WTVAdmin = require("./WTVAdmin.js");
var wtva = new WTVAdmin(this.minisrv_config, wtvclient, service_name);
var result = wtva.isAuthorized(true);
wtva, WTVAdmin = null;
return result;
}
parseJSON(json) {
if (typeof json !== 'string') json = json ? json.toString() : '';
let result = '';
let i = 0;
let isString = false;
let isEscape = false;
let isBlockComment = false;
let isLineComment = false;
while (i < json.length) {
const char = json[i];
const nextChar = json[i + 1];
if (!isString && !isEscape && char === '/' && nextChar === '*') {
isBlockComment = true;
i++;
} else if (isBlockComment && char === '*' && nextChar === '/') {
isBlockComment = false;
i++;
} else if (!isString && !isEscape && char === '/' && nextChar === '/') {
isLineComment = true;
i++;
} else if (isLineComment && (char === '\n' || char === '\r')) {
isLineComment = false;
} else if (!isBlockComment && !isLineComment) {
if (char === '"' && !isEscape) {
isString = !isString;
}
isEscape = char === '\\' && !isEscape;
result += char;
}
i++;
}
return JSON.parse(result);
}
/**
* Attempts to convert val into a boolean
* @param {string,int,boolean} val
* @returns {boolean}
*/
parseBool(val) {
return !!(val && /^(true|1|on|yes)$/i.test(val.toString().trim()));
}
getQueryString(query) {
// for easy retrofitting old code to work with the webtvism of allowing multiple of the same query name
// pass it the query, and it will return a string regardless. if its a string it just sends it back
// if its an array we just pull the last object
if (typeof (query) === "object")
return query[(Object.keys(query).length - 1)];
else
return query
}
/**
* Encodes a string, replacing < and > with < and >
* @param {string} string The string to entitize
* @param {boolean} process_newline If true, replaces ASCII newline with
* @returns {string} Entitized string
*/
htmlEntitize(string, process_newline = false) {
// Assuming checkShenanigan returns a boolean
if (this.shenanigans && this.shenanigans.checkShenanigan(this.shenanigans.shenanigans.DISABLE_HTML_ENTITIZER)) {
return string;
}
// Directly replace &, <, >, ", and ' characters
let entitized = string.replace(/[&<>"]/g, function (match) {
switch (match) {
case '&': return '&';
case '<': return '<';
case '>': return '>';
case '"': return '"';
case "'": return ''';
// ' is not needed to be replaced since it is valid in HTML5 and XHTML
default: return match;
}
});
if (process_newline) {
entitized = entitized.replace(/\n/g, "
").replace(/\r/g, "");
}
return entitized;
}
/**
* Attempts to sanitize HTML code to remove possible exploits when embedded in a WebTV Service
* @param {string} string The string to sanitize
* @returns {string} Sanitized string
*/
sanitizeSignature(string) {
var allowedSchemes = ['http', 'https', 'ftp', 'mailto'];
var self = this;
// allow links to services flagged as "wideopen"
Object.keys(this.minisrv_config.services).forEach((k) => {
var flag = parseInt(this.minisrv_config.services[k].flags, 16);
if (flag === 4 || flag === 7) {
if (!allowedSchemes.includes(k)) allowedSchemes.push(k);
}
});
self.debug("sanitizeSignature", "allowed protocols:", allowedSchemes);
if (this.shenanigans.checkShenanigan(this.shenanigans.shenanigans.DISABLE_HTML_SANITIZER)) {
// shenanigans level matches, don't filter
return string;
}
const clean = this.sanitizeHtml(string, {
allowedTags: ['a', 'audioscope', 'b', 'bgsound', 'big', 'blackface', 'blockquote', 'bq', 'br', 'caption', 'center', 'cite', 'c', 'dd', 'dfn', 'div', 'dl', 'dt', 'em', 'fn', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'html', 'i', 'img', 'label', 'li', 'listing', 'marquee', 'nobr', 'ol', 'p', 'plaintext', 'pre', 's', 'samp', 'small', 'shadow', 'span', 'strike', 'strong', 'sub', 'sup', 'tbody', 'table', 'td', 'th', 'tr', 'tt', 'u', 'ul'],
disallowedTagsMode: 'discard',
allowedAttributes: {
a: ['href', 'name', 'target'],
audioscope: ['align', 'bgcolor', 'border', 'gain', 'height', 'leftcolor', 'leftoffset', 'maxlevel', 'rightcolor', 'rightoffset', 'width' ],
bgsound: ['src', 'loop'],
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
font: ['size', 'name', 'color'],
marquee: ['aign', 'behavior', 'direction', 'height', 'hspace', 'loop', 'scrollamount', 'scrolldelay', 'transparency', 'vspace', 'width'],
},
allowedSchemes: allowedSchemes,
allowedSchemesByTag: {},
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
exclusiveFilter: function (frame) {
var allowed = true;
Object.keys(frame.attribs).forEach((k) => {
if (k == "href" || k == "background" || k == "src") {
allowed = false;
var value = frame.attribs[k];
if (frame.tag !== "a") {
// check everything except normal links
if (value.startsWith("wtvchat") || value.startsWith("irc")) {
// don't allow irc embeds
return false;
}
}
Object.keys(allowedSchemes).forEach((j) => {
if (value.startsWith(allowedSchemes[j])) {
allowed = true;
return false;
}
})
}
});
self.debug("sanitizeSignature", "filter result:", frame, "allowed:", allowed);
return !allowed;
},
allowVulnerableTags: false,
allowProtocolRelative: false
}, true)
// todo: add missing user open tags (eg if user did not close it) (might be done by sanitize-html?)
return clean;
}
/**
* Attempts to determine if the string is ASCII
* @param {string} str
* @returns {boolean} true if ASCII only, otherwise false
*/
isASCII(str) {
return typeof str === 'string' && /^[\x00-\x7F]*$/.test(str);
}
/**
* Attempts to determine if the string contains HTML
* @param {string} str
* @returns {boolean} true if HTML detected, otherwise false
*/
isHTML(str) {
const pattern = /<([A-Za-z][A-Za-z0-9]*)\b[^>]*>(.*?)<\/\1>/;
return typeof str === 'string' && pattern.test(str);
}
/**
* Attempts to determine if the string is Base64 or not
* @param {string} str String to check
* @param {object} opts
* @return {boolean} true if Base64, otherwise false
*/
isBase64(str, opts = {}) {
if (typeof str !== 'string' || (opts.allowEmpty === false && str === '')) {
return false;
}
// Create a regex string based on the provided options
const regexBase = '[A-Za-z0-9+\\/]';
const regexPadding = opts.paddingRequired === false ? '=?' : '=';
const regexMime = opts.mimeRequired ? 'data:\\w+\\/[-+.\\w]+;base64,' : '';
// Construct the final regex with the appropriate groups
const regex = `^${regexMime}(?:${regexBase}{4})*${opts.allowMime ? '?' : ''}(?:${regexBase}{2}${regexPadding}{2}|${regexBase}{3}${regexPadding})?$`;
return new RegExp(regex, 'gi').test(str);
}
utf8Decode(utf8String) {
if (typeof utf8String !== 'string') {
throw new TypeError('parameter �utf8String� is not a string');
}
const textDecoder = new TextDecoder('utf-8');
const bytes = new Uint8Array(utf8String.split('').map(c => c.charCodeAt(0)));
return textDecoder.decode(bytes);
}
decodeBufferText(buf) {
var out = "";
out = this.utf8Decode(this.iconv.decode(Buffer.from(buf),'ISO-8859-1'));
return out;
}
/**
* Attempts to convert a relative path into an absolute path
* @param {string} check_path The path to convert
* @return {string} The absolute path
*/
returnAbsolutePath(check_path) {
// Assuming this.path.sep is a slash (/ or \) and this.parentDirectory is set correctly
if (!/^(?:[a-zA-Z]:)?[\\/]/.test(check_path)) {
// It's a relative path
check_path = this.path.resolve(this.parentDirectory + this.path.sep + check_path);
}
// Use the fixPathSlashes method to normalize the slashes
return this.fixPathSlashes(check_path);
}
/**
* Detects if the client is in MiniBrowser mode
* @param {object} ssid_session
* @returns {boolean} true if yes, false it no
*/
isMiniBrowser(ssid_session) {
return (ssid_session.get("wtv-need-upgrade") || ssid_session.get("wtv-used-8675309")) ? true : false;
}
/**
* Checks if the build is older than the supplied value
* @param {object} ssid_session
* @param {int} minBuild Minimum build number to check againse
* @returns {boolean} true if the client build is less than minBuild, otherwise false
*/
isOldBuild(ssid_session, minBuild = 3500) {
return (this.isMiniBrowser(ssid_session) || parseInt(ssid_session.get("wtv-system-version")) < minBuild) ? true : false;
}
getUserConfig() {
try {
var user_config_filename = this.getAbsolutePath("user_config.json", this.appdir);
if (this.fs.lstatSync(user_config_filename)) {
try {
var minisrv_user_config = this.parseJSON(this.fs.readFileSync(user_config_filename));
} catch (f) {
console.error("ERROR: Could not read user_config.json", "\n\nReason:\n\n", f);
this.process.exit(1);
}
} else {
var minisrv_user_config = {}
}
return minisrv_user_config;
} catch (e) {
if (!this.fs.existsSync(user_config_filename)) {
console.error(" * Notice: Could not find user configuration (user_config.json). Using default configuration.");
} else {
console.error("ERROR: Could not read user_config.json", e);
this.process.exit(1);
}
}
}
/**
* Parses an SSID and attempts to decode its bits
* @param {string} ssid
* @returns {object} ssid info object
*/
parseSSID(ssid) {
const boxTypeMapping = {
"01": "Internal",
"71": "MAME",
"81": "Retail",
"91": "Viewer"
};
const regionMapping = {
"B002": "US/Canada",
"B102": "Japan"
};
const manufacturerMapping = {
"00": "Sony", // Default to Sony if the region is not Japan
"10": "Philips",
"50": "Philips",
"40": "Mitsubishi",
"70": "Samsung",
"80": "EchoStar",
"90": "RCA",
"AE": "zefie & MattMan69"
};
const ssid_obj = {
boxType: boxTypeMapping[ssid.substring(0, 2)],
unique_id: ssid.substring(2, 8),
region: regionMapping[ssid.substring(10, 14).toUpperCase()],
manufacturer: manufacturerMapping[ssid.substring(8, 10).toUpperCase()],
crc: ssid.substring(14)
};
// Special case for manufacturer based on region
if (ssid_obj.region === "Japan" && ssid.substring(8, 10).toUpperCase() === "00") {
ssid_obj.manufacturer = "Panasonic";
}
return ssid_obj;
}
/**
* Alias for parseSSID, but just the manufacture info
* @param {string} ssid
* @param {boolean} bit If true, just return the manufacture portion of the SSID
* @return {string}
*/
getManufacturer(ssid, bit = false) {
if (bit) return ssid.substring(8, 10).toUpperCase();
else return this.parseSSID(ssid).manufacturer || null;
}
readMiniSrvConfig(user_config = true, notices = true, reload_notice = false) {
const log = (msg) => {
if (notices || reload_notice) console.log(msg);
};
const logError = (msg, e) => {
console.error(msg, e);
if (this.minisrv_config.config) {
if (this.minisrv_config.config.debug_flags && this.minisrv_config.config.debug_flags.debug) {
console.error(" * Notice: Using default configuration.");
}
}
};
log(" *** Reading global configuration...");
try {
var minisrv_config = this.parseJSON(this.fs.readFileSync(this.getAbsolutePath(".." + this.path.sep + "config.json", __dirname)));
} catch (e) {
throw new Error("ERROR: Could not read config.json", e);
}
if (user_config) {
log(" *** Reading user configuration...");
try {
let minisrv_user_config = this.getUserConfig();
minisrv_config = this.integrateConfig(minisrv_config, minisrv_user_config);
} catch (e) {
logError("ERROR: Could not integrate user_config.json", e);
this.process.exit(1);
}
}
// Set debug flags based on verbosity
const debugFlags = {
debug: false,
quiet: true, // will squash debug even if its true
show_headers: false
};
if (minisrv_config.config.verbosity >= 0 && minisrv_config.config.verbosity <= 3) {
debugFlags.quiet = minisrv_config.config.verbosity < 2;
debugFlags.show_headers = minisrv_config.config.verbosity % 2 === 1;
debugFlags.debug = minisrv_config.config.verbosity === 2 || minisrv_config.config.verbosity === 3;
log(` * Console Verbosity level ${minisrv_config.config.verbosity}`);
} else {
Object.assign(debugFlags, { debug: true, quiet: false, show_headers: true });
log(" * Console Verbosity level 4 (debug verbosity)");
}
minisrv_config.config.debug_flags = debugFlags;
log(" *** Configuration successfully read.");
this.minisrv_config = minisrv_config;
return this.minisrv_config;
}
integrateConfig(main, user) {
for (const key in user) {
if (typeof user[key] === 'object' && user[key] !== null && !Array.isArray(user[key])) {
main[key] = this.integrateConfig(main[key] || {}, user[key]);
} else {
main[key] = user[key];
}
}
return main;
}
writeToUserConfig(config) {
if (config) {
try {
var minisrv_user_config = this.getUserConfig();
// write back
try {
var new_user_config = {};
Object.assign(new_user_config, minisrv_user_config, config);
if (this.minisrv_config.config.debug_flags.debug) console.log(" * Writing new user configuration...");
this.fs.writeFileSync(this.getAbsolutePath("user_config.json", this.appdir), JSON.stringify(new_user_config, null, "\t"));
return true;
}
catch (e) {
if (this.minisrv_config.config.debug_flags) {
if (this.minisrv_config.config.debug_flags.debug) console.error(" * WARNING: Could not update user config. Data may have been lost.", e);
}
}
} catch (e) {
if (this.minisrv_config.config.debug_flags) {
if (this.minisrv_config.config.debug_flags.debug) console.error(" * Notice: Could not find user configuration (user_config.json). Using default configuration.");
}
}
}
return false;
}
/**
* Generates a random string
* @param {int} len desired generated string length
* @param {string} extra_chars String of extra characters outside A-Z a-z 0-9 to include
* @returns {string} Random string
*/
generateString(len, extra_chars = '') {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + extra_chars;
const charactersLength = characters.length;
let result = '';
let randomByte;
let value;
// If in Node.js environment, use crypto for better performance and randomness
if (typeof require === 'function' && typeof process === 'object') {
const { randomBytes } = require('crypto');
for (let i = 0; i < len; i++) {
randomByte = randomBytes(1);
result += characters.charAt(randomByte[0] % charactersLength);
}
} else {
// Cache the Math functions outside of the loop
const randomFunc = Math.random;
const floorFunc = Math.floor;
for (let i = 0; i < len; i++) {
value = floorFunc(randomFunc() * charactersLength);
result += characters.charAt(value);
}
}
return result;
}
/**
* Any alias of generateString with optional special characters enabled as well
* @param {string} len desired generated string length
* @param {any} simple If false, generates a password with special chars
* @returns {string} Random string
*/
generatePassword(len, simple = false) {
return this.generateString(len, (simple) ? null : '!@#$%&()[]-_+=?.');
}
/**
* Returns the configuration object
* @returns {object} minisrv config
*/
getMiniSrvConfig() {
return this.minisrv_config;
}
lineWrap(string, len = 72, join = "\n") {
if (string.length <= len) return string;
// Create the regex outside of the loop to avoid recompilation
const wordWrapRegex = new RegExp(`(.{1,${len}})(\\s|$)|(.{${len}})`, 'g');
const matches = [];
let match;
while ((match = wordWrapRegex.exec(string)) !== null) {
// Add the matched group that is not undefined
matches.push(match[1] || match[3]);
}
return matches.join(join).trim();
}
/**
* Returns the Last-Modified date in Unix Timestamp format
* @param {string} file Path to a file
*/
getFileLastModified(file) {
var stats = this.fs.lstatSync(file);
if (stats) return new Date(stats.mtimeMs);
return false;
}
/**
* Returns the Last-Modified date in a RFC7231 compliant UTC Date String
* @param {string} file Path to a file
*/
getFileLastModifiedUTCString(file) {
return this.getFileLastModified(file).toUTCString();
}
/**
* Returns a RFC7231 compliant UTC Date String from the current time
* @param {Number} offset Offset from current time (+/-)
* @returns {string} A RFC7231 compliant UTC Date String from the current time
*/
getUTCTime(offset = 0) {
return new Date((new Date).getTime() + offset).toUTCString();
}
/**
* Converts a binary buffer to a urlencoded string
* @param {ArrayBuffer} buf byte data array
*/
urlEncodeBytes(buf) {
if (!(buf instanceof Uint8Array)) {
buf = new Uint8Array(buf);
}
const urlSafeChars = /[A-Za-z0-9_.~-]/;
let encoded = '';
let hex;
for (let i = 0; i < buf.length; i++) {
const byte = buf[i];
// Check if the byte maps to a URL-safe character
if (urlSafeChars.test(String.fromCharCode(byte))) {
encoded += String.fromCharCode(byte);
} else {
// Convert byte to a two-character hexadecimal code
hex = byte.toString(16);
encoded += `%${hex.length === 1 ? '0' + hex : hex}`;
}
}
return encoded.toUpperCase();
}
/**
* Decodes a urlencoded string into a binary buffer
* @param {string} encoded urlencoded string
*/
urlDecodeBytes(encoded) {
// Calculate the length of the decoded buffer
let bufferLength = encoded.length;
for (let i = 0; i < encoded.length; i++) {
if (encoded[i] === '%') {
bufferLength -= 2; // Each encoded sequence is three characters but represents one byte
i += 2; // Skip the next two characters
}
}
// Allocate a buffer of the correct size
let decoded = Buffer.alloc(bufferLength);
let bufferIndex = 0;
for (let i = 0; i < encoded.length; i++) {
if (encoded[i] === '%') {
decoded[bufferIndex++] = parseInt(encoded.substr(i + 1, 2), 16);
i += 2; // Skip the next two characters
} else {
decoded[bufferIndex++] = encoded.charCodeAt(i);
}
}
return decoded;
}
censorSSID(ssid) {
if (ssid) {
if (ssid.slice(0, 8) === "MSTVSIMU") {
return ssid.slice(0, 10) + ('*').repeat(10) + ssid.slice(20);
} else if (ssid.slice(0, 5) === "1SEGA") {
return ssid.slice(0, 6) + ('*').repeat(6) + ssid.slice(12);
}
return ssid.slice(0, 4) + ('*').repeat(9) + ssid.slice(-2);
} else {
return "????????????????";
}
}
/**
* Returns a censored SSID
* @param {string|Array} obj SSID String or Headers Object
*/
filterSSID(obj) {
var new_obj = false;
if (this.minisrv_config && this.minisrv_config.config.hide_ssid_in_logs) {
if (typeof obj === "string") {
return this.censorSSID(obj);
} else if (typeof obj === "object" && obj !== null) {
if ("wtv-client-serial-number" in obj) {
new_obj = this.cloneObj(obj)
new_obj["wtv-client-serial-number"] = this.censorSSID(new_obj["wtv-client-serial-number"]);
}
return (new_obj != false) ? new_obj : obj;
}
}
return obj;
}
filterRequestLog(obj) {
if (this.minisrv_config.config.filter_passwords_in_logs && obj.query) {
const passwordRegex = /(^pass$|passw(or)?d)/i;
let newobj = this.cloneObj(obj); // Clone the object once at the beginning
if (newobj.query) {
Object.keys(newobj.query).forEach((k) => {
if (passwordRegex.test(k)) {
newobj.query[k] = '*'.repeat(newobj.query[k].length);
}
});
}
delete newobj.raw_headers;
return newobj;
}
return obj;
}
decodePostData(obj) {
if (obj.post_data) {
const filterPasswords = this.minisrv_config.config.filter_passwords_in_logs === true;
try {
// Assuming CryptoJS.enc.Utf8 exists and has a stringify method
let post_text = CryptoJS.enc.Utf8.stringify(obj.post_data);
let params = new URLSearchParams(post_text);
if (filterPasswords) {
for (let [key, value] of params) {
const lowerKey = key.toLowerCase();
if (/passw(or)?d|^pass$/.test(lowerKey)) {
params.set(key, '*'.repeat(value.length));
}
}
}
// Assuming hexEncode method exists on the string prototype
obj.post_data = filterPasswords ? params.toString().hexEncode() : params.toString();
} catch (e) {
// Fallback in case of an error
if (!this.minisrv_config.config.debug_flags.quiet) {
console.error(' *** error decoding post data', e);
}
// Assuming CryptoJS.enc.Hex exists and has a stringify method
obj.post_data = CryptoJS.enc.Hex.stringify(obj.post_data);
}
}
return obj;
}
// DON'T USE THIS
// Saved for reference until I come up with a better way
// If used, this will exceed the stack limit over time
unloadModule(moduleName) {
// Search for the module in the require cache
let resolvedPath = require.resolve(moduleName);
// Remove the module from the cache
if (require.cache[resolvedPath]) {
delete require.cache[resolvedPath];
}
}
/**
* Returns an absolute path
* @param {string} path
* @param {string} directory Root directory
*/
getAbsolutePath(path = '', directory = '.') {
if (directory[0] == "/") {
return this.path.resolve(directory + this.path.sep + path);
}
try {
// start with our absolute path (of app.js)
const appdir = this.path.resolve(__dirname + this.path.sep + '..' + this.path.sep + '..')
if (path == '' && directory == '.') {
return appdir;
}
// If the directory is a valid directory, prepend it to the path
directory = this.path.resolve(appdir + this.path.sep + directory);
if (!path) {
return directory;
}
if (directory && !path.startsWith(directory)) {
if (!directory.endsWith(this.path.sep)) {
directory += this.path.sep;
}
path = directory + path;
}
} catch (e) {
// If there's an error accessing the directory, log it or handle as needed
console.error('Error resolving directory:', e);
}
// The path.resolve method will take care of normalizing slashes
return this.path.resolve(path);
}
/**
* Returns a percentage
* @param {number} partialValue
* @param {number} totalValue
* @returns {number} percentage
*/
getPercentage = function (partialValue, totalValue) {
return Math.floor((100 * partialValue) / totalValue);
}
/**
* If the file ends with .gz, remove it
* @param {string} path
* @return {string} path without gz, or unmodified path if it isnt a gz
*/
getFilePath(path) {
var path_split = path.split(this.path.sep);
path_split.pop();
return path_split.join(this.path.sep);
}
/**
* Gets the file extension from a path
* @param {string} path
* @returns {String} File Extension (without dot)
*/
getFileExt(path) {
return path.reverse().split(".")[0].reverse();
}
getLineFromFile(filename, lineNo, callback) {
let lineCount = 0;
const lineReader = this.readline.createInterface({
input: this.fs.createReadStream(filename, {
flags: 'r',
encoding: 'utf-8'
}),
crlfDelay: Infinity
});
lineReader.on('line', (line) => {
if (lineCount === lineNo) {
lineReader.close();
callback(null, line);
}
lineCount++;
});
lineReader.on('close', () => {
if (lineCount < lineNo) {
callback(new Error('File end reached without finding line'), null);
}
});
lineReader.on('error', (err) => {
callback(err, null);
});
}
/**
* Checks if service is enabled or disabled in the config
* @param {string} service Service Name
* @returns {boolean} true if enabled, false if disabled
*/
isConfiguredService(service) {
if (this.minisrv_config.services[service]) {
if (!this.minisrv_config.services[service].disabled) return true;
}
return false;
}
/**
* Create a wtv-service string for WebTV Headers
* @param {string} service A single service name, or the string "all"
* @param {object} overrides An object of overrides, used to exclude certain services when service is "all"
* @returns {string} wtv-service string formatted for WebTV Headers
*/
getServiceString(service, overrides = {}) {
// used externally by service scripts
if (service === "all") {
var self = this;
var out = "";
Object.keys(this.minisrv_config.services).sort().forEach(function (k) {
if (!self.isConfiguredService(k)) return true;
if (self.minisrv_config.services[k].pc_services) return true;
if (overrides.exceptions) {
Object.keys(overrides.exceptions).forEach(function (j) {
if (k != overrides.exceptions[j]) out += self.minisrv_config.services[k].toString(overrides) + "\n";
});
} else {
out += self.minisrv_config.services[k].toString(overrides) + "\n";
}
});
return out;
} else {
if (!this.minisrv_config.services[service]) {
throw ("SERVICE ERROR: Attempted to provision unconfigured service: " + service)
} else {
return this.minisrv_config.services[service].toString(overrides);
}
}
}
/**
* Creates an error message and sends it to the client
* @param {number} code HTTP Error Code
* @param {string} data Optinal Custom Error Message
* @param {string} details Optional extra error information
* @param {boolean} pc_mode If true, sends response formatted for PCs instead of WebTV
* @param {boolean} wtv_reset if true, tells the WebTV box to reset the service list and reconnect
*/
doErrorPage(code, data = null, details = null, pc_mode = false, wtv_reset = false) {
const minisrv_config = this.minisrv_config;
const errorMessage = minisrv_config.config.errorMessages[code] || "";
const message = data || errorMessage.replace(/\$\{(\w+)\}/g, (match, p1) => minisrv_config.config[p1] || '');
if (details && [400, 500].includes(code)) {
data += "
Details:
" + details;
}
let headers = `${code} ${message}\n`;
headers += "Content-Type: text/html\n";
if (wtv_reset && !pc_mode) {
headers += "wtv-service: reset\n";
headers += this.getServiceString('wtv-1800') + "\n";
headers += "wtv-visit: wtv-1800:/preregister?scriptless-visit-reason=999\n";
console.error(" * doErrorPage Called (sent wtv-reset):", code, message);
} else {
console.error(" * doErrorPage Called:", code, message);
}
return [headers, message];
}
/**
* Strips bad things from paths
* @param {string} base Base path
* @param {string} target Sub path
*/
makeSafePath(base, target = null, force_forward_slash = false) {
if (target) {
target.replace(/[\|\&\;\$\%\@\"\<\>\+\,\\]/g, "");
var targetPath = this.path.posix.normalize(target)
var output = this.fixPathSlashes(base + this.path.sep + targetPath);
} else {
base.replace(/[\|\&\;\$\%\@\"\<\>\+\,\\]/g, "");
var targetPath = this.path.posix.normalize(base)
var output = this.fixPathSlashes(targetPath);
}
return (force_forward_slash) ? output.replace(this.path.sep, '/') : output;
}
/**
* Returns a string with certain characters stripped out
* @param {string} username String to filter
*/
makeSafeUsername(username) {
return username.replace(/^([A-Za-z0-9\-\_])$/g, '');
}
/**
* Corrects any / or \ differences, if any for file paths
* @param {string} path
* @returns {string} corrected path
*/
fixPathSlashes(path) {
const pathModule = require('path');
// Normalize the slashes to the current environment's default.
const normalizedPath = path.replace(/[\\/]+/g, pathModule.sep);
// The path.normalize will also resolve any '..' and '.' segments.
return pathModule.normalize(normalizedPath);
}
/**
* Makes sure an SSID is clean, and doesn't contain any exploitable characters
* @param {string} ssid
* @returns {string} Sanitized SSID
*/
makeSafeSSID(ssid = "") {
ssid = ssid.replace(/[^a-zA-Z0-9]/g, "");
if (ssid.length == 0) ssid = null;
return ssid;
}
makeSafeStringPath(path = "") {
path = path.replace(/[^\w]/g, "").replace(/\.\./g, "");
if (path.length == 0) path = null;
return path;
}
unpackCompressedB64(data) {
var data_buf = (typeof data === 'object') ? Buffer.from(data.toString('ascii'), 'base64') : Buffer.from(data, 'base64');
return this.zlib.inflateSync(data_buf, { finishFlush: this.zlib.Z_SYNC_FLUSH }).toString('ascii');
}
packCompressedB64(data) {
return this.zlib.deflateSync(data, { 'level': 9 }).toString('base64');
}
/**
* Gets a Service Dependency from the first available Vault.
* @param {string} file The Path to the file
* @param {boolean} path_only If true, return the path, not the file contents
* @param {boolean} template If true, looks under templates subdir.
*/
getServiceDep(file, path_only = false, template = false) {
var self = this;
var outdata = null;
var found = false
this.minisrv_config.config.ServiceDeps.forEach(function (dep_vault_dir) {
if (found) return;
if (template) dep_vault_dir += self.path.sep + "templates";
var search = self.getAbsolutePath(dep_vault_dir + self.path.sep + file);
if (self.fs.existsSync(search)) {
if (path_only) outdata = search;
else outdata = self.fs.readFileSync(search);
found = true;
return false;
}
});
return outdata;
}
/**
* A convenience alias for getServiceDep
* @param {string} service_name Service Name
* @param {string} path Path to the template under the service directory
* @param {boolean} path_only If true, return the path, not the file contents
*/
getTemplate(service_name, path, path_only = false) {
return this.getServiceDep(service_name + this.path.sep + path, path_only, true);
}
/**
* Finds a key in an object
* @param {string} key The key to find
* @param {object} obj The object to search
* @param {boolean} case_insensitive Search case insensitive
* @returns
*/
findObjectKeyIndex(key, obj, case_insensitive = false) {
const keys = Object.keys(obj);
if (case_insensitive) {
key = key.toLowerCase();
return keys.findIndex(k => k.toLowerCase() === key);
}
return keys.indexOf(key);
}
/**
* Moves an object to the desired location in the object (reorder)
* @param {string|int} currentKey Name of the object Key to move or the index to move
* @param {string|int} destKey Name of the object key to place currentKey after or the index to place it at
* @param {object} obj The object to work on
* @param {boolean} case_insensitive Search case insensitive
* @returns {object} The modified object
*/
moveObjectKey(currentKey, destKey, obj, case_insensitive = false) {
let keys = Object.keys(obj);
let values = Object.values(obj);
const currentIndex = typeof currentKey === 'string' ? this.findObjectKeyIndex(currentKey, obj, case_insensitive) : parseInt(currentKey);
if (currentIndex === -1) return obj;
var destIndex = typeof destKey === 'string' ? this.findObjectKeyIndex(destKey, obj, case_insensitive) : parseInt(destKey);
// Bump by one if the destKey is a string (put after the key)
if (typeof destKey === 'string' && destIndex !== -1) destIndex++;
// If destKey is not found or not defined, don't move the element
if (isNaN(destIndex)) return obj;
// Remove the current element from keys and values
const [currentKeyValue] = values.splice(currentIndex, 1);
const [currentKeyName] = keys.splice(currentIndex, 1);
// Insert the current element after the destKey position
keys.splice(destIndex, 0, currentKeyName);
values.splice(destIndex, 0, currentKeyValue);
// Reconstruct the object with the new order
const result = {};
keys.forEach((key, index) => {
result[key] = values[index];
});
return result;
}
getCaseInsensitiveKey(key, obj) {
const foundKey = Object.keys(obj).find(k => k.toLowerCase() === key.toLowerCase());
return foundKey || null;
}
}
class clientShowAlert {
message = null;
buttonlabel1 = null;
buttonlabel2 = null;
buttonaction1 = null;
buttonaction2 = null;
noback = null;
image = null;
constructor(image = null, message = null, buttonlabel1 = null, buttonaction1 = null, buttonlabel2 = null, buttonaction2 = null, noback = null, sound = null) {
this.message = message;
this.buttonlabel1 = buttonlabel1;
this.buttonlabel2 = buttonlabel2;
this.buttonaction1 = buttonaction1;
this.buttonaction2 = buttonaction2;
this.message = message;
this.noback = noback;
this.sound = sound;
if (this.sound === false) this.sound = "none";
if (typeof image === 'object') {
this.image = null;
Object.keys(image).forEach(function (k) {
if (this[k] === null) this[k] = image[k];
}, this);
} else {
this.image = image;
}
}
getURL() {
var url = "client:ShowAlert?";
if (this.message) url += "message=" + escape(this.message) + "&";
if (this.buttonlabel1) url += "buttonlabel1=" + escape(this.buttonlabel1) + "&";
if (this.buttonaction1) url += "buttonaction1=" + escape(this.buttonaction1) + "&";
if (this.buttonlabel2) url += "buttonlabel2=" + escape(this.buttonlabel2) + "&";
if (this.buttonaction2) url += "buttonaction2=" + escape(this.buttonaction2) + "&";
if (this.image) url += "image=" + escape(this.image) + "&";
if (this.sound) url += "sound=" + escape(this.sound) + "&";
if (this.noback) url += "noback=true&";
return url.substring(0, url.length - 1);
}
}
module.exports.WTVShared = WTVShared;
module.exports.clientShowAlert = clientShowAlert;