diff --git a/CHANGELOG.md b/CHANGELOG.md index a35183f..2dc9860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.1.0] - 2018-09-29 +### Added +- Module hook framework, isolating modules and making them truly drop-to-install +- `./server/src/commands/core/whisper.js` module to send in-channel private messages, `/whisper` hook +- `muzzle` and `mute` aliases to `./server/src/commands/mod/dumb.js` +- `unmuzzle` and `unmute` aliases to `./server/src/commands/mod/speak.js` +- `./server/src/commands/admin/removemod.js` module to remove mods +- `./server/src/commands/mod/unbanall.js` module to clear all bans and ratelimiting + +### Changed +- Further code cleanup on all modules +- Adjusted `ipSalt` entropy +- `./server/src/commands/core/help.js` output is now helpful, added `/help` hook +- `./server/src/commands/core/chat.js` added `/myhash` and `/me` hooks +- `./server/src/commands/core/morestats.js` added `/stats` hook + ## [2.0.3] - 2018-06-03 ### Added - `./server/src/commands/mod/dumb.js` module for server-wide shadow muting diff --git a/package-lock.json b/package-lock.json index 8ddaa40..c730b29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hack.chat-v2", - "version": "2.0.3", + "version": "2.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a5371cd..c55e5f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hack.chat-v2", - "version": "2.0.3", + "version": "2.1.0", "description": "a minimal distraction free chat application", "main": "index.js", "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index b18faf3..ef02881 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "hack.chat-v2", - "version": "2.0.3", + "version": "2.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/server/package.json b/server/package.json index 8094564..7973595 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "hack.chat-v2", - "version": "2.0.3", + "version": "2.1.0", "description": "a minimal distraction free chat application", "main": "main.js", "repository": { diff --git a/server/src/commands/admin/addmod.js b/server/src/commands/admin/addmod.js index bde54a1..c4fcdd5 100644 --- a/server/src/commands/admin/addmod.js +++ b/server/src/commands/admin/addmod.js @@ -2,23 +2,24 @@ Description: Adds the target trip to the mod list then elevates the uType */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin if (socket.uType != 'admin') { - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // add new trip to config core.config.mods.push({ trip: data.trip }); // purposely not using `config.set()` to avoid auto-save - // upgarde existing connections & notify user + // find targets current connections let newMod = server.findSockets({ trip: data.trip }); if (newMod.length !== 0) { for (let i = 0, l = newMod.length; i < l; i++) { + // upgrade privilages newMod[i].uType = 'mod'; + // inform new mod server.send({ cmd: 'info', text: 'You are now a mod.' @@ -29,20 +30,21 @@ exports.run = async (core, server, socket, data) => { // return success message server.reply({ cmd: 'info', - text: `Added mod trip: ${data.trip}` + text: `Added mod trip: ${data.trip}, remember to run 'saveconfig' to make it permanent` }, socket); // notify all mods server.broadcast({ cmd: 'info', - text: `Added mod trip: ${data.trip}` + text: `Added mod: ${data.trip}` }, { uType: 'mod' }); }; +// module meta exports.requiredData = ['trip']; - exports.info = { name: 'addmod', - usage: 'addmod {trip}', - description: 'Adds target trip to the config as a mod and upgrades the socket type' + description: 'Adds target trip to the config as a mod and upgrades the socket type', + usage: ` + API: { cmd: 'addmod', trip: '' }` }; diff --git a/server/src/commands/admin/listusers.js b/server/src/commands/admin/listusers.js index d3dddc2..85fc078 100644 --- a/server/src/commands/admin/listusers.js +++ b/server/src/commands/admin/listusers.js @@ -2,12 +2,11 @@ Description: Outputs all current channels and their user nicks */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin if (socket.uType != 'admin') { - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // find all users currently in a channel @@ -37,7 +36,10 @@ exports.run = async (core, server, socket, data) => { }, socket); }; +// module meta exports.info = { name: 'listusers', - description: 'Outputs all current channels and sockets in those channels' + description: 'Outputs all current channels and sockets in those channels', + usage: ` + API: { cmd: 'listusers' }` }; diff --git a/server/src/commands/admin/reload.js b/server/src/commands/admin/reload.js index 7a0ffdc..f7f7464 100644 --- a/server/src/commands/admin/reload.js +++ b/server/src/commands/admin/reload.js @@ -2,18 +2,21 @@ Description: Clears and resets the command modules, outputting any errors */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin if (socket.uType != 'admin') { - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // do command reloads and store results let loadResult = core.managers.dynamicImports.reloadDirCache('src/commands'); loadResult += core.commands.loadCommands(); + // clear and rebuild all module hooks + server.clearHooks(); + core.commands.initCommandHooks(server); + // build reply based on reload results if (loadResult == '') { loadResult = `Loaded ${core.commands._commands.length} commands, 0 errors`; @@ -34,7 +37,10 @@ exports.run = async (core, server, socket, data) => { }, { uType: 'mod' }); }; +// module meta exports.info = { name: 'reload', - description: '(Re)loads any new commands into memory, outputs errors if any' + description: '(Re)loads any new commands into memory, outputs errors if any', + usage: ` + API: { cmd: 'reload' }` }; diff --git a/server/src/commands/admin/removemod.js b/server/src/commands/admin/removemod.js new file mode 100644 index 0000000..dda822c --- /dev/null +++ b/server/src/commands/admin/removemod.js @@ -0,0 +1,50 @@ +/* + Description: Removes target trip from the config as a mod and downgrades the socket type +*/ + +// module main +exports.run = async (core, server, socket, data) => { + // increase rate limit chance and ignore if not admin + if (socket.uType != 'admin') { + return server._police.frisk(socket.remoteAddress, 20); + } + + // remove trip from config + core.config.mods = core.config.mods.filter(mod => mod.trip !== data.trip); + + // find targets current connections + let targetMod = server.findSockets({ trip: data.trip }); + if (newMod.length !== 0) { + for (let i = 0, l = newMod.length; i < l; i++) { + // downgrade privilages + targetMod[i].uType = 'user'; + + // inform ex-mod + server.send({ + cmd: 'info', + text: 'You are now a user.' + }, targetMod[i]); + } + } + + // return success message + server.reply({ + cmd: 'info', + text: `Removed mod trip: ${data.trip}, remember to run 'saveconfig' to make it permanent` + }, socket); + + // notify all mods + server.broadcast({ + cmd: 'info', + text: `Removed mod: ${data.trip}` + }, { uType: 'mod' }); +}; + +// module meta +exports.requiredData = ['trip']; +exports.info = { + name: 'removemod', + description: 'Removes target trip from the config as a mod and downgrades the socket type', + usage: ` + API: { cmd: 'removemod', trip: '' }` +}; diff --git a/server/src/commands/admin/saveconfig.js b/server/src/commands/admin/saveconfig.js index 20927e7..b8a769b 100644 --- a/server/src/commands/admin/saveconfig.js +++ b/server/src/commands/admin/saveconfig.js @@ -2,22 +2,19 @@ Description: Writes the current config to disk */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin if (socket.uType != 'admin') { - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // attempt save, notify of failure if (!core.managers.config.save()) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Failed to save config, check logs.' }, client); - - return; } // return success message @@ -33,7 +30,10 @@ exports.run = async (core, server, socket, data) => { }, { uType: 'mod' }); }; +// module meta exports.info = { name: 'saveconfig', - description: 'Writes the current config to disk' + description: 'Writes the current config to disk', + usage: ` + API: { cmd: 'saveconfig' }` }; diff --git a/server/src/commands/admin/shout.js b/server/src/commands/admin/shout.js index 80a6470..e9c69e0 100644 --- a/server/src/commands/admin/shout.js +++ b/server/src/commands/admin/shout.js @@ -2,12 +2,11 @@ Description: Emmits a server-wide message as `info` */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin if (socket.uType != 'admin') { - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // send text to all channels @@ -17,10 +16,11 @@ exports.run = async (core, server, socket, data) => { }, {}); }; +// module meta exports.requiredData = ['text']; - exports.info = { name: 'shout', - usage: 'shout {text}', - description: 'Displays passed text to every client connected' + description: 'Displays passed text to every client connected', + usage: ` + API: { cmd: 'shout', text: '' }` }; diff --git a/server/src/commands/core/changenick.js b/server/src/commands/core/changenick.js index 5a8b5c2..6cc967c 100644 --- a/server/src/commands/core/changenick.js +++ b/server/src/commands/core/changenick.js @@ -1,17 +1,17 @@ /* - Description: Generates a semi-unique channel name then broadcasts it to each client + Description: Allows calling client to change their current nickname */ +// module support functions const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick); +// module main exports.run = async (core, server, socket, data) => { if (server._police.frisk(socket.remoteAddress, 6)) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'You are changing nicknames too fast. Wait a moment before trying again.' }, socket); - - return; } // verify user data is string @@ -22,12 +22,10 @@ exports.run = async (core, server, socket, data) => { // make sure requested nickname meets standards let newNick = data.nick.trim(); if (!verifyNickname(newNick)) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Nickname must consist of up to 24 letters, numbers, and underscores' }, socket); - - return; } // prevent admin impersonation @@ -35,12 +33,10 @@ exports.run = async (core, server, socket, data) => { if (newNick.toLowerCase() == core.config.adminName.toLowerCase()) { server._police.frisk(socket.remoteAddress, 4); - server.reply({ + return server.reply({ cmd: 'warn', - text: 'Gtfo' + text: 'You are not the admin, liar!' }, socket); - - return; } // find any sockets that have the same nickname @@ -52,12 +48,10 @@ exports.run = async (core, server, socket, data) => { // return error if found if (userExists.length > 0) { // That nickname is already in that channel - server.reply({ + return server.reply({ cmd: 'warn', text: 'Nickname taken' }, socket); - - return; } // build join and leave notices @@ -65,6 +59,7 @@ exports.run = async (core, server, socket, data) => { cmd: 'onlineRemove', nick: socket.nick }; + let joinNotice = { cmd: 'onlineAdd', nick: newNick, @@ -86,10 +81,11 @@ exports.run = async (core, server, socket, data) => { socket.nick = newNick; }; +// module meta exports.requiredData = ['nick']; - exports.info = { name: 'changenick', - usage: 'changenick {nick}', - description: 'This will change your current connections nickname' + description: 'This will change your current connections nickname', + usage: ` + API: { cmd: 'changenick', nick: '' }` }; diff --git a/server/src/commands/core/chat.js b/server/src/commands/core/chat.js index 89d497e..70b9876 100644 --- a/server/src/commands/core/chat.js +++ b/server/src/commands/core/chat.js @@ -2,6 +2,7 @@ Description: Rebroadcasts any `text` to all clients in a `channel` */ +// module support functions const parseText = (text) => { // verifies user input is text if (typeof text !== 'string') { @@ -16,25 +17,23 @@ const parseText = (text) => { return text; }; +// module main exports.run = async (core, server, socket, data) => { // check user input let text = parseText(data.text); + if (!text) { // lets not send objects or empty text, yea? - server._police.frisk(socket.remoteAddress, 6); - - return; + return server._police.frisk(socket.remoteAddress, 13); } // check for spam let score = text.length / 83 / 4; if (server._police.frisk(socket.remoteAddress, score)) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.' }, socket); - - return; } // build chat payload @@ -54,26 +53,77 @@ exports.run = async (core, server, socket, data) => { payload.trip = socket.trip; } - // check if the users connection is muted - // TODO: Add a more contained way for modules to interact, event hooks or something? - if(core.muzzledHashes && core.muzzledHashes[socket.hash]){ - server.broadcast( payload, { channel: socket.channel, hash: socket.hash }); - if(core.muzzledHashes[socket.hash].allies){ - server.broadcast( payload, { channel: socket.channel, nick: core.muzzledHashes[socket.hash].allies }); - } - } else { - //else send it to everyone - server.broadcast( payload, { channel: socket.channel}); - } + // broadcast to channel peers + server.broadcast( payload, { channel: socket.channel}); // stats are fun core.managers.stats.increment('messages-sent'); }; -exports.requiredData = ['text']; +// module hook functions +exports.initHooks = (server) => { + server.registerHook('in', 'chat', this.commandCheckIn); + server.registerHook('out', 'chat', this.commandCheckOut); +}; +// checks for miscellaneous '/' based commands +exports.commandCheckIn = (core, server, socket, payload) => { + if (typeof payload.text !== 'string') { + return false; + } + + if (payload.text.startsWith('/myhash')) { + server.reply({ + cmd: 'info', + text: `Your hash: ${socket.hash}` + }, socket); + + return false; + } + + return payload; +}; + +// checks for miscellaneous '/' based commands +exports.commandCheckOut = (core, server, socket, payload) => { + if (!payload.text.startsWith('/')) { + return payload; + } + + if (payload.text.startsWith('//me ')) { + payload.text = payload.text.substr(1, payload.text.length); + + return payload; + } + + // TODO: make emotes their own module #lazydev + if (payload.text.startsWith('/me ')) { + let emote = payload.text.substr(4); + if (emote.trim() === '') { + emote = 'fails at life'; + } + + let newPayload = { + cmd: 'info', + type: 'emote', + text: `@${payload.nick} ${emote}` + }; + + return newPayload; + } + + return payload; +}; + +// module meta +exports.requiredData = ['text']; exports.info = { name: 'chat', - usage: 'chat {text}', - description: 'Broadcasts passed `text` field to the calling users channel' + description: 'Broadcasts passed `text` field to the calling users channel', + usage: ` + API: { cmd: 'chat', text: '' } + Text: Uuuuhm. Just kind type in that little box at the bottom and hit enter.\n + Bonus super secret hidden commands: + /me + /myhash` }; diff --git a/server/src/commands/core/help.js b/server/src/commands/core/help.js index 8fd02a1..60a7280 100644 --- a/server/src/commands/core/help.js +++ b/server/src/commands/core/help.js @@ -2,41 +2,50 @@ Description: Outputs the current command module list or command categories */ +// module support functions const stripIndents = require('common-tags').stripIndents; -exports.run = async (core, server, socket, data) => { - // TODO: this module needs to be clean up badly :( +// module main +exports.run = async (core, server, socket, payload) => { + // check for spam + if (server._police.frisk(socket.remoteAddress, 2)) { + return server.reply({ + cmd: 'warn', + text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.' + }, socket); + } - // verify passed arguments - let typeDt = typeof data.type; - let catDt = typeof data.category; - let cmdDt = typeof data.command; - if (typeDt !== 'undefined' && typeDt !== 'string' ) { - return; - } else if (catDt !== 'undefined' && catDt !== 'string' ) { - return; - } else if (cmdDt !== 'undefined' && cmdDt !== 'string' ) { + // verify user input + if (typeof payload.command !== 'undefined' && typeof payload.command !== 'string') { return; } - // set default reply - let reply = stripIndents`Help usage: - Show all categories -> { cmd: 'help', type: 'categories' } - Show all commands in category -> { cmd: 'help', category: '' } - Show specific command -> { cmd: 'help', command: '' }`; + let reply = ''; + if (typeof payload.command === 'undefined') { + reply = stripIndents`Listing all current commands. For specific help on certain commands, use either: + Text: /help + API: {cmd: 'help', command: ''}`; + reply += '\n\n-------------------------------------\n\n'; - // change reply based on query - if (typeDt !== 'undefined') { let categories = core.commands.categories().sort(); - reply = `Command Categories:\n${categories.map(c => `- ${c.replace('../src/commands/', '')}`).join('\n')}`; - } else if (catDt !== 'undefined') { - let catCommands = core.commands.all('../src/commands/' + data.category).sort((a, b) => a.info.name.localeCompare(b.info.name)); - reply = `${data.category} commands:\n${catCommands.map(c => `- ${c.info.name}`).join('\n')}`; - } else if (cmdDt !== 'undefined') { - let command = core.commands.get(data.command); - reply = stripIndents` - Usage: ${command.info.usage || command.info.name} - Description: ${command.info.description || '¯\_(ツ)_/¯'}`; + for (let i = 0, j = categories.length; i < j; i++) { + reply += `${categories[i].replace('../src/commands/', '').replace(/^\w/, c => c.toUpperCase())} Commands:\n`; + let catCommands = core.commands.all(categories[i]).sort((a, b) => a.info.name.localeCompare(b.info.name)); + reply += ` ${catCommands.map(c => `${c.info.name}`).join(', ')}\n\n`; + } + } else { + let command = core.commands.get(payload.command); + + if (typeof command === 'undefined') { + reply = 'Unknown command'; + } else { + reply = stripIndents`Name: ${command.info.name} + Aliases: ${typeof command.info.aliases !== 'undefined' ? command.info.aliases.join(', ') : 'None'} + Category: ${command.info.category.replace('../src/commands/', '').replace(/^\w/, c => c.toUpperCase())} + Required Parameters: ${command.requiredData || 'None'}\n + Description: ${command.info.description || '¯\_(ツ)_/¯'}\n + Usage: ${command.info.usage || command.info.name}`; + } } // output reply @@ -46,8 +55,36 @@ exports.run = async (core, server, socket, data) => { }, socket); }; +// module hook functions +exports.initHooks = (server) => { + server.registerHook('in', 'chat', this.helpCheck); +}; + +// hooks chat commands checking for /whisper +exports.helpCheck = (core, server, socket, payload) => { + if (typeof payload.text !== 'string') { + return false; + } + + if (payload.text.startsWith('/help')) { + let input = payload.text.substr(1, payload.text.length).split(' ', 2); + + this.run(core, server, socket, { + cmd: input[0], + command: input[1] + }); + + return false; + } + + return payload; +}; + +// module meta exports.info = { name: 'help', - usage: 'help ([ type:categories] | [category: | command: ])', - description: 'Outputs information about the servers current protocol' + description: 'Outputs information about the servers current protocol', + usage: ` + API: { cmd: 'help', command: '' } + Text: /help ` }; diff --git a/server/src/commands/core/invite.js b/server/src/commands/core/invite.js index d616193..f44779b 100644 --- a/server/src/commands/core/invite.js +++ b/server/src/commands/core/invite.js @@ -2,17 +2,17 @@ Description: Generates a semi-unique channel name then broadcasts it to each client */ +// module support functions const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick); +// module main exports.run = async (core, server, socket, data) => { // check for spam if (server._police.frisk(socket.remoteAddress, 2)) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'You are sending invites too fast. Wait a moment before trying again.' }, socket); - - return; } // verify user input @@ -34,16 +34,15 @@ exports.run = async (core, server, socket, data) => { invite: channel, text: `${socket.nick} invited you to ?${channel}` }; + let inviteSent = server.broadcast( payload, { channel: socket.channel, nick: data.nick }); // server indicates the user was not found if (!inviteSent) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Could not find user in channel' }, socket); - - return; } // reply with common channel @@ -56,10 +55,11 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.increment('invites-sent'); }; +// module meta exports.requiredData = ['nick']; - exports.info = { name: 'invite', - usage: 'invite {nick}', - description: 'Generates a unique (more or less) room name and passes it to two clients' + description: 'Generates a unique (more or less) room name and passes it to two clients', + usage: ` + API: { cmd: 'invite', nick: '' }` }; diff --git a/server/src/commands/core/join.js b/server/src/commands/core/join.js index 31458a2..31bc3c1 100644 --- a/server/src/commands/core/join.js +++ b/server/src/commands/core/join.js @@ -2,6 +2,7 @@ Description: Initial entry point, applies `channel` and `nick` to the calling socket */ +// module support functions const crypto = require('crypto'); const hash = (password) => { @@ -12,15 +13,54 @@ const hash = (password) => { const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick); +// exposed "login" function to allow hooks to verify user join events +// returns object containing user info or string if error +exports.parseNickname = (core, data) => { + let userInfo = { + nick: '', + uType: 'user', + trip: null, + }; + + // seperate nick from password + let nickArray = data.nick.split('#', 2); + userInfo.nick = nickArray[0].trim(); + + if (!verifyNickname(userInfo.nick)) { + // return error as string + return 'Nickname must consist of up to 24 letters, numbers, and underscores'; + } + + let password = nickArray[1]; + if (userInfo.nick.toLowerCase() == core.config.adminName.toLowerCase()) { + if (password !== core.config.adminPass) { + return 'You are not the admin, liar!'; + } else { + userInfo.uType = 'admin'; + userInfo.trip = 'Admin'; + } + } else if (password) { + userInfo.trip = hash(password + core.config.tripSalt); + } + + // TODO: disallow moderator impersonation + for (let mod of core.config.mods) { + if (userInfo.trip === mod.trip) { + userInfo.uType = 'mod'; + } + } + + return userInfo; +}; + +// module main exports.run = async (core, server, socket, data) => { // check for spam if (server._police.frisk(socket.remoteAddress, 3)) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'You are joining channels too fast. Wait a moment and try again.' }, socket); - - return; } // calling socket already in a channel @@ -39,75 +79,39 @@ exports.run = async (core, server, socket, data) => { return; } - // process nickname - let nick = data.nick; - let nickArray = nick.split('#', 2); - nick = nickArray[0].trim(); - - if (!verifyNickname(nick)) { - server.reply({ + let userInfo = this.parseNickname(core, data); + if (typeof userInfo === 'string') { + return server.reply({ cmd: 'warn', - text: 'Nickname must consist of up to 24 letters, numbers, and underscores' + text: userInfo }, socket); - - return; } // check if the nickname already exists in the channel let userExists = server.findSockets({ channel: data.channel, - nick: (targetNick) => targetNick.toLowerCase() === nick.toLowerCase() + nick: (targetNick) => targetNick.toLowerCase() === userInfo.nick.toLowerCase() }); if (userExists.length > 0) { // that nickname is already in that channel - server.reply({ + return server.reply({ cmd: 'warn', text: 'Nickname taken' }, socket); - - return; } - // TODO: should we check for mod status first to prevent overwriting of admin status somehow? Meh, w/e, cba. - let uType = 'user'; - let trip = null; - let password = nickArray[1]; - if (nick.toLowerCase() == core.config.adminName.toLowerCase()) { - if (password != core.config.adminPass) { - server._police.frisk(socket.remoteAddress, 4); - - server.reply({ - cmd: 'warn', - text: 'Gtfo' - }, socket); - - return; - } else { - uType = 'admin'; - trip = 'Admin'; - } - } else if (password) { - trip = hash(password + core.config.tripSalt); - } - - // TODO: disallow moderator impersonation - for (let mod of core.config.mods) { - if (trip === mod.trip) { - uType = 'mod'; - } - } + userInfo.userHash = server.getSocketHash(socket); // prepare to notify channel peers let newPeerList = server.findSockets({ channel: data.channel }); - let userHash = server.getSocketHash(socket); let nicks = []; let joinAnnouncement = { cmd: 'onlineAdd', - nick: nick, - trip: trip || 'null', - hash: userHash + nick: userInfo.nick, + trip: userInfo.trip || 'null', + hash: userInfo.userHash }; // send join announcement and prep online set @@ -117,11 +121,11 @@ exports.run = async (core, server, socket, data) => { } // store user info - socket.uType = uType; - socket.nick = nick; - socket.channel = channel; - socket.hash = userHash; - if (trip !== null) socket.trip = trip; + socket.uType = userInfo.uType; + socket.nick = userInfo.nick; + socket.channel = data.channel; + socket.hash = userInfo.userHash; + if (userInfo.trip !== null) socket.trip = userInfo.trip; nicks.push(socket.nick); @@ -135,10 +139,11 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.increment('users-joined'); }; +// module meta exports.requiredData = ['channel', 'nick']; - exports.info = { name: 'join', - usage: 'join {channel} {nick}', - description: 'Place calling socket into target channel with target nick & broadcast event to channel' + description: 'Place calling socket into target channel with target nick & broadcast event to channel', + usage: ` + API: { cmd: 'join', nick: '', channel: '' }` }; diff --git a/server/src/commands/core/morestats.js b/server/src/commands/core/morestats.js index b64d478..e8eed05 100644 --- a/server/src/commands/core/morestats.js +++ b/server/src/commands/core/morestats.js @@ -2,6 +2,7 @@ Description: Outputs more info than the legacy stats command */ +// module support functions const stripIndents = require('common-tags').stripIndents; const formatTime = (time) => { @@ -19,6 +20,7 @@ const formatTime = (time) => { return `${days.toFixed(0)}d ${hours.toFixed(0)}h ${minutes.toFixed(0)}m ${seconds.toFixed(0)}s`; }; +// module main exports.run = async (core, server, socket, data) => { // gather connection and channel count let ips = {}; @@ -54,7 +56,33 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.increment('stats-requested'); }; +// module hook functions +exports.initHooks = (server) => { + server.registerHook('in', 'chat', this.statsCheck); +}; + +// hooks chat commands checking for /stats +exports.statsCheck = (core, server, socket, payload) => { + if (typeof payload.text !== 'string') { + return false; + } + + if (payload.text.startsWith('/stats')) { + this.run(core, server, socket, { + cmd: 'morestats' + }); + + return false; + } + + return payload; +}; + +// module meta exports.info = { name: 'morestats', - description: 'Sends back current server stats to the calling client' + description: 'Sends back current server stats to the calling client', + usage: ` + API: { cmd: 'morestats' } + Text: /stats` }; diff --git a/server/src/commands/core/move.js b/server/src/commands/core/move.js index 284a38d..e85f481 100644 --- a/server/src/commands/core/move.js +++ b/server/src/commands/core/move.js @@ -2,15 +2,14 @@ Description: Changes the current channel of the calling socket */ +// module main exports.run = async (core, server, socket, data) => { // check for spam if (server._police.frisk(socket.remoteAddress, 6)) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'You are changing channels too fast. Wait a moment before trying again.' }, socket); - - return; } // check user input @@ -81,10 +80,11 @@ exports.run = async (core, server, socket, data) => { socket.channel = data.channel; }; +// module meta exports.requiredData = ['channel']; - exports.info = { name: 'move', - usage: 'move {channel}', - description: 'This will change the current channel to the new one provided' + description: 'This will change your current channel to the new one provided', + usage: ` + API: { cmd: 'move', channel: '' }` }; diff --git a/server/src/commands/core/ping.js b/server/src/commands/core/ping.js index cf1d8a4..1e710e5 100644 --- a/server/src/commands/core/ping.js +++ b/server/src/commands/core/ping.js @@ -2,10 +2,12 @@ Description: This module is only in place to supress error notices legacy sources may get */ +// module main exports.run = async (core, server, socket, data) => { return; }; +// module meta exports.info = { name: 'ping', description: 'This module is only in place to supress error notices legacy sources may get' diff --git a/server/src/commands/core/stats.js b/server/src/commands/core/stats.js index 74bd4a5..b271bb1 100644 --- a/server/src/commands/core/stats.js +++ b/server/src/commands/core/stats.js @@ -2,6 +2,7 @@ Description: Legacy stats output, kept for compatibility, outputs user and channel count */ +// module main exports.run = async (core, server, socket, data) => { // gather connection and channel count let ips = {}; @@ -29,7 +30,10 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.increment('stats-requested'); }; +// module meta exports.info = { name: 'stats', - description: 'Sends back legacy server stats to the calling client' + description: 'Sends back legacy server stats to the calling client', + usage: ` + API: { cmd: 'stats' }` }; diff --git a/server/src/commands/core/whisper.js b/server/src/commands/core/whisper.js new file mode 100644 index 0000000..2ad2df0 --- /dev/null +++ b/server/src/commands/core/whisper.js @@ -0,0 +1,110 @@ +/* + Description: Display text on targets screen that only they can see +*/ + +// module support functions +const verifyNickname = (nick) => /^[a-zA-Z0-9_]{1,24}$/.test(nick); + +const parseText = (text) => { + // verifies user input is text + if (typeof text !== 'string') { + return false; + } + + // strip newlines from beginning and end + text = text.replace(/^\s*\n|^\s+$|\n\s*$/g, ''); + // replace 3+ newlines with just 2 newlines + text = text.replace(/\n{3,}/g, "\n\n"); + + return text; +}; + +// module main +exports.run = async (core, server, socket, payload) => { + // check user input + let text = parseText(payload.text); + + if (!text) { + // lets not send objects or empty text, yea? + return server._police.frisk(socket.remoteAddress, 13); + } + + // check for spam + let score = text.length / 83 / 4; + if (server._police.frisk(socket.remoteAddress, score)) { + return server.reply({ + cmd: 'warn', + text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.' + }, socket); + } + + let targetNick = payload.nick; + if (!verifyNickname(targetNick)) { + return; + } + + // find target user + let targetClient = server.findSockets({ channel: socket.channel, nick: targetNick }); + + if (targetClient.length === 0) { + return server.reply({ + cmd: 'warn', + text: 'Could not find user in channel' + }, socket); + } + + targetClient = targetClient[0]; + + server.reply({ + cmd: 'info', + type: 'whisper', + from: socket.nick, + trip: socket.trip || 'null', + text: `${socket.nick} whispered: ${text}` + }, targetClient); + + server.reply({ + cmd: 'info', + type: 'whisper', + text: `You whispered to @${targetNick}: ${text}` + }, socket); +}; + +// module hook functions +exports.initHooks = (server) => { + server.registerHook('in', 'chat', this.whisperCheck); +}; + +// hooks chat commands checking for /whisper +exports.whisperCheck = (core, server, socket, payload) => { + if (typeof payload.text !== 'string') { + return false; + } + + if (payload.text.startsWith('/whisper')) { + let input = payload.text.split(' '); + let target = input[1].replace(/@/g, ''); + input.splice(0, 2); + let whisperText = input.join(' '); + + this.run(core, server, socket, { + cmd: 'whisper', + nick: target, + text: whisperText + }); + + return false; + } + + return payload; +}; + +// module meta +exports.requiredData = ['nick', 'text']; +exports.info = { + name: 'whisper', + description: 'Display text on targets screen that only they can see', + usage: ` + API: { cmd: 'whisper', nick: '', text: '' } + Text: /whisper ` +}; diff --git a/server/src/commands/internal/disconnect.js b/server/src/commands/internal/disconnect.js index 7b9b299..8656779 100644 --- a/server/src/commands/internal/disconnect.js +++ b/server/src/commands/internal/disconnect.js @@ -3,12 +3,11 @@ when a socket connection is closed or lost. */ +// module main exports.run = async (core, server, socket, data) => { if (data.cmdKey !== server._cmdKey) { // internal command attempt by client, increase rate limit chance and ignore - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // send leave notice to client peers @@ -23,8 +22,8 @@ exports.run = async (core, server, socket, data) => { socket.terminate(); }; +// module meta exports.requiredData = ['cmdKey']; - exports.info = { name: 'disconnect', usage: 'Internal Use Only', diff --git a/server/src/commands/internal/socketreply.js b/server/src/commands/internal/socketreply.js index e77d080..fa3b8cf 100644 --- a/server/src/commands/internal/socketreply.js +++ b/server/src/commands/internal/socketreply.js @@ -2,20 +2,19 @@ Description: Used to relay warnings to clients internally */ +// module main exports.run = async (core, server, socket, data) => { if (data.cmdKey !== server._cmdKey) { // internal command attempt by client, increase rate limit chance and ignore - server._police.frisk(socket.remoteAddress, 20); - - return; + return server._police.frisk(socket.remoteAddress, 20); } // send warning to target socket server.reply({ cmd: 'warn', text: data.text }, socket); }; +// module meta exports.requiredData = ['cmdKey', 'text']; - exports.info = { name: 'socketreply', usage: 'Internal Use Only', diff --git a/server/src/commands/mod/ban.js b/server/src/commands/mod/ban.js index 721ad27..8236136 100644 --- a/server/src/commands/mod/ban.js +++ b/server/src/commands/mod/ban.js @@ -2,12 +2,11 @@ Description: Adds the target socket's ip to the ratelimiter */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin or mod - if (socket.uType == 'user') { - server._police.frisk(socket.remoteAddress, 10); - - return; + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); } // check user input @@ -20,24 +19,20 @@ exports.run = async (core, server, socket, data) => { let badClient = server.findSockets({ channel: socket.channel, nick: targetNick }); if (badClient.length === 0) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Could not find user in channel' }, socket); - - return; } badClient = badClient[0]; // i guess banning mods or admins isn't the best idea? if (badClient.uType !== 'user') { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Cannot ban other mods, how rude' }, socket); - - return; } // commit arrest record @@ -64,10 +59,11 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.increment('users-banned'); }; +// module meta exports.requiredData = ['nick']; - exports.info = { name: 'ban', - usage: 'ban {nick}', - description: 'Disconnects the target nickname in the same channel as calling socket & adds to ratelimiter' + description: 'Disconnects the target nickname in the same channel as calling socket & adds to ratelimiter', + usage: ` + API: { cmd: 'ban', nick: '' }` }; diff --git a/server/src/commands/mod/dumb.js b/server/src/commands/mod/dumb.js index 675ecd6..ecb4e0d 100644 --- a/server/src/commands/mod/dumb.js +++ b/server/src/commands/mod/dumb.js @@ -1,18 +1,20 @@ /* - * Description: Make a user (spammer) dumb + * Description: Make a user (spammer) dumb (mute) * Author: simple */ +// module constructor exports.init = (core) => { + if (typeof core.muzzledHashes === 'undefined') { core.muzzledHashes = {}; -} + } +}; +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin or mod - if (socket.uType == 'user') { - server._police.frisk(socket.remoteAddress, 10); - - return; + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); } // check user input @@ -24,29 +26,25 @@ exports.run = async (core, server, socket, data) => { let badClient = server.findSockets({ channel: socket.channel, nick: data.nick }); if (badClient.length === 0) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Could not find user in channel' }, socket); - - return; } badClient = badClient[0]; // likely dont need this, muting mods and admins is fine if (badClient.uType !== 'user') { - server.reply({ + return server.reply({ cmd: 'warn', text: 'This trick wont work on mods and admin' }, socket); - - return; } // store hash in mute list let record = core.muzzledHashes[badClient.hash] = { - dumb:true + dumb: true } // store allies if needed @@ -59,12 +57,78 @@ exports.run = async (core, server, socket, data) => { cmd: 'info', text: `${socket.nick} muzzled ${data.nick} in ${socket.channel}, userhash: ${badClient.hash}` }, { uType: 'mod' }); -} +}; +// module hook functions +exports.initHooks = (server) => { + server.registerHook('in', 'chat', this.chatCheck); + server.registerHook('in', 'invite', this.inviteCheck); + // TODO: add whisper hook, need hook priorities todo finished first +}; + +// hook incoming chat commands, shadow-prevent chat if they are muzzled +exports.chatCheck = (core, server, socket, payload) => { + if (typeof payload.text !== 'string') { + return false; + } + + if(core.muzzledHashes[socket.hash]){ + // build fake chat payload + mutedPayload = { + cmd: 'chat', + nick: socket.nick, + text: payload.text + }; + + if (socket.trip) { + mutedPayload.trip = socket.trip; + } + + // broadcast to any duplicate connections in channel + server.broadcast( mutedPayload, { channel: socket.channel, hash: socket.hash }); + + // broadcast to allies, if any + if(core.muzzledHashes[socket.hash].allies){ + server.broadcast( mutedPayload, { channel: socket.channel, nick: core.muzzledHashes[socket.hash].allies }); + } + + // blanket "spam" protection, may expose the ratelimiting lines from `chat` and use that, TODO: one day #lazydev + server._police.frisk(socket.remoteAddress, 9); + + return false; + } + + return payload; +}; + +// shadow-prevent all invites from muzzled users +exports.inviteCheck = (core, server, socket, payload) => { + if (typeof payload.nick !== 'string') { + return false; + } + + if(core.muzzledHashes[socket.hash]){ + // generate common channel + let channel = Math.random().toString(36).substr(2, 8); + + // send fake reply + server.reply({ + cmd: 'info', + text: `You invited ${payload.nick} to ?${channel}` + }, socket); + + return false; + } + + return payload; +}; + +// module meta exports.requiredData = ['nick']; - exports.info = { name: 'dumb', - usage: 'dumb {nick} [allies...]', - description: 'Globally shadow mute a connection. Optional allies array will see muted messages.' + description: 'Globally shadow mute a connection. Optional allies array will see muted messages.', + usage: ` + API: { cmd: 'dumb', nick: '', allies: ['', ...] }` }; +exports.info.aliases = ['muzzle', 'mute']; diff --git a/server/src/commands/mod/kick.js b/server/src/commands/mod/kick.js index 75c0d40..0e8ee0a 100644 --- a/server/src/commands/mod/kick.js +++ b/server/src/commands/mod/kick.js @@ -2,12 +2,11 @@ Description: Forces a change on the target(s) socket's channel, then broadcasts event */ +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin or mod - if (socket.uType == 'user') { - server._police.frisk(socket.remoteAddress, 10); - - return; + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); } // check user input @@ -21,12 +20,10 @@ exports.run = async (core, server, socket, data) => { let badClients = server.findSockets({ channel: socket.channel, nick: data.nick }); if (badClients.length === 0) { - server.reply({ + return server.reply({ cmd: 'warn', text: 'Could not find user(s) in channel' }, socket); - - return; } // check if found targets are kickable, commit kick @@ -75,10 +72,11 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.increment('users-kicked', kicked.length); }; +// module meta exports.requiredData = ['nick']; - exports.info = { name: 'kick', - usage: 'kick {nick}', - description: 'Silently forces target client(s) into another channel. `nick` may be string or array of strings' + description: 'Silently forces target client(s) into another channel. `nick` may be string or array of strings', + usage: ` + API: { cmd: 'kick', nick: '' }` }; diff --git a/server/src/commands/mod/moveuser.js b/server/src/commands/mod/moveuser.js new file mode 100644 index 0000000..e4f6c22 --- /dev/null +++ b/server/src/commands/mod/moveuser.js @@ -0,0 +1,105 @@ +/* + Description: Removes the target socket from the current channel and forces a join event in another +*/ + +// module main +exports.run = async (core, server, socket, data) => { + // increase rate limit chance and ignore if not admin or mod + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); + } + + // check user input + if (typeof data.nick !== 'string' || typeof data.channel !== 'string') { + return; + } + + if (data.channel === socket.channel) { + // moving them into the same channel? y u do this? + return; + } + + let badClients = server.findSockets({ channel: socket.channel, nick: data.nick }); + + if (badClients.length === 0) { + return server.reply({ + cmd: 'warn', + text: 'Could not find user in channel' + }, socket); + } + + let badClient = badClients[0]; + + if (badClient.uType !== 'user') { + return server.reply({ + cmd: 'warn', + text: 'Cannot move other mods, how rude' + }, socket); + } + + const currentNick = badClient.nick.toLowerCase(); + let userExists = server.findSockets({ + channel: data.channel, + nick: (targetNick) => targetNick.toLowerCase() === currentNick + }); + + if (userExists.length > 0) { + // That nickname is already in that channel + return; + } + + let peerList = server.findSockets({ channel: socket.channel }); + + if (peerList.length > 1) { + for (let i = 0, l = peerList.length; i < l; i++) { + server.reply({ + cmd: 'onlineRemove', + nick: peerList[i].nick + }, badClient); + + if (badClient.nick !== peerList[i].nick){ + server.reply({ + cmd: 'onlineRemove', + nick: badClient.nick + }, peerList[i]); + } + } + } + + let newPeerList = server.findSockets({ channel: data.channel }); + let moveAnnouncement = { + cmd: 'onlineAdd', + nick: badClient.nick, + trip: badClient.trip || 'null', + hash: server.getSocketHash(badClient) + }; + let nicks = []; + + for (let i = 0, l = newPeerList.length; i < l; i++) { + server.reply(moveAnnouncement, newPeerList[i]); + nicks.push(newPeerList[i].nick); + } + + nicks.push(badClient.nick); + + server.reply({ + cmd: 'onlineSet', + nicks: nicks + }, badClient); + + badClient.channel = data.channel; + + server.broadcast( { + cmd: 'info', + text: `${badClient.nick} was moved into ?${data.channel}` + }, { channel: data.channel }); +}; + +// module meta +exports.requiredData = ['nick', 'channel']; +exports.info = { + name: 'moveuser', + description: 'This will move the target user nick into another channel', + usage: ` + API: { cmd: 'moveuser', nick: '', channel: '' }` +}; diff --git a/server/src/commands/mod/speak.js b/server/src/commands/mod/speak.js index 454ca94..e2a3ef7 100644 --- a/server/src/commands/mod/speak.js +++ b/server/src/commands/mod/speak.js @@ -3,22 +3,26 @@ * Author: simple */ + // module constructor + exports.init = (core) => { + if (typeof core.muzzledHashes === 'undefined') { + core.muzzledHashes = {}; + } + }; + +// module main exports.run = async (core, server, socket, data) => { // increase rate limit chance and ignore if not admin or mod - if (socket.uType == 'user') { - server._police.frisk(socket.remoteAddress, 10); - - return; + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); } // check user input if (typeof data.ip !== 'string' && typeof data.hash !== 'string') { - server.reply({ + return server.reply({ cmd: 'warn', text: "hash:'targethash' or ip:'1.2.3.4' is required" }, socket); - - return; } // find target & remove mute status @@ -36,10 +40,13 @@ exports.run = async (core, server, socket, data) => { cmd: 'info', text: `${socket.nick} unmuzzled : ${target}` }, { uType: 'mod' }); -} +}; +// module meta exports.info = { name: 'speak', - usage: 'speak {[ip || hash]}', - description: 'Pardon a dumb user to be able to speak again' + description: 'Pardon a dumb user to be able to speak again', + usage: ` + API: { cmd: 'speak', ip/hash: ' { // increase rate limit chance and ignore if not admin or mod - if (socket.uType == 'user') { - server._police.frisk(socket.remoteAddress, 10); - - return; + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); } // check user input if (typeof data.ip !== 'string' && typeof data.hash !== 'string') { - server.reply({ + return server.reply({ cmd: 'warn', text: "hash:'targethash' or ip:'1.2.3.4' is required" }, socket); - - return; } // find target @@ -55,8 +52,10 @@ exports.run = async (core, server, socket, data) => { core.managers.stats.decrement('users-banned'); }; +// module meta exports.info = { name: 'unban', - usage: 'unban {[ip || hash]}', - description: 'Removes target ip from the ratelimiter' + description: 'Removes target ip from the ratelimiter', + usage: ` + API: { cmd: 'unban', ip/hash: '' }` }; diff --git a/server/src/commands/mod/unbanall.js b/server/src/commands/mod/unbanall.js new file mode 100644 index 0000000..a9cf682 --- /dev/null +++ b/server/src/commands/mod/unbanall.js @@ -0,0 +1,36 @@ +/* + Description: Clears all bans and ratelimits +*/ + +// module main +exports.run = async (core, server, socket, data) => { + // increase rate limit chance and ignore if not admin or mod + if (socket.uType === 'user') { + return server._police.frisk(socket.remoteAddress, 10); + } + + // remove arrest records + server._police._records = {}; + + console.log(`${socket.nick} [${socket.trip}] unbanned all`); + + // reply with success + server.reply({ + cmd: 'info', + text: `Unbanned all ip addresses` + }, socket); + + // notify mods + server.broadcast({ + cmd: 'info', + text: `${socket.nick} unbanned all ip addresses` + }, { uType: 'mod' }); +}; + +// module meta +exports.info = { + name: 'unbanall', + description: 'Clears all banned ip addresses', + usage: ` + API: { cmd: 'unbanall' }` +}; diff --git a/server/src/core/server.js b/server/src/core/server.js index ded7cac..08ddad7 100644 --- a/server/src/core/server.js +++ b/server/src/core/server.js @@ -10,8 +10,8 @@ const wsServer = require('ws').Server; const socketReady = require('ws').OPEN; const crypto = require('crypto'); -const ipSalt = (Math.random().toString(36).substring(2, 16) + Math.random().toString(36).substring(2, (Math.random() * 16))).repeat(16); -const internalCmdKey = (Math.random().toString(36).substring(2, 16) + Math.random().toString(36).substring(2, (Math.random() * 16))).repeat(16); +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(''); const Police = require('./rateLimiter'); const pulseSpeed = 16000; // ping all clients every X ms @@ -25,6 +25,7 @@ class server extends wsServer { super({ port: core.config.websocketPort }); this._core = core; + this._hooks = {}; this._police = new Police(); this._cmdBlacklist = {}; this._cmdKey = internalCmdKey; @@ -40,6 +41,8 @@ class server extends wsServer { this.on('connection', (socket, request) => { this.newConnection(socket, request); }); + + this._core.commands.initCommandHooks(this); } /** @@ -105,42 +108,59 @@ class server extends wsServer { // Penalize here, but don't do anything about it this._police.frisk(socket.remoteAddress, 1); - // ignore ridiculously large packets + // Ignore ridiculously large packets if (data.length > 65536) { return; } // Start sent data verification - var args = null; + var payload = null; try { - args = JSON.parse(data); + payload = JSON.parse(data); } catch (e) { // Client sent malformed json, gtfo socket.close(); } - if (args === null) { + if (payload === null) { return; } - if (typeof args.cmd === 'undefined') { + if (typeof payload.cmd === 'undefined') { return; } - if (typeof args.cmd !== 'string') { + if (typeof payload.cmd !== 'string') { return; } - if (typeof socket.channel === 'undefined' && args.cmd !== 'join') { + if (typeof socket.channel === 'undefined' && (payload.cmd !== 'join' && payload.cmd !== 'chat')) { return; } - if (typeof this._cmdBlacklist[args.cmd] === 'function') { + if (typeof this._cmdBlacklist[payload.cmd] === 'function') { return; } - // Finished verification, pass to command modules - this._core.commands.handleCommand(this, socket, args); + // Execute `in` (incoming data) hooks and process results + payload = this.executeHooks('in', socket, payload); + + if (typeof payload === 'string') { + // A hook malfunctioned, reply with error + this._core.commands.handleCommand(this, socket, { + cmd: 'socketreply', + cmdKey: this._cmdKey, + text: payload + }); + + return; + } else if (payload === false) { + // A hook requested this data be dropped + return; + } + + // Finished verification & hooks, pass to command modules + this._core.commands.handleCommand(this, socket, payload); } /** @@ -168,16 +188,33 @@ class server extends wsServer { /** * Send data payload to specific socket/client * - * @param {Object} data Object to convert to json for transmission + * @param {Object} payload Object to convert to json for transmission * @param {Object} socket The target client */ - send (data, socket) { + send (payload, socket) { // Add timestamp to command - data.time = Date.now(); + payload.time = Date.now(); + + // Execute `in` (incoming data) hooks and process results + payload = this.executeHooks('out', socket, payload); + + if (typeof payload === 'string') { + // A hook malfunctioned, reply with error + this._core.commands.handleCommand(this, socket, { + cmd: 'socketreply', + cmdKey: this._cmdKey, + text: payload + }); + + return; + } else if (payload === false) { + // A hook requested this data be dropped + return; + } try { if (socket.readyState === socketReady) { - socket.send(JSON.stringify(data)); + socket.send(JSON.stringify(payload)); } } catch (e) { } } @@ -185,20 +222,20 @@ class server extends wsServer { /** * Overload function for `this.send()` * - * @param {Object} data Object to convert to json for transmission + * @param {Object} payload Object to convert to json for transmission * @param {Object} socket The target client */ - reply (data, socket) { - this.send(data, socket); + reply (payload, socket) { + this.send(payload, socket); } /** * Finds sockets/clients that meet the filter requirements, then passes the data to them * - * @param {Object} data Object to convert to json for transmission + * @param {Object} payload Object to convert to json for transmission * @param {Object} filter see `this.findSockets()` */ - broadcast (data, filter) { + broadcast (payload, filter) { let targetSockets = this.findSockets(filter); if (targetSockets.length === 0) { @@ -206,7 +243,7 @@ class server extends wsServer { } for (let i = 0, l = targetSockets.length; i < l; i++) { - this.send(data, targetSockets[i]); + this.send(payload, targetSockets[i]); } return true; @@ -271,7 +308,7 @@ class server extends wsServer { } /** - * Encrypts target socket's remote address using non-static variable length salt + * 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 @@ -287,6 +324,73 @@ class server extends wsServer { return sha.digest('base64').substr(0, 15); } + + /** + * 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 + */ + // TODO: add hook priority levels + registerHook (type, command, hookFunction) { + if (typeof this._hooks[type] === 'undefined') { + this._hooks[type] = new Map(); + } + + if (!this._hooks[type].has(command)) { + this._hooks[type].set(command, []); + } + + this._hooks[type].get(command).push(hookFunction); + } + + /** + * Loops through registered hooks & processes the results. Returned data will + * be one of three possiblities: + * 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`) + */ + executeHooks (type, socket, payload) { + let command = payload.cmd; + + if (typeof this._hooks[type] !== 'undefined') { + if (this._hooks[type].has(command)) { + let hooks = this._hooks[type].get(command); + + for (let i = 0, j = hooks.length; i < j; i++) { + try { + payload = hooks[i](this._core, this, socket, payload); + } catch (err) { + let errText = `Hook failure, '${type}', '${command}': ${err}`; + console.log(errText); + return errText; + } + + // A payload may choose to return false to prevent all further processing + if (payload === false) { + return false; + } + } + } + } + + return payload; + } + + /** + * Wipe server hooks to make ready for module reload calls + */ + clearHooks () { + this._hooks = {}; + } } module.exports = server; diff --git a/server/src/managers/commands.js b/server/src/managers/commands.js index fd743fb..a5ef464 100644 --- a/server/src/managers/commands.js +++ b/server/src/managers/commands.js @@ -71,8 +71,9 @@ class CommandManager { command.info.category = category; - if (this._categories.indexOf(category) === -1) + if (this._categories.indexOf(category) === -1) { this._categories.push(category); + } } if (typeof command.init === 'function') { @@ -114,7 +115,7 @@ class CommandManager { /** * Pulls all command names from a passed `category` * - * @param {String} category reference to the newly loaded object + * @param {String} category [Optional] filter return results by this category */ all (category) { return !category ? this._commands : this._commands.filter(c => c.info.category.toLowerCase() === category.toLowerCase()); @@ -148,6 +149,15 @@ class CommandManager { return this._commands.find(c => c.info[key] === value); } + /** + * Runs `initHooks` function on any modules that utilize the event + * + * @param {Object} server main server object + */ + 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 *