UNPKG

ws-user

Version:

User authentication and authorization for WebSocket applications.

1,016 lines (756 loc) 78.1 kB
/** Copyright (c) Manuel Lõhmus (MIT License) */ 'use strict'; var path = require('path'), fs = require('fs'), crypto = require("crypto"), DataContext = require('data-context'), configSets = require('config-sets'), userConfigSets = configSets('ws-user', { isDebug: false, pathToFrontendErrorFile: 'log/frontend_errors.log', pathToLogFile: '', pathToLoggedUsers: 'logged_users.json', pathToUsersDir: 'users', fileSecurityCodeText: "./public/www/templates/.security-code.txt", fileSecurityCodeHtml: "./public/www/templates/.security-code.html" }), loggedUsers = DataContext.watchJsonFile({ filePath: userConfigSets.pathToLoggedUsers }); Object.defineProperties(CreateWsUser, { CONNECTING: { value: 0, writable: false, enumerable: false, configurable: false }, OPEN: { value: 1, writable: false, enumerable: false, configurable: false }, CLOSING: { value: 2, writable: false, enumerable: false, configurable: false }, CLOSED: { value: 3, writable: false, enumerable: false, configurable: false }, PAUSE: { value: 4, writable: false, enumerable: false, configurable: false }, addUser: { value: addUser, writable: false, enumerable: false, configurable: false }, }); //*** nodemailer *** var nodemailer = require("nodemailer"), nodemailerOptions = configSets('nodemailer', { isDebug: false, domain_account: 'localhost', auth: { user: "*user*@localhost", pass: "" } }); /** * @typedef {object} MailOptions * @property {string} from * @property {string} to * @property {string} cc * @property {string} bcc * @property {string} subject * @property {string} text * @property {string} html * @property {any[]} attachments */ /** * Send mail from noreply@*******.** * @param {MailOptions} mailOptions * @param {(err:Error|null)=>void} callback */ function sendMail(mailOptions, callback) { if (nodemailerOptions.auth.user === "*user*@localhost" || nodemailerOptions.auth.pass === "") { var errorMessage = "Please configure 'nodemailer' transporter options in 'config-sets.json' file.\n\tSee options: https://nodemailer.com/smtp/"; if (nodemailerOptions.isDebug) { console.error("[ ERROR ] 'nodemailer'", errorMessage); } return callback(errorMessage); } nodemailer.createTransport(nodemailerOptions) .sendMail(mailOptions, function (err, info) { if (err) { if (nodemailerOptions.isDebug) { console.error("[ ERROR ] 'nodemailer'", err); } callback(err); } else { var isOK = info.response.startsWith("250"); mailOptions.to .split(",") .map(function (str) { return str.trim() }) .forEach(function (str) { if (!info.accepted.includes(str)) { isOK = false; } }); if (nodemailerOptions.isDebug) { console.log("[ INFO ] 'nodemailer'", info); } callback(isOK ? null : new Error("Sending email failed.")); } }); } return module.exports = CreateWsUser; /** * CreateWsUser * @param {Options} options * @property {0} CONNECTING - The connection is not yet open. * @property {1} OPEN - The connection is open and ready to communicate. * @property {2} CLOSING - The connection is in the process of closing. * @property {3} CLOSED - The connection is closed or couldn't be opened. * @property {4} PAUSE - The connection is paused. * @returns {WsUser} * * @typedef {Object} Options * @property {http.IncomingMessage|http.ClientRequest} request - Reference link: https://nodejs.org/docs/latest/api/http.html#class-httpincomingmessage or https://nodejs.org/docs/latest/api/http.html#class-httpclientrequest * @property {Object.<string, string>} headers - Key-value pairs of header names and values. Header names are lower-cased. * @property {net.Socket} socket - This class is an abstraction of a TCP socket or a streaming IPC endpoint. * @property {string} protocol - The sub-protocol selected by the server. Default ''. * @property {string} origin - String. Default empty string. * @property {number} heartbeatInterval_ms - The interval after which ping pong takes place. Default on the client side 0ms and on the server side 30000ms. * @property {Object} extension - The extensions selected by the server. Default 'permessage-deflate' * @property {string} url - This is the URL of the WebSocket server to connect to. Default empty string. * * @typedef {Object} WsUser * @property {0|1|2|3|4} redyState CONNECTING: 0 | OPEN: 1 | CLOSING: 2 | CLOSED: 3 | PAUSE: 4 * @property {()=>void} onopen - Event handler for the 'open' event. * @property {(ev:{data})=>void} onmessage - Event handler for the 'message' event. * @property {(ev:{error})=>void} onerror - Event handler for the 'error' event. * @property {(ev:{code, reason})=>void} onclose - Event handler for the 'close' event. * @property {(data:string|BinaryData)=>void} send - Sends data to the server over the WebSocket connection. * @property {(code:number, reason:string)=>void} close - Closes the WebSocket connection or connection attempt, if any. */ function CreateWsUser({ request, url = '', headers, socket, protocol = '', origin = '', heartbeatInterval_ms = 0, extension, } = {}) { if (CreateWsUser === this?.constructor) { throw new Error('CreateWsUser must be called without `new` keyword!'); } /** * CONNECTING: 0 | OPEN: 1 | CLOSING: 2 | CLOSED: 3 | PAUSE: 4 * @type {0|1|2|3|4} readyState */ var readyState = CreateWsUser.CONNECTING, isWsUser = false, connID = '', email = '', roles = [], organizations = [], sendedData = null, ws = createWebSocket(), location = origin || headers?.origin || request?.headers?.origin, self = Object.create(null); if (!ws) { return null; } loggedUsers.on('-change', (event) => { if (!loggedUsers[email] && readyState === 1 && roles.length) { sendUserinfo({ isLogged: false }); } return Boolean(self); }); return Object.defineProperties(self, { // Public properties readyState: { get: function () { return readyState }, set: function (v) { readyState = v; this.onreadystate?.(v); }, enumerable: false, configurable: false }, protocol: { get: function () { return protocol }, enumerable: false, configurable: false }, connID: { get: function () { return connID }, enumerable: false, configurable: false }, email: { get: function () { return email }, enumerable: false, configurable: false }, roles: { get: function () { return roles }, enumerable: false, configurable: false }, organizations: { get: function () { return organizations }, enumerable: false, configurable: false }, url: { get: function () { return url }, enumerable: false, configurable: false }, isLoggedIn: { get: function () { return Boolean(loggedUsers[email]?.connIDs?.includes(connID)); }, enumerable: false, configurable: false }, extensionCommands: { value: Object.create(null), writable: false, enumerable: false, configurable: false }, // Public events (callbacks) onreadystate: { value: null, writable: true, enumerable: false, configurable: false }, onopen: { value: null, writable: true, enumerable: false, configurable: false }, onmessage: { value: null, writable: true, enumerable: false, configurable: false }, onerror: { value: null, writable: true, enumerable: false, configurable: false }, onclose: { value: null, writable: true, enumerable: false, configurable: false }, onloggedin: { value: null, writable: true, enumerable: false, configurable: false }, // Public methods send: { value: sendData, writable: false, enumerable: false, configurable: false }, messageHandled: { value: messageHandled, writable: false, enumerable: false, configurable: false }, close: { value: close, writable: false, enumerable: false, configurable: false } }); function createWebSocket() { if (!headers) { headers = request.headers; } if (headers?.['sec-websocket-protocol']) { var[pro, id, mail] = headers['sec-websocket-protocol'] .replace(/,+/g, ' ') .split(' ') .filter(p => p); protocol = pro ? pro : protocol; connID = id ? id : ''; email = mail ? mail.replace("*", "@") : ''; isWsUser = protocol === 'ws-user'; } if (loggedUsers[email]?.connIDs?.includes(connID) && loggedUsers[email].roles) { roles = loggedUsers[email].roles; organizations = loggedUsers[email].organizations; } ws = require('ws13')({ isDebug: userConfigSets.isDebug, request, headers, socket, protocol, origin, heartbeatInterval_ms, extension, }); if (!ws) { return null; } ws.onopen = onWsOpen; ws.onmessage = onWsMessage; ws.onerror = onWsError; ws.onclose = onWsClose; return ws; } // Event handlers function onWsOpen() { pDebug('Connection opened'); self.readyState = CreateWsUser.OPEN; if (isWsUser && loggedUsers[email]?.connIDs.includes(connID)) { sendUserinfo({ isLogged: true, user: loggedUsers[email] }); } else if (isWsUser) { sendUserinfo({ isLogged: false }); } if (sendedData?.startsWith('$userinfo')) { sendedData = null; } if (sendedData) { sendData(sendedData); } self.onopen && self.onopen.call(self); } function onWsMessage(event) { if (typeof event.data === 'string' && !event.data) { pDebug('Received - State => OPEN'); self.readyState = CreateWsUser.OPEN; sendedData = null; return; } if (executeCommand(event.data)) { return; } pDebug(`'Message received'`, (event.data + '').replace(/\s+/g, '').substring(0, 64)); self.onmessage && self.onmessage.call(self, event); } function onWsError(event) { pError(event); self.onerror && self.onerror.call(self, event); } function onWsClose(event) { pDebug('Connection closed', event); self.readyState = CreateWsUser.CLOSED; self.onclose && self.onclose.call(self, event); // websocket - Going Away if (isWsUser && (event.code === 1001 || event.code === 1006)) { connLogout(); } function connLogout() { if (loggedUsers[email]?.connIDs.includes(connID)) { loggedUsers[email].connIDs = loggedUsers[email].connIDs.filter(c => c !== connID); } if (!loggedUsers[email]?.connIDs.length) { delete loggedUsers[email]; } } } // Public methods function sendData(data) { if (!data || !(data?.length || data?.byteLength) || self.readyState === CreateWsUser.CLOSING || self.readyState === CreateWsUser.CLOSED) { return; } // OPEN: 1 if (self.readyState === CreateWsUser.OPEN) { pDebug('Send - State => PAUSE'); pDebug(`'Sending message'`, data.replace(/\s+/g, '').substring(0, 64)); self.readyState = CreateWsUser.PAUSE; ws.send(data); sendedData = data; return; } // Wait for connection to open and try again in 100ms intervals setTimeout(sendData, 100, data); } function messageHandled() { ws.send(''); } function close(code = 1000, reason = 'Normal closure') { if (!ws) { return; } self.readyState = CreateWsUser.CLOSING; ws.close(code, reason); } // Private methods function executeCommand(message) { if (ws.protocol !== 'ws-user') { return false; } var msgObj = parseMsg(message); if (msgObj) { setTimeout(execute, 0, msgObj); return true; } return false; function parseMsg(message) { if (message[0] !== '$') { return null; } var msgObj = { cmd: '', rawBody: '' }; message = (message + '').split(':'); msgObj.cmd = (message.shift() + '').trim(); if (msgObj.cmd[0] === '$') { msgObj.cmd = msgObj.cmd.substring(1); } msgObj.rawBody = message.join(':'); return msgObj; } function execute(msgObj) { var functionMap = { log: function (msg) { if (!msg || !userConfigSets.pathToLogFile) { return; } var text = `${new Date().toISOString()}|${ws?.ip || 'undefined'}`; while (text.length < 40) { text += ' '; } text += `${msg}\n`; var filePath = path.parse(userConfigSets.pathToLogFile); filePath.base = location.substring(location.indexOf('//') + 2) + ('-' + filePath.base || '.log'); filePath = path.resolve( path.parse(process.argv[1]).dir.split("node_modules").shift(), filePath.dir, filePath.base ); if (!fs.existsSync(path.parse(filePath).dir)) { fs.mkdirSync( path.parse(filePath).dir, { recursive: true } ); } fs.appendFile(filePath, text, function (err) { if (err) { pError(err); } }); done(); }, log_error: function (msg) { var text = `[${new Date().toISOString()}][ ERROR ]\t${msg}\n`; var filePath = path.resolve(path.parse(process.argv[1]).dir.split("node_modules").shift(), userConfigSets.pathToFrontendErrors); if (!fs.existsSync(path.parse(filePath).dir)) { fs.mkdirSync( path.parse(filePath).dir, { recursive: true } ); } fs.appendFile(filePath, text, function (err) { if (err) { pError(err); } }); pError('Frontend:', msg); done(); }, login: function (msg) { msg = unmasking(msg, connID); var oldEmail = email, [userEmail, password] = msg.split(':'); var user = getUserDataFromFile(userEmail); if (!user) { return; } email = userEmail; if (user.password !== encryptPassword(password)) { sendUserinfo({ isLogged: false, message: 'Wrong password.', alerttype: 'alert-warning', user: { email: userEmail, securityCode: user.securityCode } }); done(); return; } // Logout if (loggedUsers[oldEmail]?.connIDs.includes(connID)) { loggedUsers[oldEmail].connIDs = loggedUsers[oldEmail].connIDs.filter(c => c !== connID); organizations = []; roles = []; if (typeof self.onloggedin === 'function') { self.onloggedin.call(self, { isLoggedIn: false }); } } if (!loggedUsers[oldEmail]?.connIDs.length) { delete loggedUsers[oldEmail]; } // Login if (!loggedUsers[userEmail]) { loggedUsers[userEmail] = { connIDs: [], email: user.email, name: user.name, organizations: user.organizations, roles: user.roles }; } if (!loggedUsers[userEmail].connIDs.includes(connID)) { loggedUsers[userEmail].connIDs.push(connID); } organizations = user.organizations; roles = user.roles; sendUserinfo({ isLogged: true, message: 'You are logged in.', user }); if (!user.ip_addresses) { user.ip_addresses = []; } if (!user.ip_addresses.includes(ws.ip)) { user.ip_addresses.push(ws.ip); saveUserDataToFile(user, function () { done(); }); } if (typeof self.onloggedin === 'function') { self.onloggedin.call(self, { isLoggedIn: true }); } done(); }, logout: function (msg) { var conn_id = msg; if (conn_id && loggedUsers[email]) { loggedUsers[email].connIDs = loggedUsers[email].connIDs.filter(c => c !== conn_id); sendUserinfo({ message: 'You are logged out.' }); } else { delete loggedUsers[email]; sendUserinfo({ message: 'You are logged out everywhere.' }); } if (typeof self.onloggedin === 'function') { self.onloggedin.call(self, { isLoggedIn: false }); } done(); }, create_account: function (msg) { msg = unmasking(msg, connID); var [userEmail, password, username] = msg.split(':'), filePath = findUserDataFilePath(userEmail); if (filePath) { sendUserinfo({ isLogged: false, message: 'Email in use.', alerttype: 'alert-warning', }); done(); return; } email = userEmail; password = encryptPassword(password); self.create_account = { email: userEmail, password, name: username, organizations: [], roles: ['user'] }; sendSecurityCode(self.create_account, done); }, update_name: function update_name(msg) { var [userEmail, username] = msg.split(':'), user = getUserDataFromFile(userEmail); if (!user) { return; } user.name = username; if (loggedUsers[userEmail]) { loggedUsers[userEmail].name = username; } saveUserDataToFile(user, function () { sendUserinfo({ isLogged: true, message: 'You name is updated.', user }); done(); }); }, update_password: function update_password(msg) { msg = unmasking(msg, connID); var [userEmail, password] = msg.split(':'), user = getUserDataFromFile(userEmail); if (!user) { return; } user.password = encryptPassword(password); saveUserDataToFile(user, function () { sendUserinfo({ isLogged: true, message: 'You password is updated.', user }); done(); }); }, security_code: function (msg) { msg = unmasking(msg, connID); var [userEmail, securityCode, resetPassword] = msg.split(':'); // end create account if (securityCode && !resetPassword && self.create_account?.email === userEmail) { if (self.create_account.securityCode !== securityCode) { sendUserinfo({ isLogged: false, message: `The security code does not match, try again. (${userEmail})`, alerttype: 'alert-warning', user: { email: userEmail } }); delete self.create_account; if (typeof self.onloggedin === 'function') { self.onloggedin.call(self, { isLoggedIn: false }); } done(); return; } delete self.create_account.securityCode; saveUserDataToFile(self.create_account, function () { sendUserinfo({ isLogged: true, message: 'You are logged in.', user: self.create_account }); delete self.create_account; if (typeof self.onloggedin === 'function') { self.onloggedin.call(self, { isLoggedIn: true }); } done(); }); return; } var user = getUserDataFromFile(userEmail, done); if (!user) { return; } if (securityCode) { if (securityCode !== user.securityCode) { sendUserinfo({ isLogged: false, message: `The security code does not match, try again. (${userEmail})`, alerttype: 'alert-warning', user: { email: userEmail } }); return; } delete user.securityCode; user.resetPassword = true; sendUserinfo({ isLogged: true, message: 'You are logged in.', user }); delete user.resetPassword; roles = user.roles; saveUserDataToFile(user, done); return; } sendSecurityCode(user, function () { saveUserDataToFile(user, done); }); } } if (typeof functionMap[msgObj.cmd] === 'function') { return functionMap[msgObj.cmd](msgObj.rawBody); } if (typeof self.extensionCommands[msgObj.cmd] === 'function') { return self.extensionCommands[msgObj.cmd].call(self, { message: msgObj.rawBody, done }); } pDebug('Command not found >', msgObj.cmd); done(); return; function done() { messageHandled(); } function getUserDataFilePath(userEmail) { var filePath = findUserDataFilePath(userEmail); if (!filePath) { sendUserinfo({ isLogged: false, message: `User does not exist. (${userEmail})`, alerttype: 'alert-warning', user: { email: userEmail } }); done(); return; } return filePath; } function getUserDataFromFile(userEmail) { if (!validEmail(userEmail)) { sendUserinfo({ isLogged: false, message: 'User email not validated.', alerttype: 'alert-warning', }); done(); return; } var filePath = getUserDataFilePath(userEmail, done); if (!filePath) { return; } return DataContext.parse(fs.readFileSync(filePath, { encoding: 'utf8' }), DataContext); } function saveUserDataToFile(user, cb_ok) { var filePath = calcUserDataFilePath(user.email); if (!filePath) { return; } if (!fs.existsSync(path.parse(filePath).dir)) { fs.mkdirSync( path.parse(filePath).dir, { recursive: true } ); } fs.writeFile( filePath, DataContext.stringify(user, null, 2), { encoding: 'utf8' }, (err) => { if (err) throw err; if (typeof cb_ok === 'function') { cb_ok(); } } ); } function sendSecurityCode(user, cb_ok) { user.securityCode = generateSecurityCode(); sendSecurityCodeEmail(user, function (err) { if (err) { if (userConfigSets.isDebug) { pDebug(user.email, 'securityCode', user.securityCode); } else { delete user.securityCode; sendUserinfo({ isLogged: false, message: 'Sending email failed.', alerttype: 'alert-warning' }); done(); return; } } sendUserinfo({ isLogged: false, message: 'Please check your email for the security code.', alerttype: 'alert-info', user: { securityCode: true, email: user.email } }); if (typeof cb_ok === 'function') { cb_ok(); } }); function sendSecurityCodeEmail(user, callback) { var strText = userConfigSets?.fileSecurityCodeText && fs.existsSync(userConfigSets.fileSecurityCodeText) ? fs.readFileSync(fileText).toString() : "", strHtml = userConfigSets?.fileSecurityCodeHtml && fs.existsSync(userConfigSets.fileSecurityCodeHtml) ? fs.readFileSync(fileHtml).toString() : "", domain = nodemailerOptions.domain_account; if (!domain || domain.includes('localhost')) { domain = require('node:os').hostname(); } var data = { domain: domain.replace(/^\w/, function (c) { return c.toUpperCase(); }), email: maskEmail(user.email), securitycode: user.securityCode }; Object.keys(data).forEach(function (key) { strText = strText.split('[' + key + ']').join(data[key]); strHtml = strHtml.split('[' + key + ']').join(htmlEncode(data[key])); }); sendMail({ from: nodemailerOptions.auth.user, // sender address to: user.email,// + ", conextra.ee@outlook.com", // list of receivers subject: domain + " account security code", // Subject line text: strText, // plain text body html: strHtml, // html body }, callback ); function maskEmail(email) { email = (email + '').split('@'); email[0] = email[0].length > 3 ? email[0].substr(0, 2) + '**********' : email[0].substr(0, 1) + '**********'; return email.join('@'); } } } } } /** * @typedef {Object} Userinfo * @property {boolan} isLogged * @property {string} message * @property {string} alerttype "alert-error" | "alert-success" | "alert-info" | "alert-warning" * @property {object} user * * @param {Userinfo} options * @returns {void} * */ function sendUserinfo({ isLogged = false, message = '', alerttype = 'alert-success', user = {} } = {}) { sendData('$userinfo:' + JSON.stringify({ isLogged, message, alerttype, email: user.email || '', name: user.name || '', organizations: user.organizations || [], roles: user.roles || [], securityCode: Boolean(user.securityCode), resetPassword: Boolean(user.resetPassword) })); } // Debugging function pDebug(...args) { if (userConfigSets.isDebug) { console.log(`[ DEBUG ] 'ws-user' protocol:`, protocol, ...args); } } function pError(...args) { if (userConfigSets.isDebug) { console.error(`[ ERROR ] 'ws-user' protocol:`, protocol, ...args); } } } //*** Helpers ***/ function encryptPassword(password) { return sha512('us' + password); function sha512(data) { return crypto.createHash("sha512").update(data, "binary").digest("hex"); } } function unmasking(str, key) { str = Buffer.from(str, 'base64'); key = Buffer.from(key.substring(0, 4), 'utf-8'); for (var i = 0, n = str.length; i < n; i++) { str[i] = str[i] ^ key[i & 3]; } return str.toString('utf8'); } function generateSecurityCode() { function getRandomInt(max) { return Math.floor(Math.random() * Math.floor(max)); } var key = Array.from("0123456789"); var code = ''; while (code.length < 7) { code += key.splice(getRandomInt(key.length - 1), 1).join(''); } return code; } function calcFilePath(filePath) { return path.resolve(path.parse(process.argv[1]).dir.split("node_modules").shift(), filePath); } function calcUserDataFilePath(userEmail) { return calcFilePath(path.join(userConfigSets.pathToUsersDir, userEmail, userEmail + '.json')); } function findUserDataFilePath(userEmail) { var filePath = calcUserDataFilePath(userEmail); if (!fs.existsSync(filePath)) { return; } return filePath; } function addUser(email, password, username) { if (!validEmail(email)) { return `Error: Email not validated.`; } if (!password) { return `Error: Password not validated.`; } if (password.length < 6) { return `Error: Password too short.`; } var filePath = findUserDataFilePath(email); if (filePath) { return `Error: Email in use.`; } if (!username) { // If username is not provided, use the part before the '@' in the email address username = email.split('@')[0]; // Replace all '.' with ' ' in username username = username.replace(/\./g, ' '); // Remove all special characters from username username = username.trim().replace(/[^a-zA-Z0-9_\s]/g, ''); // Capitalize the first letters username = username.replace(/^\w/, function (c) { return c.toUpperCase(); }); } password = encryptPassword(password); var user = { email, password, name: username, organizations: [], roles: ['user'], //securityCode: generateSecurityCode() }; // Create user directory if it doesn't exist filePath = calcUserDataFilePath(email); if (!fs.existsSync(path.parse(filePath).dir)) { fs.mkdirSync( path.parse(filePath).dir, { recursive: true } ); } // Write user data to file fs.writeFileSync(filePath, DataContext.stringify(user, null, 2), { encoding: 'utf8' }); return 'User created successfully.'; } function validEmail(mail) { return /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()\.,;\s@\"]+\.{0,1})+([^<>()\.,;:\s@\"]{2,}|[\d\.]+))$/.test(mail); } function htmlEncode(input) { var strOutput; if (input && input.constructor === String) { strOutput = ""; for (var i = 0; i < input.length; i++) { var charCode = input.charCodeAt(i); switch (charCode) { case 9: strOutput += "&emsp;"; break; // \t case 10: strOutput += input.charCodeAt(i - 1) === 13 ? "" : "<br/>"; break; // \n case 13: strOutput += "<br/>"; break; // \r case 32: strOutput += "&nbsp;"; break; // " " case 34: strOutput += "&quot;"; break; // " case 38: strOutput += "&amp;"; break; // & case 39: strOutput += "&apos;"; break; // ' case 60: strOutput += "&lt;"; break; // < case 62: strOutput += "&gt;"; break; // > case 338: strOutput += "&OElig;"; break; // ? case 339: strOutput += "&oelig;"; break; // ? case 352: strOutput += "&Scaron;"; break; // Š case 353: strOutput += "&scaron;"; break; // š case 376: strOutput += "&Yuml;"; break; // ? case 402: strOutput += "&fnof;"; break; // ? case 710: strOutput += "&circ;"; break; // ? case 732: strOutput += "&tilde;"; break; // ? case 8194: strOutput += "&ensp;"; break; // case 8195: strOutput += "&emsp;"; break; // case 8201: strOutput += "&thinsp;"; break; // case 8204: strOutput += "&zwnj;"; break; // case 8205: strOutput += "&zwj;"; break; // case 8206: strOutput += "&lrm;"; break; // case 8207: strOutput += "&rlm;"; break; // case 8211: strOutput += "&ndash;"; break; // – case 8212: strOutput += "&mdash;"; break; // — case 8216: strOutput += "&lsquo;"; break; // ‘ case 8217: strOutput += "&rsquo;"; break; // ’ case 8218: strOutput += "&sbquo;"; break; // ‚ case 8220: strOutput += "&ldquo;"; break; // “ case 8221: strOutput += "&rdquo;"; break; // ” case 8222: strOutput += "&bdquo;"; break; // „ case 8224: strOutput += "&dagger;"; break; // † case 8225: strOutput += "&Dagger;"; break; // ‡ case 8226: strOutput += "&bull;"; break; // • case 8249: strOutput += "&lsaquo;"; break; // ‹ case 8250: strOutput += "&rsaquo;"; break; // › //case : strOutput += "&;"; break; // default: if (charCode < 128) strOutput += input.charAt(i); else strOutput += "&#" + charCode + ";"; break; } } return strOutput; } return input; }; function htmlDecode(input) { var strOutput; if (input && input.constructor === String) { strOutput = ""; /** @const */ var entities = { "Aacute": [193], "aacute": [225], "Abreve": [258], "abreve": [259], "ac": [8766], "acd": [8767], "acE": [8766, 819], "Acirc": [194], "acirc": [226], "acute": [180], "Acy": [1040], "acy": [1072], "AElig": [198], "aelig": [230], "af": [8289], "Afr": [120068], "afr": [120094], "Agrave": [192], "agrave": [224], "alefsym": [8501], "aleph": [8501], "Alpha": [913], "alpha": [945], "Amacr": [256], "amacr": [257], "amalg": [10815], "AMP": [38], "amp": [38], "And": [10835], "and": [8743], "andand": [10837], "andd": [10844], "andslope": [10840], "andv": [10842], "ang": [8736], "ange": [10660], "angle": [8736], "angmsd": [8737], "angmsdaa": [10664], "angmsdab": [10665], "angmsdac": [10666], "angmsdad": [10667], "angmsdae": [10668], "angmsdaf": [10669], "angmsdag": [10670], "angmsdah": [10671], "angrt": [8735], "angrtvb": [8894], "angrtvbd": [10653], "angsph": [8738], "angst": [197], "angzarr": [9084], "Aogon": [260], "aogon": [261], "Aopf": [120120], "aopf": [120146], "ap": [8776], "apacir": [10863], "apE": [10864], "ape": [8778], "apid": [8779], "apos": [39], "ApplyFunction": [8289], "approx": [8776], "approxeq": [8778], "Aring": [197], "aring": [229], "Ascr": [119964], "ascr": [119990], "Assign": [8788], "ast": [42], "asymp": [8776], "asympeq": [8781], "Atilde": [195], "atilde": [227], "Auml": [196], "auml": [228], "awconint": [8755], "awint": [10769], "backcong": [8780], "backepsilon": [1014], "backprime": [8245], "backsim": [8765], "backsimeq": [8909], "Backslash": [8726], "Barv": [10983], "barvee": [8893], "Barwed": [8966], "barwed": [8965], "barwedge": [8965], "bbrk": [9141], "bbrktbrk": [9142], "bcong": [8780], "Bcy": [1041], "bcy": [1073], "bdquo": [8222], "becaus": [8757], "Because": [8757], "because": [8757], "bemptyv": [10672], "bepsi": [1014], "bernou": [8492], "Bernoullis": [8492], "Beta": [914], "beta": [946], "beth": [8502], "between": [8812], "Bfr": [120069], "bfr": [120095], "bigcap": [8898], "bigcirc": [9711], "bigcup": [8899], "bigodot": [10752], "bigoplus": [10753], "bigotimes": [10754], "bigsqcup": [10758], "bigstar": [9733], "bigtriangledown": [9661], "bigtriangleup": [9651], "biguplus": [10756], "bigvee": [8897], "bigwedge": [8896], "bkarow": [10509], "blacklozenge": [10731], "blacksquare": [9642], "blacktriangle": [9652], "blacktriangledown": [9662], "blacktriangleleft": [9666], "blacktriangleright": [9656], "blank": [9251], "blk12": [9618], "blk14": [9617], "blk34": [9619], "block": [9608], "bne": [61, 8421], "bnequiv": [8801, 8421], "bNot": [10989], "bnot": [8976], "Bopf": [120121], "bopf": [120147], "bot": [8869], "bottom": [8869], "bowtie": [8904], "boxbox": [10697], "boxDL": [9559], "boxDl": [9558], "boxdL": [9557], "boxdl": [9488], "boxDR": [9556], "boxDr": [9555], "boxdR": [9554], "boxdr": [9484], "boxH": [9552], "boxh": [9472], "boxHD": [9574], "boxHd": [9572], "boxhD": [9573], "boxhd": [9516], "boxHU": [9577], "boxHu": [9575], "boxhU": [9576], "boxhu": [9524], "boxminus": [8863], "boxplus": [8862], "boxtimes": [8864], "boxUL": [9565], "boxUl": [9564], "boxuL": [9563], "boxul": [9496], "boxUR": [9562], "boxUr": [9561], "boxuR": [9560], "boxur": [9492], "boxV": [9553], "boxv": [9474], "boxVH": [9580], "boxVh": [9579], "boxvH": [9578], "boxvh": [9532], "boxVL": [9571], "boxVl": [9570], "boxvL": [9569], "boxvl": [9508], "boxVR": [9568], "boxVr": [9567], "boxvR": [9566], "boxvr": [9500], "bprime": [8245], "Breve": [728], "breve": [728], "brvbar": [166], "Bscr": [8492], "bscr": [119991], "bsemi": [8271], "bsim": [8765], "bsime": [8909], "bsol": [92], "bsolb": [10693], "bsolhsub": [10184], "bull": [8226], "bullet": [8226], "bump": [8782], "bumpE": [10926], "bumpe": [8783], "Bumpeq": [8782], "bumpeq": [8783], "Cacute": [262], "cacute": [263], "Cap": [8914], "cap": [8745], "capand": [10820], "capbrcup": [10825], "capcap": [10827], "capcup": [10823], "capdot": [10816], "CapitalDifferentialD": [8517], "caps": [8745, 65024], "caret": [8257], "caron": [711], "Cayleys": [8493], "ccaps": [10829], "Ccaron": [268], "ccaron": [269], "Ccedil": [199], "ccedil": [231], "Ccirc": [264], "ccirc": [265], "Cconint": [8752], "ccups": [10828], "ccupssm": [10832], "Cdot": [266], "cdot": [267], "cedil": [184], "Cedilla": [184], "cemptyv": [10674], "cent": [162], "CenterDot": [183], "centerdot": [183], "Cfr": [8493], "cfr": [120096], "CHcy": [1063], "chcy": [1095], "check": [10003], "checkmark": [10003], "Chi": [935], "chi": [967], "cir": [9675], "circ": [710], "circeq": [8791], "circlearrowleft": [8634], "circlearrowright": [8635], "circledast": [8859], "circledcirc": [8858], "circleddash": [8861], "CircleDot": [8857], "circledR": [174], "circledS": [9416], "CircleMinus": [8854], "CirclePlus": [8853], "CircleTimes": [8855], "cirE": [10691], "cire": [8791], "cirfnint": [10768], "cirmid": [10991], "cirscir": [10690], "ClockwiseContourIntegral": [8754], "CloseCurlyDoubleQuote": [8221], "CloseCurlyQuote": [8217], "clubs": [9827], "clubsuit": [9827], "Colon": [8759], "colon": [58], "Colone": [10868], "colone": [8788], "coloneq": [8788], "comma": [44], "commat": [64], "comp": [8705], "compfn": [8728], "complement": [8705], "complexes": [8450], "cong": [8773], "congdot": [10861], "Congruent": [8801], "Conint": [8751], "conint": [8750], "ContourIntegral": [8750], "Copf": [8450], "copf": [120148], "coprod": [8720], "Coproduct": [8720], "COPY": [169], "copy": [169], "copysr": [8471], "CounterClockwiseContourIntegral": [8755], "crarr": [8629], "Cross": [10799], "cross": [10007], "Cscr": [119966], "cscr": [119992], "csub": [10959], "csube": [10961], "csup": [10960], "csupe": [10962], "ctdot": [8943], "cudarrl": [10552], "cudarrr": [10549], "cuepr": [8926], "cuesc": [8927], "cularr": [8630], "cularrp": [10557], "Cup": [8915], "cup": [8746], "cupbrcap": [10824], "CupCap": [8781], "cupcap": [10822], "cupcup": [10826], "cupdot": [8845], "cupor": [10821], "cups": [8746, 65024], "curarr": [8631], "curarrm": [10556], "curlyeqprec": [8926], "curlyeqsucc": [8927], "curlyvee": [8910], "curlywedge": [8911], "curren": [164], "curvearrowleft": [8630], "curvearrowright": [8631], "cuvee": [8910], "cuwed": [8911], "cwconint": [8754], "cwint": [8753], "cylcty": [9005], "Dagger": [8225], "dagger": [8224], "daleth": [8504], "Darr": [8609], "dArr": [8659], "darr": [8595], "dash": [8208], "Dashv": [10980], "dashv": [8867], "dbkarow": [10511], "dblac": [733], "Dcaron": [270], "dcaron": [271], "Dcy": [1044], "dcy": [1076], "DD": [8517], "dd": [8518], "ddagger": [8225], "ddarr": [8650], "DDotrahd": [10513], "ddotseq": [10871], "deg": [176], "Del": [8711], "Delta": [916], "delta": [948], "demptyv": [10673], "dfisht": [10623], "Dfr": [120071], "dfr": [120097], "dHar": [10597], "dharl": [8643], "dharr": [8642], "DiacriticalAcute": [180], "DiacriticalDot": [729], "DiacriticalDoubleAcute": [733], "DiacriticalGrave": [96], "DiacriticalTilde": [732], "diam": [8900], "Diamond": [8900], "diamond": [8900], "diamondsuit": [9830], "diams": [9830], "die": [168], "DifferentialD": [8518], "digamma": [989], "disin": [8946], "div": [247], "divide": [247], "divideontimes": [8903], "divonx": [8903], "DJcy": [1026], "djcy": [1106], "dlcorn": [8990], "dlcrop": [8973], "dollar": [36], "Dopf": [120123], "dopf": [120149], "Dot": [168], "dot": [729], "DotDot": [8412], "doteq": [8784], "doteqdot": [8785], "DotEqual": [8784], "dotminus": [8760], "dotplus": [8724], "dotsquare": [8865], "doublebarwedge": [8966], "DoubleContourIntegral": [8751], "DoubleDot": [168], "DoubleDownArrow": [8659], "DoubleLeftArrow": [8656], "DoubleLeftRightArrow": [8660], "DoubleLeftTee": [10980], "DoubleLongLeftArrow": [10232], "DoubleLongLeftRightArrow": [10234], "DoubleLongRightArrow": [10233], "DoubleRightArrow": [8658], "DoubleRightTee": [8872], "DoubleUpArrow": [8657], "DoubleUpDownArrow": [8661], "DoubleVerticalBar": [8741], "DownArrow": [8595], "Downarrow": [8659], "downarrow": [8595], "DownArrowBar": [10515], "DownArrowUpArrow": [8693], "DownBreve": [785], "downdownarrows": [8650], "downharpoonleft": [8643], "downharpoonright": [8642], "DownLeftRightVector": [10576], "DownLeftTeeVector": [10590], "DownLeftVector": [8637], "DownLeftVectorBar": [10582], "DownRightTeeVector": [10591], "DownRightVector": [8641], "DownRightVectorBar": [10583], "DownTee": [8868], "DownTeeArrow": [8615], "drbkarow": [10512], "drcorn": [8991], "drcrop": [8972], "Dscr": [119967], "dscr": [119993], "DScy": [1029], "dscy": [1109], "dsol": [10742], "Dstrok": [272], "dstrok": [273], "dtdot": [8945], "dtri": [9663], "dtrif": [9662], "duarr": [8693], "duhar": [10607], "dwangle": [10662], "DZcy": [1039], "dzcy": [1119], "dzigrarr": [10239], "Eacute": [201], "eacute": [233], "easter": [10862], "Ecaron": [282], "ecaron": [283], "ecir": [8790], "Ecirc": [202], "ecirc": [234], "ecolon": [8789], "Ecy": [1069], "ecy": [1101], "eDDot": [10871], "Edot": [278], "eDot": [8785], "edot": [279], "ee": [8519], "efDot": [8786], "Efr": [120072], "efr": [120098], "eg": [10906], "Egrave": [200], "egrave": [232], "egs": [10902], "egsdot": [10904], "el": [10905], "Element": [8712], "elinters": [9191], "ell": [8467], "els": [10901], "elsdot": [10903], "Emacr": [274], "emacr": [275], "empty": [8709], "emptyset": [8709], "EmptySmallSquare": [9723], "emptyv": [8709], "EmptyVerySmallSquare": [9643], "emsp": [8195], "emsp13": [8196], "emsp14": [8197], "ENG": [330], "eng": [331], "ensp": [8194], "Eogon": [280], "eogon": [281], "Eopf": [120124], "eopf": [120150], "epar": [8917], "eparsl": [10723], "eplus": [10865], "epsi": [949], "Epsilon": [917], "epsilon": [949], "epsiv": [1013], "eqcirc": [8790], "eqcolon": [8789], "eqsim": [8770], "eqslantgtr": [10902], "eqslantless": [10901], "Equal": [10869], "equals": [61], "EqualTilde": [8770], "equest": [8799], "Equilibrium": [8652], "equiv": [8801], "equivDD": [10872], "eqvparsl": [10725], "erarr": [10609], "erDot": [8787], "Escr": [8496], "escr": [8495], "esdot": [8784], "Esim": [10867], "esim": [8770], "Eta": [919], "eta": [951], "ETH": [208], "eth": [240], "Euml": [203], "euml": [235], "euro": [8364], "excl": [33], "exist": [8707], "Exists": [8707], "expectation": [8496], "ExponentialE": [8519], "exponentiale": [8519], "fallingdotseq": [8786], "Fcy": [1060], "fcy": [1092], "female": [9792], "ffilig": [64259], "fflig": [64256], "ffllig": [64260], "Ffr": [120073], "ffr": [120099], "filig": [64257], "FilledSmallSquare": [9724], "FilledVerySmallSquare": [9642], "fjlig": [102, 106], "flat": [9837], "fllig": [64258], "fltns": [9649], "fnof": [402], "Fopf": [120125], "fopf": [120151], "ForAll": [8704], "forall": [8704], "fork": [8916], "forkv": [10969], "Fouriertrf": [8497], "fpartint": [10765], "frac12": [189], "frac13": [8531], "frac14": [188], "frac15": [8533], "frac16": [8537], "frac18": [8539], "frac23": [8532], "frac25": [8534], "frac34": [190], "frac35": [8535], "frac38": [8540], "frac45": [8536], "frac56": [8538], "frac58": [8541], "frac78": [8542], "frasl": [8260], "frown": [8994], "Fscr": [8497], "fscr": [119995], "gacute": [501], "Gamma": [915], "gamma": [947], "Gammad": [988], "gammad": [989], "gap": [10886], "Gbreve": [286], "gbreve": [287], "Gcedil": [290], "Gcirc": [284], "gcirc": [285], "Gcy": [1043], "gcy": [1075], "Gdot": [288], "gdot": [289], "gE": [8807], "ge": [8805], "gEl": [10892], "gel": [8923], "geq": [8805], "geqq": [8807], "geqslant": [10878], "ges": [10878], "gescc": [10921], "gesdot": [10880], "gesdoto": [10882], "gesdotol": [10884], "gesl": [8923, 65024], "gesles": [10900], "Gfr": [120074], "gfr": [120100], "Gg": [8921], "gg": [8811], "ggg": [8921], "gimel": [8503], "GJcy": [1027], "gjcy": [1107], "gl": [8823], "gla": [10917], "glE": [10898], "glj": [10916], "gnap": [10890], "gnapprox": [10890], "gnE": [8809], "gne": [10888], "gneq": [10888], "gneqq": [8809], "gnsim": [8935], "Gopf": [120126], "gopf": [120152], "grave": [96], "GreaterEqual": [8805], "GreaterEqualLess": [8923], "GreaterFullEqual": [8807], "GreaterGreater": [10914], "GreaterLess": [8823], "GreaterSlantEqual": [10878], "GreaterTilde": [8819], "Gscr": [119970], "gscr": [8458], "gsim": [8819], "gsime": [10894], "gsiml": [10896], "GT": [62], "Gt": [8811], "gt": [62], "gtcc": [10919], "gtcir": [10874], "gtdot": [8919], "gtlPar": [10645], "gtquest": [10876], "gtrapprox": [10886], "gtrarr": [10616], "gtrdot": [8919], "gtreqless": [8923], "gtreqqless": [10892], "gtrless": [8823], "gtrsim": [8819], "gvertneqq": [8809, 65024], "gvnE": [8809, 65024], "Hacek": [711], "hairsp": [8202], "half": [189], "hamilt": [8459], "HARDcy": [1066], "hardcy": [1098], "hArr": [8660], "harr": [8596], "harrcir": [10568], "harrw": [8621], "Hat": [94], "hbar": [8463], "Hcirc": [292], "hcirc": [293], "hearts": [9829], "heartsuit": [9829], "hellip": [8230], "hercon": [8889], "Hfr": [8460], "hfr": [120101], "HilbertSpace": [8459], "hksearow": [10533], "hkswarow": [10534], "hoarr": [8703], "homtht": [8763], "hookleftarrow": [8617], "hookrightarrow": [8618], "Hopf": [8461], "hopf": [120153], "horbar": [8213], "HorizontalLine": [9472], "Hscr": [8459], "hscr": [119997], "hslash": [8463], "Hstrok": [294], "hstrok": [295], "HumpDownHump": [8782]