From f28e65ab8035682372edbe1c11d9ca2581e0a2e6 Mon Sep 17 00:00:00 2001 From: marzavec Date: Wed, 6 Nov 2019 23:35:23 -0800 Subject: Syntax update and formatting tweaks --- server/src/serverLib/CommandManager.js | 201 +++++++++--------- server/src/serverLib/ConfigManager.js | 74 ++++--- server/src/serverLib/CoreApp.js | 84 +++++--- server/src/serverLib/ImportsManager.js | 87 ++++---- server/src/serverLib/MainServer.js | 376 ++++++++++++++++++++------------- server/src/serverLib/RateLimiter.js | 105 ++++++--- server/src/serverLib/StatsManager.js | 63 ++++-- server/src/serverLib/index.js | 14 +- 8 files changed, 593 insertions(+), 411 deletions(-) (limited to 'server/src/serverLib') diff --git a/server/src/serverLib/CommandManager.js b/server/src/serverLib/CommandManager.js index 0c4f0aa..e715c6b 100644 --- a/server/src/serverLib/CommandManager.js +++ b/server/src/serverLib/CommandManager.js @@ -1,47 +1,71 @@ -/** - * Commands / protocol manager- loads, validates and handles command execution - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * - */ - -const path = require('path'); -const didYouMean = require('didyoumean2').default; +import { + basename, + join, + sep, + dirname, + relative, +} from 'path'; +import didYouMean from 'didyoumean2'; // default command modules path const CmdDir = 'src/commands'; +/** + * Commands / protocol manager- loads, validates and handles command execution + * @property {Array} commands - Array of currently loaded command modules + * @property {Array} categories - Array of command modules categories + * @author Marzavec ( https://github.com/marzavec ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) + */ class CommandManager { /** * Create a `CommandManager` instance for handling commands/protocol * * @param {Object} core Reference to the global core object */ - constructor (core) { + constructor(core) { + /** + * Stored reference to the core + * @type {CoreApp} + */ this.core = core; + + /** + * Command module storage + * @type {Array} + */ this.commands = []; + + /** + * Command module category names (based off directory or module meta) + * @type {Array} + */ this.categories = []; - if (!this.core.config.hasOwnProperty('logErrDetailed')) { + + /** + * Full path to config.json file + * @type {String} + */ + if (typeof this.core.config.logErrDetailed === 'undefined') { this.core.config.logErrDetailed = false; } } /** * (Re)initializes name spaces for commands and starts load routine - * + * @public * @return {String} Module errors or empty if none */ - loadCommands () { + loadCommands() { this.commands = []; this.categories = []; const commandImports = this.core.dynamicImports.getImport(CmdDir); let cmdErrors = ''; - Object.keys(commandImports).forEach(file => { - let command = commandImports[file]; - let name = path.basename(file); + Object.keys(commandImports).forEach((file) => { + const command = commandImports[file]; + const name = basename(file); cmdErrors += this.validateAndLoad(command, file, name); }); @@ -50,29 +74,28 @@ class CommandManager { /** * Checks the module after having been `require()`ed in and reports errors - * * @param {Object} command reference to the newly loaded object * @param {String} file file path to the module * @param {String} name command (`cmd`) name - * + * @private * @return {String} Module errors or empty if none */ - validateAndLoad (command, file, name) { - let error = this.validateCommand(command); + validateAndLoad(command, file, name) { + const error = this.validateCommand(command); if (error) { - let errText = `Failed to load '${name}': ${error}`; + const errText = `Failed to load command module '${name}': ${error}`; console.log(errText); return errText; } if (!command.category) { - let base = path.join(this.core.dynamicImports.base, 'commands'); + const base = join(this.core.dynamicImports.base, 'commands'); let category = 'Uncategorized'; - if (file.indexOf(path.sep) > -1) { - category = path.dirname(path.relative(base, file)) - .replace(new RegExp(path.sep.replace('\\', '\\\\'), 'g'), '/'); + if (file.indexOf(sep) > -1) { + category = dirname(relative(base, file)) + .replace(new RegExp(sep.replace('\\', '\\\\'), 'g'), '/'); } command.info.category = category; @@ -86,7 +109,7 @@ class CommandManager { try { command.init(this.core); } catch (err) { - let errText = `Failed to initialize '${name}': ${err}`; + const errText = `Failed to initialize '${name}': ${err}`; console.log(errText); return errText; } @@ -99,121 +122,109 @@ class CommandManager { /** * Checks the module after having been `require()`ed in and reports errors - * * @param {Object} object reference to the newly loaded object - * + * @private * @return {String} Module errors or null if none */ - validateCommand (object) { - if (typeof object !== 'object') - return 'command setup is invalid'; - - if (typeof object.run !== 'function') - return 'run function is missing'; - - if (typeof object.info !== 'object') - return 'info object is missing'; - - if (typeof object.info.name !== 'string') - return 'info object is missing a valid name field'; + validateCommand(object) { + if (typeof object !== 'object') { return 'command setup is invalid'; } + if (typeof object.run !== 'function') { return 'run function is missing'; } + if (typeof object.info !== 'object') { return 'info object is missing'; } + if (typeof object.info.name !== 'string') { return 'info object is missing a valid name field'; } return null; } /** * Pulls all command names from a passed `category` - * * @param {String} category [Optional] filter return results by this category - * + * @public * @return {Array} Array of command modules matching the category */ - all (category) { + all(category) { return !category ? this.commands : this.commands.filter( - c => c.info.category.toLowerCase() === category.toLowerCase() - ); + (c) => c.info.category.toLowerCase() === category.toLowerCase(), + ); } /** - * Pulls all category names - * - * @return {Array} Array of sub directories under CmdDir + * All category names + * @public + * @readonly + * @return {Array} Array of command category names */ - get categoriesList () { + get categoriesList() { return this.categories; } /** - * Pulls command by name or alia(s) - * + * Pulls command by name or alias * @param {String} name name or alias of command - * + * @public * @return {Object} Target command module object */ - get (name) { + get(name) { return this.findBy('name', name) || this.commands.find( - command => command.info.aliases instanceof Array && - command.info.aliases.indexOf(name) > -1 + (command) => command.info.aliases instanceof Array + && command.info.aliases.indexOf(name) > -1, ); } /** * Pulls command by arbitrary search of the `module.info` attribute - * * @param {String} key name or alias of command * @param {String} value name or alias of command - * + * @public * @return {Object} Target command module object */ - findBy (key, value) { - return this.commands.find(c => c.info[key] === value); + findBy(key, value) { + return this.commands.find((c) => c.info[key] === value); } /** * Runs `initHooks` function on any modules that utilize the event - * + * @private * @param {Object} server main server object */ - initCommandHooks (server) { - this.commands.filter(c => typeof c.initHooks !== 'undefined').forEach( - c => c.initHooks(server) - ); + initCommandHooks(server) { + this.commands.filter((c) => typeof c.initHooks !== 'undefined').forEach( + (c) => c.initHooks(server), + ); } /** * Finds and executes the requested command, or fails with semi-intelligent error - * * @param {Object} server main server reference * @param {Object} socket calling socket reference * @param {Object} data command structure passed by socket (client) - * + * @public * @return {*} Arbitrary module return data */ - handleCommand (server, socket, data) { + handleCommand(server, socket, data) { // Try to find command first - let command = this.get(data.cmd); + const command = this.get(data.cmd); if (command) { return this.execute(command, server, socket, data); - } else { - // Then fail with helpful (sorta) message - return this.handleFail(server, socket, data); } + + // Then fail with helpful (sorta) message + return this.handleFail(server, socket, data); } /** * Requested command failure handler, attempts to find command and reports back - * * @param {Object} server main server reference * @param {Object} socket calling socket reference * @param {Object} data command structure passed by socket (client) - * + * @private * @return {*} Arbitrary module return data */ - handleFail (server, socket, data) { - const maybe = didYouMean(data.cmd, this.all().map(c => c.info.name), { + handleFail(server, socket, data) { + const maybe = didYouMean(data.cmd, this.all().map((c) => c.info.name), { threshold: 5, - thresholdType: 'edit-distance' + thresholdType: 'edit-distance', }); if (maybe) { @@ -221,7 +232,7 @@ class CommandManager { return this.handleCommand(server, socket, { cmd: 'socketreply', cmdKey: server.cmdKey, - text: `Command not found, did you mean: \`${maybe}\`?` + text: `Command not found, did you mean: \`${maybe}\`?`, }); } @@ -229,39 +240,37 @@ class CommandManager { return this.handleCommand(server, socket, { cmd: 'socketreply', cmdKey: server.cmdKey, - text: 'Unknown command' + text: 'Unknown command', }); } /** * Attempt to execute the requested command, fail if err or bad params - * * @param {Object} command target command module * @param {Object} server main server reference * @param {Object} socket calling socket reference * @param {Object} data command structure passed by socket (client) - * + * @private * @return {*} Arbitrary module return data */ - async execute (command, server, socket, data) { + async execute(command, server, socket, data) { if (typeof command.requiredData !== 'undefined') { - let missing = []; - for (let i = 0, len = command.requiredData.length; i < len; i++) { - if (typeof data[command.requiredData[i]] === 'undefined') - missing.push(command.requiredData[i]); + const missing = []; + for (let i = 0, len = command.requiredData.length; i < len; i += 1) { + if (typeof data[command.requiredData[i]] === 'undefined') { missing.push(command.requiredData[i]); } } if (missing.length > 0) { console.log(`Failed to execute '${ - command.info.name - }': missing required ${missing.join(', ')}\n\n`); + command.info.name + }': missing required ${missing.join(', ')}\n\n`); this.handleCommand(server, socket, { cmd: 'socketreply', cmdKey: server.cmdKey, text: `Failed to execute '${ - command.info.name - }': missing required ${missing.join(', ')}\n\n` + command.info.name + }': missing required ${missing.join(', ')}\n\n`, }); return null; @@ -271,20 +280,20 @@ class CommandManager { try { return await command.run(this.core, server, socket, data); } catch (err) { - let errText = `Failed to execute '${command.info.name}': `; - + const errText = `Failed to execute '${command.info.name}': `; + // If we have more detail enabled, then we get the trace // if it isn't, or the property doesn't exist, then we'll get only the message if (this.core.config.logErrDetailed === true) { console.log(errText + err.stack); } else { - console.log(errText + err.toString()) + console.log(errText + err.toString()); } this.handleCommand(server, socket, { cmd: 'socketreply', cmdKey: server.cmdKey, - text: errText + err.toString() + text: errText + err.toString(), }); return null; @@ -292,4 +301,4 @@ class CommandManager { } } -module.exports = CommandManager; +export default CommandManager; diff --git a/server/src/serverLib/ConfigManager.js b/server/src/serverLib/ConfigManager.js index e29a0e7..bb414be 100644 --- a/server/src/serverLib/ConfigManager.js +++ b/server/src/serverLib/ConfigManager.js @@ -1,38 +1,47 @@ +import dateFormat from 'dateformat'; +import { + existsSync, + ensureFileSync, + readJsonSync, + copySync, + writeJSONSync, + removeSync, +} from 'fs-extra'; +import { resolve } from 'path'; + /** * Server configuration manager, handling loading, creation, parsing and saving * of the main config.json file - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * + * @property {String} base - Base path that all imports are required in from + * @author Marzavec ( https://github.com/marzavec ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) */ -const dateFormat = require('dateformat'); -const fse = require('fs-extra'); -const path = require('path'); - class ConfigManager { /** * Create a `ConfigManager` instance for managing application settings - * * @param {String} basePath executing directory name; __dirname */ - constructor (basePath = __dirname) { - this.configPath = path.resolve(basePath, 'config/config.json'); + constructor(basePath = __dirname) { + /** + * Full path to config.json file + * @type {String} + */ + this.configPath = resolve(basePath, 'config/config.json'); - if (!fse.existsSync(this.configPath)){ - fse.ensureFileSync(this.configPath); + if (!existsSync(this.configPath)) { + ensureFileSync(this.configPath); } } /** - * Loads config.json (main server config) into mem - * - * @return {Object || Boolean} False if the config.json could not be loaded + * Loads config.json (main server config) into memory + * @public + * @return {(JSON|Boolean)} False if the config.json could not be loaded */ - async load () { + async load() { try { - this.config = fse.readJsonSync(this.configPath); + this.config = readJsonSync(this.configPath); } catch (e) { return false; } @@ -42,12 +51,12 @@ class ConfigManager { /** * Creates backup of current config into configPath - * + * @private * @return {String} Backed up config.json path */ - async backup () { + backup() { const backupPath = `${this.configPath}.${dateFormat('dd-mm-yy-HH-MM-ss')}.bak`; - fse.copySync(this.configPath, backupPath); + copySync(this.configPath, backupPath); return backupPath; } @@ -55,18 +64,18 @@ class ConfigManager { /** * First makes a backup of the current `config.json`, then writes current config * to disk - * + * @public * @return {Boolean} False on failure */ - async save () { - const backupPath = await this.backup(); + save() { + const backupPath = this.backup(); try { - fse.writeJSONSync(this.configPath, this.config, { - // Indent with two spaces + writeJSONSync(this.configPath, this.config, { + // Indent with two spaces spaces: 2, }); - fse.removeSync(backupPath); + removeSync(backupPath); return true; } catch (err) { @@ -78,18 +87,17 @@ class ConfigManager { /** * Updates current config[`key`] with `value` then writes changes to disk - * * @param {*} key arbitrary configuration key * @param {*} value new value to change `key` to - * + * @public * @return {Boolean} False on failure */ - async set (key, value) { + set(key, value) { const realKey = `${key}`; this.config[realKey] = value; - return await this.save(); + return this.save(); } } -module.exports = ConfigManager; +export default ConfigManager; diff --git a/server/src/serverLib/CoreApp.js b/server/src/serverLib/CoreApp.js index 6bab090..8c2225d 100644 --- a/server/src/serverLib/CoreApp.js +++ b/server/src/serverLib/CoreApp.js @@ -1,30 +1,32 @@ -/** - * The core / global reference object - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * - */ - -const path = require('path'); -const { +import { join } from 'path'; +import { CommandManager, ConfigManager, ImportsManager, MainServer, - StatsManager -} = require('./'); + StatsManager, +} from '.'; +/** + * The core app builds all required classes and maintains a central + * reference point across the app + * @property {ConfigManager} configManager - Provides loading and saving of the server config + * @property {Object} config - The current json config object + * @property {ImportsManager} dynamicImports - Dynamic require interface allowing hot reloading + * @property {CommandManager} commands - Manages and executes command modules + * @property {StatsManager} stats - Stores and adjusts arbritary stat data + * @property {MainServer} server - Main websocket server reference + * @author Marzavec ( https://github.com/marzavec ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) + */ class CoreApp { /** - * Create the main core instance. - */ - constructor () { - - } - - async init () { + * Load config then initialize children + * @public + * @return {void} + */ + async init() { await this.buildConfigManager(); this.buildImportManager(); @@ -33,8 +35,14 @@ class CoreApp { this.buildMainServer(); } - async buildConfigManager () { - this.configManager = new ConfigManager(path.join(__dirname, '../..')); + /** + * Creates a new instance of the ConfigManager, loads and checks + * the server config + * @private + * @return {void} + */ + async buildConfigManager() { + this.configManager = new ConfigManager(join(__dirname, '../..')); this.config = await this.configManager.load(); if (this.config === false) { @@ -43,23 +51,43 @@ class CoreApp { } } - buildImportManager () { - this.dynamicImports = new ImportsManager(path.join(__dirname, '../..')); + /** + * Creates a new instance of the ImportsManager + * @private + * @return {void} + */ + buildImportManager() { + this.dynamicImports = new ImportsManager(join(__dirname, '../..')); } - buildCommandsManager () { + /** + * Creates a new instance of the CommandManager and loads the command modules + * @private + * @return {void} + */ + buildCommandsManager() { this.commands = new CommandManager(this); this.commands.loadCommands(); } - buildStatsManager () { + /** + * Creates a new instance of the StatsManager and sets the server start time + * @private + * @return {void} + */ + buildStatsManager() { this.stats = new StatsManager(this); this.stats.set('start-time', process.hrtime()); } - buildMainServer () { + /** + * Creates a new instance of the MainServer + * @private + * @return {void} + */ + buildMainServer() { this.server = new MainServer(this); } } -module.exports = CoreApp; +export { CoreApp }; diff --git a/server/src/serverLib/ImportsManager.js b/server/src/serverLib/ImportsManager.js index 8db7b68..ac1fc4c 100644 --- a/server/src/serverLib/ImportsManager.js +++ b/server/src/serverLib/ImportsManager.js @@ -1,69 +1,77 @@ +import { + resolve, + basename as _basename, + relative, +} from 'path'; +import RecursiveRead from 'readdir-recursive'; + /** * Import managment base, used to load commands/protocol and configuration objects - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * + * @property {String} base - Base path that all imports are required in from + * @author Marzavec ( https://github.com/marzavec ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) */ - -const read = require('readdir-recursive'); -const path = require('path'); - class ImportsManager { /** - * Create a `ImportsManager` instance for (re)loading classes and config - * + * Create an `ImportsManager` instance for (re)loading classes and config * @param {String} basePath executing directory name; default __dirname */ - constructor (basePath) { + constructor(basePath) { + /** + * Stored reference to the base directory path + * @type {String} + */ this.basePath = basePath; + + /** + * Data holder for imported modules + * @type {Object} + */ this.imports = {}; } /** * Pull base path that all imports are required in from - * + * @public * @type {String} readonly */ - get base () { + get base() { return this.basePath; } /** * Gather all js files from target directory, then verify and load - * - * @param {String} dirName The name of the dir to load, relative to the basePath. - * + * @param {String} dirName The name of the dir to load, relative to the basePath + * @private * @return {String} Load errors or empty if none */ - loadDir (dirName) { - const dir = path.resolve(this.basePath, dirName); + loadDir(dirName) { + const dir = resolve(this.basePath, dirName); let errorText = ''; try { - read.fileSync(dir).forEach(file => { - const basename = path.basename(file); + RecursiveRead.fileSync(dir).forEach((file) => { + const basename = _basename(file); if (basename.startsWith('_') || !basename.endsWith('.js')) return; let imported; try { imported = require(file); + + if (!this.imports[dirName]) { + this.imports[dirName] = {}; + } + + this.imports[dirName][file] = imported; } catch (e) { - let err = `Unable to load modules from ${dirName} (${path.relative(dir, file)})\n${e}`; + const err = `Unable to load modules from ${dirName} (${relative(dir, file)})\n${e}`; errorText += err; console.error(err); - return errorText; - } - - if (!this.imports[dirName]) { - this.imports[dirName] = {}; } - - this.imports[dirName][file] = imported; }); } catch (e) { - let err = `Unable to load modules from ${dirName}\n${e}`; + const err = `Unable to load modules from ${dirName}\n${e}`; errorText += err; console.error(err); return errorText; @@ -75,15 +83,13 @@ class ImportsManager { /** * Unlink references to each loaded module, pray to google that gc knows it's job, * then reinitialize this class to start the reload - * - * @param {Array} dirName The name of the dir to load, relative to the _base path. - * + * @public * @return {String} Load errors or empty if none */ - reloadDirCache () { + reloadDirCache() { let errorText = ''; - Object.keys(this.imports).forEach(dir => { + Object.keys(this.imports).forEach((dir) => { Object.keys(this.imports[dir]).forEach((mod) => { delete require.cache[require.resolve(mod)]; }); @@ -97,20 +103,19 @@ class ImportsManager { /** * Pull reference to imported modules that were imported from dirName, or * load required directory if not found - * * @param {String} dirName The name of the dir to load, relative to the _base path. - * + * @public * @return {Object} Object containing command module paths and structs */ - getImport (dirName) { - let imported = this.imports[dirName]; + getImport(dirName) { + const imported = this.imports[dirName]; if (!imported) { this.loadDir(dirName); } - return Object.assign({}, this.imports[dirName]); + return { ...this.imports[dirName] }; } } -module.exports = ImportsManager; +export default ImportsManager; diff --git a/server/src/serverLib/MainServer.js b/server/src/serverLib/MainServer.js index 8ef5129..35a0d3e 100644 --- a/server/src/serverLib/MainServer.js +++ b/server/src/serverLib/MainServer.js @@ -1,58 +1,96 @@ +import { + Server as WsServer, + OPEN as SocketReady, +} from 'ws'; +import { createHash } from 'crypto'; +import RateLimiter from './RateLimiter'; + +import { ServerConst } from '../utility/Constants'; + /** * Main websocket server handling communications and connection events - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * + * @property {RateLimiter} police - Main rate limit handler + * @property {String} cmdKey - Internal use command key + * @author Marzavec ( https://github.com/marzavec ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) */ - -const WsServer = require('ws').Server; -const SocketReady = require('ws').OPEN; -const Crypto = require('crypto'); -const RateLimiter = require('./RateLimiter'); -const PulseSpeed = 16000; // ping all clients every X ms -const IpSalt = [...Array(Math.floor(Math.random()*128)+128)].map(i=>(~~(Math.random()*36)).toString(36)).join(''); -const InternalCmdKey = [...Array(Math.floor(Math.random()*128)+128)].map(i=>(~~(Math.random()*36)).toString(36)).join(''); - class MainServer extends WsServer { /** - * Create a HackChat server instance. - * - * @param {Object} core Reference to the global core object - */ - constructor (core) { + * Create a HackChat server instance + * @param {CoreApp} core Reference to the global core object + */ + constructor(core) { super({ port: core.config.websocketPort }); + /** + * Stored reference to the core + * @type {CoreApp} + */ this.core = core; + + /** + * Command key used to verify internal commands + * @type {String} + */ + this.internalCmdKey = [...Array(Math.floor(Math.random() * 128) + 128)].map(() => (~~(Math.random() * 36)).toString(36)).join(''); + + /** + * Salt used to hash a clients ip + * @type {String} + */ + this.ipSalt = [...Array(Math.floor(Math.random() * 128) + 128)].map(() => (~~(Math.random() * 36)).toString(36)).join(''); + + /** + * Data store for command hooks + * @type {Object} + */ this.hooks = {}; + + /** + * Main rate limit tracker + * @type {RateLimiter} + */ this.police = new RateLimiter(); + + /** + * Black listed command names + * @type {Object} + */ this.cmdBlacklist = {}; + /** + * Stored info about the last server error + * @type {ErrorEvent} + */ + this.lastErr = null; + this.setupServer(); this.loadHooks(); } /** * Internal command key getter. Used to verify that internal only commands - * originate internally and not from a connected client. - * TODO: update to a structure that cannot be passed through json - * - * @type {String} readonly + * originate internally and not from a connected client + * @todo Update to a structure that cannot be passed through json + * @type {String} + * @public + * @readonly */ - get cmdKey () { - return InternalCmdKey; + get cmdKey() { + return this.internalCmdKey; } /** * Create ping interval and setup server event listeners - * + * @private + * @return {void} */ - setupServer () { - this.heartBeat = setInterval(() => this.beatHeart(), PulseSpeed); + setupServer() { + this.heartBeat = setInterval(() => this.beatHeart(), ServerConst.PulseSpeed); this.on('error', (err) => { - this.handleError('server', err); + this.handleError(err); }); this.on('connection', (socket, request) => { @@ -62,66 +100,71 @@ class MainServer extends WsServer { /** * Send empty `ping` frame to each client - * + * @private + * @return {void} */ - beatHeart () { - let targetSockets = this.findSockets({}); + beatHeart() { + const targetSockets = this.findSockets({}); if (targetSockets.length === 0) { return; } - for (let i = 0, l = targetSockets.length; i < l; i++) { + for (let i = 0, l = targetSockets.length; i < l; i += 1) { try { if (targetSockets[i].readyState === SocketReady) { targetSockets[i].ping(); } - } catch (e) { } + } catch (e) { /* yolo */ } } } /** * Bind listeners for the new socket created on connection to this class - * - * @param {Object} socket New socket object + * @param {ws#WebSocket} socket New socket object * @param {Object} request Initial headers of the new connection + * @private + * @return {void} */ - newConnection (socket, request) { - socket.remoteAddress = request.headers['x-forwarded-for'] || request.connection.remoteAddress; + newConnection(socket, request) { + const newSocket = socket; - socket.on('message', (data) => { + newSocket.address = request.headers['x-forwarded-for'] || request.connection.remoteAddress; + + newSocket.on('message', (data) => { this.handleData(socket, data); }); - socket.on('close', () => { + newSocket.on('close', () => { this.handleClose(socket); }); - socket.on('error', (err) => { - this.handleError(socket, err); + newSocket.on('error', (err) => { + this.handleError(err); }); } /** * Handle incoming messages from clients, parse and check command, then hand-off - * - * @param {Object} socket Calling socket object + * @param {ws#WebSocket} socket Calling socket object * @param {String} data Message sent from client + * @private + * @return {void} */ - handleData (socket, data) { + handleData(socket, data) { // Don't penalize yet, but check whether IP is rate-limited - if (this.police.frisk(socket.remoteAddress, 0)) { + if (this.police.frisk(socket.address, 0)) { this.core.commands.handleCommand(this, socket, { cmd: 'socketreply', cmdKey: this.cmdKey, - text: 'You are being rate-limited or blocked.' + text: 'You are being rate-limited or blocked.', }); return; } // Penalize here, but don't do anything about it - this.police.frisk(socket.remoteAddress, 1); + this.police.frisk(socket.address, 1); // Ignore ridiculously large packets if (data.length > 65536) { @@ -141,11 +184,11 @@ class MainServer extends WsServer { return; } - // TODO: make this more flexible - /* - * Issue #1: hard coded `cmd` check - * Issue #2: hard coded `cmd` value checks - */ + /** + * @todo make the following more flexible + * Issue #1: hard coded `cmd` check + * Issue #2: hard coded `cmd` value checks + */ if (typeof payload.cmd === 'undefined') { return; } @@ -161,7 +204,7 @@ class MainServer extends WsServer { if (typeof this.cmdBlacklist[payload.cmd] === 'function') { return; } - // End TODO // + // End @todo // // Execute `in` (incoming data) hooks and process results payload = this.executeHooks('in', socket, payload); @@ -171,11 +214,11 @@ class MainServer extends WsServer { this.core.commands.handleCommand(this, socket, { cmd: 'socketreply', cmdKey: this.cmdKey, - text: payload + text: payload, }); return; - } else if (payload === false) { + } if (payload === false) { // A hook requested this data be dropped return; } @@ -185,87 +228,107 @@ class MainServer extends WsServer { } /** - * Handle socket close from clients - * - * @param {Object} socket Closing socket object + * Pass socket close event to disconnection command module + * @param {ws#WebSocket} socket Closing socket object + * @private + * @return {void} */ - handleClose (socket) { + handleClose(socket) { this.core.commands.handleCommand(this, socket, { cmd: 'disconnect', - cmdKey: this.cmdKey + cmdKey: this.cmdKey, }); } /** * "Handle" server or socket errors - * - * @param {Object||String} socket Calling socket object, or 'server' - * @param {String} err The sad stuff + * @param {ErrorEvent} err The sad stuff + * @private + * @return {void} */ - handleError (socket, err) { + handleError(err) { + this.lastErr = err; console.log(`Server error: ${err}`); } /** * Send data payload to specific socket/client - * * @param {Object} payload Object to convert to json for transmission - * @param {Object} socket The target client + * @param {ws#WebSocket} socket The target client + * @example + * server.send({ + * cmd: 'info', + * text: 'Only targetSocket will see this' + * }, targetSocket); + * @public + * @return {void} */ - send (payload, socket) { + send(payload, socket) { + let outgoingPayload = payload; + // Add timestamp to command - payload.time = Date.now(); + outgoingPayload.time = Date.now(); // Execute `in` (incoming data) hooks and process results - payload = this.executeHooks('out', socket, payload); + outgoingPayload = this.executeHooks('out', socket, outgoingPayload); - if (typeof payload === 'string') { + if (typeof outgoingPayload === 'string') { // A hook malfunctioned, reply with error this.core.commands.handleCommand(this, socket, { cmd: 'socketreply', cmdKey: this.cmdKey, - text: payload + text: outgoingPayload, }); return; - } else if (payload === false) { + } if (outgoingPayload === false) { // A hook requested this data be dropped return; } try { if (socket.readyState === SocketReady) { - socket.send(JSON.stringify(payload)); + socket.send(JSON.stringify(outgoingPayload)); } - } catch (e) { } + } catch (e) { /* yolo */ } } /** * Overload function for `this.send()` - * * @param {Object} payload Object to convert to json for transmission - * @param {Object} socket The target client + * @param {ws#WebSocket} socket The target client + * @example + * server.reply({ + * cmd: 'info', + * text: 'Only targetSocket will see this' + * }, targetSocket); + * @public + * @return {void} */ - reply (payload, socket) { + reply(payload, socket) { this.send(payload, socket); } /** * Finds sockets/clients that meet the filter requirements, then passes the data to them - * * @param {Object} payload Object to convert to json for transmission * @param {Object} filter see `this.findSockets()` - * + * @example + * server.broadcast({ + * cmd: 'info', + * text: 'Everyone in "programming" will see this' + * }, { channel: 'programming' }); + * @public * @return {Boolean} False if no clients matched the filter, true if data sent */ - broadcast (payload, filter) { - let targetSockets = this.findSockets(filter); + broadcast(payload, filter) { + const targetSockets = this.findSockets(filter); if (targetSockets.length === 0) { return false; } - for (let i = 0, l = targetSockets.length; i < l; i++) { + for (let i = 0, l = targetSockets.length; i < l; i += 1) { this.send(payload, targetSockets[i]); } @@ -274,51 +337,54 @@ class MainServer extends WsServer { /** * Finds sockets/clients that meet the filter requirements, returns result as array - * * @param {Object} data Object to convert to json for transmission * @param {Object} filter The socket must of equal or greater attribs matching `filter` - * = {} // matches all - * = { channel: 'programming' } // matches any socket where (`socket.channel` === 'programming') - * = { channel: 'programming', nick: 'Marzavec' } // matches any socket where (`socket.channel` === 'programming' && `socket.nick` === 'Marzavec') - * + * @example + * // match all sockets: + * `filter` = {} + * // match any socket where socket.channel === 'programming' + * `filter` = { channel: 'programming' } + * // match any socket where + * // socket.channel === 'programming' && socket.nick === 'Marzavec' + * `filter` = { channel: 'programming', nick: 'Marzavec' } + * @public * @return {Array} Clients who matched the filter requirements */ - findSockets (filter) { - let filterAttribs = Object.keys(filter); - let reqCount = filterAttribs.length; + findSockets(filter) { + const filterAttribs = Object.keys(filter); + const reqCount = filterAttribs.length; let curMatch; - let matches = []; - for ( let socket of this.clients ) { + const matches = []; + this.clients.forEach((socket) => { + // for (const socket of this.clients) { curMatch = 0; - for (let i = 0; i < reqCount; i++) { + for (let i = 0; i < reqCount; i += 1) { if (typeof socket[filterAttribs[i]] !== 'undefined') { - switch(typeof filter[filterAttribs[i]]) { + switch (typeof filter[filterAttribs[i]]) { case 'object': { if (Array.isArray(filter[filterAttribs[i]])) { if (filter[filterAttribs[i]].indexOf(socket[filterAttribs[i]]) !== -1) { - curMatch++; - } - } else { - if (socket[filterAttribs[i]] === filter[filterAttribs[i]]) { - curMatch++; + curMatch += 1; } + } else if (socket[filterAttribs[i]] === filter[filterAttribs[i]]) { + curMatch += 1; } - break; + break; } case 'function': { if (filter[filterAttribs[i]](socket[filterAttribs[i]])) { - curMatch++; + curMatch += 1; } - break; + break; } default: { if (socket[filterAttribs[i]] === filter[filterAttribs[i]]) { - curMatch++; + curMatch += 1; } - break; + break; } } } @@ -327,7 +393,7 @@ class MainServer extends WsServer { if (curMatch === reqCount) { matches.push(socket); } - } + }); return matches; } @@ -335,18 +401,20 @@ class MainServer extends WsServer { /** * Hashes target socket's remote address using non-static variable length salt * encodes and shortens the output, returns that value - * - * @param {Object||String} target Either the target socket or ip as string - * + * @param {(ws#WebSocket|String)} target Either the target socket or ip as string + * @example + * let userHash = server.getSocketHash('1.2.3.4'); + * let userHash = server.getSocketHash(client); + * @public * @return {String} Hashed client connection string */ - getSocketHash (target) { - let sha = Crypto.createHash('sha256'); + getSocketHash(target) { + const sha = createHash('sha256'); if (typeof target === 'string') { - sha.update(target + IpSalt); + sha.update(target + this.ipSalt); } else { - sha.update(target.remoteAddress + IpSalt); + sha.update(target.address + this.ipSalt); } return sha.digest('base64').substr(0, 15); @@ -355,9 +423,10 @@ class MainServer extends WsServer { /** * (Re)loads all command module hooks, then sorts their order of operation by * priority, ascending (0 being highest priority) - * + * @public + * @return {void} */ - loadHooks () { + loadHooks() { // clear current hooks (if any) this.clearHooks(); // notify each module to register their hooks (if any) @@ -366,23 +435,23 @@ class MainServer extends WsServer { let curHooks = []; let hookObj = []; - if (typeof this.hooks['in'] !== 'undefined') { + if (typeof this.hooks.in !== 'undefined') { // start sorting, with incoming first - curHooks = [ ...this.hooks['in'].keys() ]; - for (let i = 0, j = curHooks.length; i < j; i++) { - hookObj = this.hooks['in'].get(curHooks[i]); - hookObj.sort( (h1, h2) => h1.priority - h2.priority ); - this.hooks['in'].set(hookObj); + curHooks = [...this.hooks.in.keys()]; + for (let i = 0, j = curHooks.length; i < j; i += 1) { + hookObj = this.hooks.in.get(curHooks[i]); + hookObj.sort((h1, h2) => h1.priority - h2.priority); + this.hooks.in.set(hookObj); } } - if (typeof this.hooks['out'] !== 'undefined') { + if (typeof this.hooks.out !== 'undefined') { // then outgoing - curHooks = [ ...this.hooks['out'].keys() ]; - for (let i = 0, j = curHooks.length; i < j; i++) { - hookObj = this.hooks['out'].get(curHooks[i]); - hookObj.sort( (h1, h2) => h1.priority - h2.priority ); - this.hooks['out'].set(hookObj); + curHooks = [...this.hooks.out.keys()]; + for (let i = 0, j = curHooks.length; i < j; i += 1) { + hookObj = this.hooks.out.get(curHooks[i]); + hookObj.sort((h1, h2) => h1.priority - h2.priority); + this.hooks.out.set(hookObj); } } } @@ -391,17 +460,19 @@ class MainServer extends WsServer { * Adds a target function to an array of hooks. Hooks are executed either before * processing user input (`in`) or before sending data back to the client (`out`) * and allows a module to modify each payload before moving forward - * * @param {String} type The type of event, typically `in` (incoming) or `out` (outgoing) * @param {String} command Should match the desired `cmd` attrib of the payload - * @param {Function} hookFunction Target function to execute, should accept `server`, `socket` and `payload` as parameters - * @param {Number} priority Execution priority, hooks with priority 1 will be executed before hooks with priority 200 for example + * @param {Function} hookFunction Target function to execute, should accept + * `server`, `socket` and `payload` as parameters + * @param {Number} priority Execution priority, hooks with priority 1 will be executed before + * hooks with priority 200 for example + * @example + * // Create hook to add "and stuff" to every chat line + * server.registerHook('in', 'chat', (server, socket, payload) => payload.text += ' and stuff'); + * @public + * @return {void} */ - registerHook (type, command, hookFunction, priority) { - if (typeof priority === 'undefined') { - priority = 25; - } - + registerHook(type, command, hookFunction, priority = 25) { if (typeof this.hooks[type] === 'undefined') { this.hooks[type] = new Map(); } @@ -412,7 +483,7 @@ class MainServer extends WsServer { this.hooks[type].get(command).push({ run: hookFunction, - priority: priority + priority, }); } @@ -422,25 +493,25 @@ class MainServer extends WsServer { * A payload (modified or not) that will continue through the data flow * A boolean false to indicate halting the data through flow * A string which indicates an error occured in executing the hook - * * @param {String} type The type of event, typically `in` (incoming) or `out` (outgoing) - * @param {Object} socket Either the target client or the client triggering the hook (depending on `type`) - * @param {Object} payload Either incoming data from client or outgoing data (depending on `type`) - * - * @return {Object || Boolean} + * @param {ws#WebSocket} socket Either target client or client (depends on `type`) + * @param {Object} payload Either incoming data from client or outgoing data (depends on `type`) + * @private + * @return {Object|Boolean} */ - executeHooks (type, socket, payload) { - let command = payload.cmd; + executeHooks(type, socket, payload) { + const command = payload.cmd; + let newPayload = payload; if (typeof this.hooks[type] !== 'undefined') { if (this.hooks[type].has(command)) { - let hooks = this.hooks[type].get(command); + const hooks = this.hooks[type].get(command); - for (let i = 0, j = hooks.length; i < j; i++) { + for (let i = 0, j = hooks.length; i < j; i += 1) { try { - payload = hooks[i].run(this.core, this, socket, payload); + newPayload = hooks[i].run(this.core, this, socket, newPayload); } catch (err) { - let errText = `Hook failure, '${type}', '${command}': `; + const errText = `Hook failure, '${type}', '${command}': `; if (this.core.config.logErrDetailed === true) { console.log(errText + err.stack); } else { @@ -450,23 +521,24 @@ class MainServer extends WsServer { } // A hook function may choose to return false to prevent all further processing - if (payload === false) { + if (newPayload === false) { return false; } } } } - return payload; + return newPayload; } /** * Wipe server hooks to make ready for module reload calls - * + * @public + * @return {void} */ - clearHooks () { + clearHooks() { this.hooks = {}; } } -module.exports = MainServer; +export default MainServer; diff --git a/server/src/serverLib/RateLimiter.js b/server/src/serverLib/RateLimiter.js index 43cf077..3f9bc27 100644 --- a/server/src/serverLib/RateLimiter.js +++ b/server/src/serverLib/RateLimiter.js @@ -1,39 +1,60 @@ +import { RateLimits } from '../utility/Constants'; + /** * Tracks frequency of occurances based on `id` (remote address), then allows or * denies command execution based on comparison with `threshold` - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * + * @property {Object} data - The current stats data + * @author Marzavec ( https://github.com/marzavec ) + * @author Andrew Belt ( https://github.com/AndrewBelt ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) */ - class RateLimiter { /** - * Create a ratelimiter instance. - */ - constructor () { + * Create a ratelimiter instance + */ + constructor() { + /** + * Data holder rate limit records + * @type {Object} + */ this.records = {}; - this.halflife = 30 * 1000; // milliseconds - this.threshold = 25; + + /** + * Time in milliseconds to decrement ratelimit weight + * @type {Number} + */ + this.halflife = RateLimits.halflife; + + /** + * Weight until ratelimited + * @type {Number} + */ + this.threshold = RateLimits.threshold; + + /** + * Stores the associated connection fingerprint with record id + * @type {Array} + */ this.hashes = []; } /** * Finds current score by `id` - * * @param {String} id target id / address - * + * @private * @return {Object} Object containing the record meta */ - search (id) { + search(id) { let record = this.records[id]; if (!record) { - record = this.records[id] = { + this.records[id] = { time: Date.now(), - score: 0 - } + score: 0, + }; + + record = this.records[id]; } return record; @@ -41,20 +62,22 @@ class RateLimiter { /** * Adjusts the current ratelimit score by `deltaScore` - * * @param {String} id target id / address * @param {Number} deltaScore amount to adjust current score by - * + * @example + * // Penalize by 1 and store if connection is ratelimited or not + * let isLimited = police.frisk(socket.address, 1); + * @public * @return {Boolean} True if record threshold has been exceeded */ - frisk (id, deltaScore) { - let record = this.search(id); + frisk(id, deltaScore) { + const record = this.search(id); if (record.arrested) { return true; } - record.score *= Math.pow(2, -(Date.now() - record.time ) / this.halflife); + record.score *= 2 ** -(Date.now() - record.time) / this.halflife; record.score += deltaScore; record.time = Date.now(); @@ -67,11 +90,16 @@ class RateLimiter { /** * Statically set server to no longer accept traffic from `id` - * * @param {String} id target id / address + * @example + * // Usage within a command module: + * let badClient = server.findSockets({ channel: socket.channel, nick: targetNick }); + * server.police.arrest(badClient[0].address, badClient[0].hash); + * @public + * @return {void} */ - arrest (id, hash) { - let record = this.search(id); + arrest(id, hash) { + const record = this.search(id); record.arrested = true; this.hashes[hash] = id; @@ -79,17 +107,32 @@ class RateLimiter { /** * Remove statically assigned limit from `id` - * * @param {String} id target id / address + * @example + * // Usage within a command module: + * server.police.pardon('targetHashOrIP'); + * @public + * @return {void} */ - pardon (id) { - if (typeof this.hashes[id] !== 'undefined') { - id = this.hashes[id]; + pardon(id) { + let targetId = id; + if (typeof this.hashes[targetId] !== 'undefined') { + targetId = this.hashes[targetId]; } - let record = this.search(id); + const record = this.search(targetId); record.arrested = false; } + + /** + * Clear all records + * @public + * @return {void} + */ + clear() { + this.records = {}; + this.hashes = []; + } } -module.exports = RateLimiter; +export default RateLimiter; diff --git a/server/src/serverLib/StatsManager.js b/server/src/serverLib/StatsManager.js index e472504..b6e4f97 100644 --- a/server/src/serverLib/StatsManager.js +++ b/server/src/serverLib/StatsManager.js @@ -1,60 +1,79 @@ /** * Simple generic stats collection script for events occurances (etc) - * - * Version: v2.0.0 - * Developer: Marzavec ( https://github.com/marzavec ) - * License: WTFPL ( http://www.wtfpl.net/txt/copying/ ) - * + * @property {Object} data - The current stats data + * @author Marzavec ( https://github.com/marzavec ) + * @version v2.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) */ - class StatsManager { /** - * Create a stats instance. - * + * Create a stats instance */ - constructor () { + constructor() { + /** + * Data holder for the stats class + * @type {Object} + */ this.data = {}; } /** * Retrieve value of arbitrary `key` reference - * * @param {String} key Reference to the arbitrary store name - * + * @example + * // Find previously set `start-time` + * stats.get('start-time'); + * @public * @return {*} Data referenced by `key` */ - get (key) { + get(key) { return this.data[key]; } /** * Set value of arbitrary `key` reference - * * @param {String} key Reference to the arbitrary store name * @param {Number} value New value for `key` + * @example + * // Set `start-time` + * stats.set('start-time', process.hrtime()); + * @public + * @return {void} */ - set (key, value) { + set(key, value) { this.data[key] = value; } /** * Increase value of arbitrary `key` reference, by 1 or `amount` - * * @param {String} key Reference to the arbitrary store name - * @param {Number} amount Value to increase `key` by, or 1 if omitted + * @param {?Number} [amount=1] Value to increase `key` by, or 1 if omitted + * @example + * // Increment by `amount` + * stats.increment('users', 6); + * // Increment by 1 + * stats.increment('users'); + * @public + * @return {void} */ - increment (key, amount) { - this.set(key, (this.get(key) || 0) + (amount || 1)); + increment(key, amount = 1) { + this.set(key, (this.get(key) || 0) + amount); } /** * Reduce value of arbitrary `key` reference, by 1 or `amount` - * * @param {String} key Reference to the arbitrary store name - * @param {Number} amount Value to decrease `key` by, or 1 if omitted + * @param {?Number} [amount=1] Value to decrease `key` by, or 1 if omitted + * @example + * // Decrement by `amount` + * stats.decrement('users', 6); + * // Decrement by 1 + * stats.decrement('users'); + * @public + * @return {void} */ - decrement (key, amount) { - this.set(key, (this.get(key) || 0) - (amount || 1)); + decrement(key, amount = 1) { + this.set(key, (this.get(key) || 0) - amount); } } diff --git a/server/src/serverLib/index.js b/server/src/serverLib/index.js index 4583de6..a820588 100644 --- a/server/src/serverLib/index.js +++ b/server/src/serverLib/index.js @@ -1,8 +1,6 @@ -module.exports = { - CommandManager: require('./CommandManager'), - ConfigManager: require('./ConfigManager'), - ImportsManager: require('./ImportsManager'), - MainServer: require('./MainServer'), - RateLimiter: require('./RateLimiter'), - StatsManager: require('./StatsManager') -}; +export const CommandManager = require('./CommandManager').default; +export const ConfigManager = require('./ConfigManager').default; +export const ImportsManager = require('./ImportsManager').default; +export const MainServer = require('./MainServer').default; +export const RateLimiter = require('./RateLimiter').default; +export const StatsManager = require('./StatsManager'); -- cgit v1.2.1