diff --git a/server/src/commands/admin/saveconfig.js b/server/src/commands/admin/saveconfig.js index f13bdee..bc01270 100644 --- a/server/src/commands/admin/saveconfig.js +++ b/server/src/commands/admin/saveconfig.js @@ -14,7 +14,7 @@ export async function run({ core, server, socket }) { // attempt save, notify of failure if (!core.configManager.save()) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Failed to save config, check logs.', }, socket); } diff --git a/server/src/commands/core/changenick.js b/server/src/commands/core/changenick.js index a081a1e..e520aba 100644 --- a/server/src/commands/core/changenick.js +++ b/server/src/commands/core/changenick.js @@ -12,7 +12,7 @@ export async function run({ }) { if (server.police.frisk(socket.address, 6)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are changing nicknames too fast. Wait a moment before trying again.', }, socket); } @@ -28,7 +28,7 @@ export async function run({ const newNick = payload.nick.trim(); if (!UAC.verifyNickname(newNick)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Nickname must consist of up to 24 letters, numbers, and underscores', }, socket); } @@ -39,14 +39,14 @@ export async function run({ server.police.frisk(socket.address, 4); return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are not the admin, liar!', }, socket); } if (newNick == previousNick) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You already have that name', }, socket); } @@ -63,7 +63,7 @@ export async function run({ if (userExists.length > 0) { // That nickname is already in that channel return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Nickname taken', }, socket); } @@ -118,7 +118,7 @@ export function nickCheck({ // If there is no nickname target parameter if (input[1] === undefined) { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Refer to `/help nick` for instructions on how to use this command.', }, socket); diff --git a/server/src/commands/core/chat.js b/server/src/commands/core/chat.js index 2c3d6e3..f8998a5 100644 --- a/server/src/commands/core/chat.js +++ b/server/src/commands/core/chat.js @@ -37,7 +37,7 @@ export async function run({ const score = text.length / 83 / 4; if (server.police.frisk(socket.address, score)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.', }, socket); } @@ -46,6 +46,7 @@ export async function run({ const outgoingPayload = { cmd: 'chat', nick: socket.nick, /* @legacy */ + uType: socket.uType, /* @legacy */ userid: socket.userid, channel: socket.channel, text, @@ -111,7 +112,7 @@ export function finalCmdCheck({ server, socket, payload }) { } server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: `Unknown command: ${payload.text}`, }, socket); diff --git a/server/src/commands/core/emote.js b/server/src/commands/core/emote.js index b21d7b6..1939e6f 100644 --- a/server/src/commands/core/emote.js +++ b/server/src/commands/core/emote.js @@ -33,7 +33,7 @@ export async function run({ server, socket, payload }) { const score = text.length / 83 / 4; if (server.police.frisk(socket.address, score)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.', }, socket); } @@ -78,7 +78,7 @@ export function emoteCheck({ // If there is no emote target parameter if (input[1] === undefined) { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Refer to `/help emote` for instructions on how to use this command.', }, socket); diff --git a/server/src/commands/core/help.js b/server/src/commands/core/help.js index 56069c5..faebd14 100644 --- a/server/src/commands/core/help.js +++ b/server/src/commands/core/help.js @@ -9,7 +9,7 @@ export async function run({ // check for spam if (server.police.frisk(socket.address, 2)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.', }, socket); } diff --git a/server/src/commands/core/invite.js b/server/src/commands/core/invite.js index 7136892..14fcb74 100644 --- a/server/src/commands/core/invite.js +++ b/server/src/commands/core/invite.js @@ -2,6 +2,17 @@ Description: Generates a semi-unique channel name then broadcasts it to each client */ +import { + findUser, +} from '../utility/_Channels'; +import { + Errors, +} from '../utility/_Constants'; +import { + legacyInviteOut, + legacyInviteReply, +} from '../utility/_LegacyFunctions'; + // module support functions /** * Returns the channel that should be invited to. @@ -22,34 +33,35 @@ export async function run({ // check for spam if (server.police.frisk(socket.address, 2)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'You are sending invites too fast. Wait a moment before trying again.', + id: Errors.Invite.RATELIMIT, }, socket); } // verify user input - if (typeof payload.userid !== 'number' || typeof payload.channel !== 'string') { - return true; - } + // if this is a legacy client add missing params to payload + if (socket.hcProtocol === 1) { + if (typeof socket.channel === 'undefined' || typeof payload.nick !== 'string') { + return true; + } - // why would you invite yourself? - if (payload.userid === socket.userid) { + payload.channel = socket.channel; // eslint-disable-line no-param-reassign + } else if (typeof payload.userid !== 'number' || typeof payload.channel !== 'string') { return true; } // @todo Verify this socket is part of payload.channel - multichannel patch // find target user - let targetClient = server.findSockets({ channel: payload.channel, userid: payload.userid }); - - if (targetClient.length === 0) { + const targetUser = findUser(server, payload); + if (!targetUser) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'Could not find user in that channel', + id: Errors.Global.UNKNOWN_USER, }, socket); } - [targetClient] = targetClient; - // generate common channel const channel = getChannel(payload.to); @@ -58,15 +70,23 @@ export async function run({ cmd: 'invite', channel: socket.channel, from: socket.userid, - to: payload.userid, + to: targetUser.userid, inviteChannel: channel, }; // send invite notice to target client - server.reply(outgoingPayload, targetClient); + if (targetUser.hcProtocol === 1) { + server.reply(legacyInviteOut(outgoingPayload, socket.nick), targetUser); + } else { + server.reply(outgoingPayload, targetUser); + } // send invite notice to this client - server.reply(outgoingPayload, socket); + if (socket.hcProtocol === 1) { + server.reply(legacyInviteReply(outgoingPayload, targetUser.nick), socket); + } else { + server.reply(outgoingPayload, socket); + } // stats are fun core.stats.increment('invites-sent'); diff --git a/server/src/commands/core/join.js b/server/src/commands/core/join.js index 3ee6722..037eb6c 100644 --- a/server/src/commands/core/join.js +++ b/server/src/commands/core/join.js @@ -1,139 +1,118 @@ /* eslint no-param-reassign: 0 */ /* - Description: Initial entry point, applies `channel` and `nick` to the calling socket + Description: Adds requested channel into the calling clients "subscribed channels" */ -import * as UAC from '../utility/UAC/_info'; - -// module support functions -const crypto = require('crypto'); - -const hash = (password) => { - const sha = crypto.createHash('sha256'); - sha.update(password); - return sha.digest('base64').substr(0, 6); -}; - -// exposed "login" function to allow hooks to verify user join events -// returns object containing user info or string if error -export function parseNickname(core, data) { - const userInfo = { - nick: data.nick, - uType: 'user', /* @legacy */ - trip: null, - level: UAC.levels.default, - }; - - if (!UAC.verifyNickname(userInfo.nick)) { - // return error as string - // @todo Remove english and change to numeric id - return 'Nickname must consist of up to 24 letters, numbers, and underscores'; - } - - const password = data.pass || false; - - if (hash(password + core.config.tripSalt) === core.config.adminTrip) { - userInfo.uType = 'admin'; /* @legacy */ - userInfo.trip = 'Admin'; - userInfo.level = UAC.levels.admin; - } else if (userInfo.nick.toLowerCase() === core.config.adminName.toLowerCase()) { - // they've got the main-admin name while not being an admin - // @todo Remove english and change to numeric id - return 'You are not the admin, liar!'; - } else if (password) { - userInfo.trip = hash(password + core.config.tripSalt); - } - - // @todo disallow moderator impersonation - // for (const mod of core.config.mods) { - core.config.mods.forEach((mod) => { - if (userInfo.trip === mod.trip) { - userInfo.uType = 'mod'; /* @legacy */ - userInfo.level = UAC.levels.moderator; - } - }); - - return userInfo; -} +// import * as UAC from '../utility/UAC/_info'; +import { + canJoinChannel, +} from '../utility/_Channels'; +import { + Errors, +} from '../utility/_Constants'; +import { + upgradeLegacyJoin, + legacyLevelToLabel, +} from '../utility/_LegacyFunctions'; +import { + verifyNickname, + getUserPerms, +} from '../utility/_UAC'; // module main export async function run({ core, server, socket, payload, -}) { - // check for spam +}) { // check for spam if (server.police.frisk(socket.address, 3)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'You are joining channels too fast. Wait a moment and try again.', + id: Errors.Join.RATELIMIT, + }, socket); + } + + // `join` is the legacy entry point, check if it needs to be upgraded + if (typeof socket.hcProtocol === 'undefined') { + payload = upgradeLegacyJoin(server, socket, payload); + } + + // store payload values + const { channel, nick, pass } = payload; + + // check if a client is able to join target channel + const mayJoin = canJoinChannel(channel, socket); + if (mayJoin !== true) { + return server.reply({ + cmd: 'warn', + text: 'You may not join that channel.', + id: mayJoin, }, socket); } // calling socket already in a channel - // @todo Multichannel update + // @todo multichannel update, will remove if (typeof socket.channel !== 'undefined') { return server.reply({ cmd: 'warn', // @todo Remove this text: 'Joining more than one channel is not currently supported', + id: Errors.Join.ALREADY_JOINED, }, socket); } + // end todo - // check user input - if (typeof payload.channel !== 'string' || typeof payload.nick !== 'string') { - return true; - } - - const channel = payload.channel.trim(); - if (!channel) { - // must join a non-blank channel - return true; - } - - const userInfo = this.parseNickname(core, payload); - if (typeof userInfo === 'string') { + // validates the user input for `nick` + const validName = verifyNickname(nick, socket); + if (validName !== true) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id - text: userInfo, + cmd: 'warn', + text: 'Nickname must consist of up to 24 letters, numbers, and underscores', + id: Errors.Join.INVALID_NICK, }, socket); } + // get trip and level + const { trip, level } = getUserPerms(pass, core.config, channel); + // store the user values + const userInfo = { + nick, + trip, + uType: legacyLevelToLabel(level), + hash: socket.hash, + level, + userid: socket.userid, + channel, + }; + + // prevent admin impersonation + if (nick.toLowerCase() === core.config.adminName.toLowerCase()) { + if (userInfo.trip !== 'Admin') { + userInfo.nick = `Fake${userInfo.nick}`; + } + } + // check if the nickname already exists in the channel const userExists = server.findSockets({ - channel: payload.channel, + channel, nick: (targetNick) => targetNick.toLowerCase() === userInfo.nick.toLowerCase(), }); if (userExists.length > 0) { // that nickname is already in that channel return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'Nickname taken', + id: Errors.Join.NAME_TAKEN, }, socket); } - // populate final userinfo fields - // @todo this could be move into parseNickname, changing the function name to match - userInfo.hash = server.getSocketHash(socket); - userInfo.userid = socket.userid; - - // @todo place this within it's own function allowing import // prepare to notify channel peers - const newPeerList = server.findSockets({ channel: payload.channel }); + const newPeerList = server.findSockets({ channel }); const nicks = []; /* @legacy */ const users = []; + const joinAnnouncement = { ...{ cmd: 'onlineAdd' }, ...userInfo }; - const joinAnnouncement = { - cmd: 'onlineAdd', - nick: userInfo.nick, - trip: userInfo.trip || 'null', - utype: userInfo.uType, /* @legacy */ - hash: userInfo.hash, - level: userInfo.level, - userid: userInfo.userid, - channel: payload.channel, - }; - - // send join announcement and prep online set + // send join announcement and prep online set reply for (let i = 0, l = newPeerList.length; i < l; i += 1) { server.reply(joinAnnouncement, newPeerList[i]); nicks.push(newPeerList[i].nick); /* @legacy */ @@ -141,34 +120,26 @@ export async function run({ users.push({ nick: newPeerList[i].nick, trip: newPeerList[i].trip, - utype: newPeerList[i].uType, /* @legacy */ + uType: newPeerList[i].uType, /* @legacy */ hash: newPeerList[i].hash, level: newPeerList[i].level, userid: newPeerList[i].userid, - channel: payload.channel, + channel, isme: false, }); } // store user info - socket.uType = userInfo.uType; /* @legacy */ socket.nick = userInfo.nick; socket.trip = userInfo.trip; - socket.channel = payload.channel; /* @legacy */ - socket.hash = userInfo.hash; socket.level = userInfo.level; + socket.uType = userInfo.uType; /* @legacy */ + socket.channel = channel; /* @legacy */ + // @todo multi-channel patch + // socket.channels.push(channel); - nicks.push(socket.nick); /* @legacy */ - users.push({ - nick: socket.nick, - trip: socket.trip, - utype: socket.uType, - hash: socket.hash, - level: socket.level, - userid: socket.userid, - channel: payload.channel, - isme: true, - }); + nicks.push(userInfo.nick); /* @legacy */ + users.push({ ...{ isme: true }, ...userInfo }); // reply with channel peer list server.reply({ @@ -186,7 +157,7 @@ export async function run({ export const requiredData = []; // ['channel', 'nick']; export const info = { name: 'join', - description: 'Place calling socket into target channel with target nick & broadcast event to channel', + description: 'Join the target channel using the supplied nick and password', usage: ` API: { cmd: 'join', nick: '', pass: '', channel: '' }`, }; diff --git a/server/src/commands/core/move.js b/server/src/commands/core/move.js index e27445c..a0ff8e1 100644 --- a/server/src/commands/core/move.js +++ b/server/src/commands/core/move.js @@ -8,7 +8,7 @@ export async function run({ server, socket, payload }) { // check for spam if (server.police.frisk(socket.address, 6)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are changing channels too fast. Wait a moment before trying again.', }, socket); } @@ -20,7 +20,7 @@ export async function run({ server, socket, payload }) { if (payload.channel === '') { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Cannot move to an empty channel.', }, socket); } @@ -109,7 +109,7 @@ export function moveCheck({ // If there is no channel target parameter if (input[1] === undefined) { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Refer to `/help move` for instructions on how to use this command.', }, socket); diff --git a/server/src/commands/core/session.js b/server/src/commands/core/session.js index 4a4c9b4..1012400 100644 --- a/server/src/commands/core/session.js +++ b/server/src/commands/core/session.js @@ -53,6 +53,7 @@ export async function run({ server, socket }) { socket.sessionID = createSessionID(); socket.hcProtocol = 2; socket.userid = Math.floor(Math.random() * 9999999999999); + socket.hash = server.getSocketHash(socket); // dispatch info server.reply({ diff --git a/server/src/commands/core/whisper.js b/server/src/commands/core/whisper.js index 5ed7d8f..fe9ead6 100644 --- a/server/src/commands/core/whisper.js +++ b/server/src/commands/core/whisper.js @@ -38,7 +38,7 @@ export async function run({ server, socket, payload }) { const score = text.length / 83 / 4; if (server.police.frisk(socket.address, score)) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'You are sending too much text. Wait a moment and try again.\nPress the up arrow key to restore your last message.', }, socket); } @@ -53,7 +53,7 @@ export async function run({ server, socket, payload }) { if (targetClient.length === 0) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Could not find user in channel', }, socket); } @@ -98,7 +98,7 @@ export function whisperCheck({ // If there is no nickname target parameter if (input[1] === undefined) { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Refer to `/help whisper` for instructions on how to use this command.', }, socket); @@ -126,7 +126,7 @@ export function whisperCheck({ if (payload.text.startsWith('/r ')) { if (typeof socket.whisperReply === 'undefined') { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Cannot reply to nobody', }, socket); diff --git a/server/src/commands/internal/legacylayer.js b/server/src/commands/internal/legacylayer.js deleted file mode 100644 index f4775b0..0000000 --- a/server/src/commands/internal/legacylayer.js +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint no-param-reassign: 0 */ - -/* - Description: This module adjusts outgoing data, making it compatible with legacy clients - Dear god this module is horrifying -*/ - -// import * as UAC from '../utility/UAC/_info'; - -// module main -export async function run({ server, socket }) { - return server.police.frisk(socket.address, 20); -} - -// module hook functions -export function initHooks(server) { - server.registerHook('in', 'join', this.joinCheck.bind(this), 10); - server.registerHook('in', 'invite', this.inviteInCheck.bind(this), 10); - server.registerHook('out', 'invite', this.inviteOutCheck.bind(this), 10); - server.registerHook('in', 'ban', this.banCheck.bind(this), 10); - server.registerHook('in', 'dumb', this.dumbCheck.bind(this), 10); - server.registerHook('in', 'kick', this.kickCheck.bind(this), 10); -} - -// hook incoming join events, if session was not invoked, default proto to 1 -export function joinCheck({ socket, payload }) { - if (typeof socket.hcProtocol === 'undefined') { - socket.hcProtocol = 1; - - const nickArray = payload.nick.split('#', 2); - payload.nick = nickArray[0].trim(); - if (nickArray[1] && typeof payload.pass === 'undefined') { - payload.pass = nickArray[1]; // eslint-disable-line prefer-destructuring - } - - // dunno how this happened on the legacy version - if (typeof payload.password !== 'undefined') { - payload.pass = payload.password; - } - - if (typeof socket.userid === 'undefined') { - socket.userid = Math.floor(Math.random() * 9999999999999); - } - } - - return payload; -} - -// if legacy client sent an invite, downgrade request -export function inviteInCheck({ server, socket, payload }) { - if (socket.hcProtocol === 1) { - let targetClient = server.findSockets({ channel: socket.channel, nick: payload.nick }); - - if (targetClient.length === 0) { - server.reply({ - cmd: 'warn', - text: 'Could not find user in that channel', - }, socket); - - return false; - } - - [targetClient] = targetClient; - - payload.userid = targetClient.userid; - payload.channel = socket.channel; - } - - return payload; -} - -// -export function inviteOutCheck({ server, socket, payload }) { - if (socket.hcProtocol === 1) { - payload.cmd = 'info'; - if (socket.userid === payload.from) { - let toClient = server.findSockets({ channel: socket.channel, userid: payload.from }); - [toClient] = toClient; - payload.type = 'invite'; - payload.from = toClient.nick; - payload.text = `You invited ${toClient.nick} to ?${payload.inviteChannel}`; - } else if (socket.userid === payload.to) { - let fromClient = server.findSockets({ channel: socket.channel, userid: payload.from }); - [fromClient] = fromClient; - payload.type = 'invite'; - payload.from = fromClient.nick; - payload.text = `${fromClient.nick} invited you to ?${payload.inviteChannel}`; - } - } - - return payload; -} - -export function banCheck({ server, socket, payload }) { - if (socket.hcProtocol === 1) { - let targetClient = server.findSockets({ channel: socket.channel, nick: payload.nick }); - - if (targetClient.length === 0) { - server.reply({ - cmd: 'warn', - text: 'Could not find user in that channel', - }, socket); - - return false; - } - - [targetClient] = targetClient; - - payload.userid = targetClient.userid; - payload.channel = socket.channel; - } - - return payload; -} - -export function dumbCheck({ server, socket, payload }) { - if (socket.hcProtocol === 1) { - let targetClient = server.findSockets({ channel: socket.channel, nick: payload.nick }); - - if (targetClient.length === 0) { - server.reply({ - cmd: 'warn', - text: 'Could not find user in that channel', - }, socket); - - return false; - } - - [targetClient] = targetClient; - - payload.userid = targetClient.userid; - payload.channel = socket.channel; - } - - return payload; -} - -export function kickCheck({ server, socket, payload }) { - if (socket.hcProtocol === 1) { - if (typeof payload.nick !== 'number') { - if (typeof payload.nick !== 'object' && !Array.isArray(payload.nick)) { - return true; - } - } - - const targetClient = server.findSockets({ channel: socket.channel, nick: payload.nick }); - - if (targetClient.length === 0) { - return false; - } - - payload.userid = []; - for (let i = 0, j = targetClient.length; i < j; i += 1) { - payload.userid.push(targetClient[i].userid); - } - - payload.channel = socket.channel; - } - - return payload; -} - -export const info = { - name: 'legacylayer', - description: 'This module adjusts outgoing data, making it compatible with legacy clients', -}; diff --git a/server/src/commands/internal/socketreply.js b/server/src/commands/internal/socketreply.js index 4221a76..9c7ddc8 100644 --- a/server/src/commands/internal/socketreply.js +++ b/server/src/commands/internal/socketreply.js @@ -11,7 +11,7 @@ export async function run({ server, socket, payload }) { // send warning to target socket server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: payload.text, }, socket); diff --git a/server/src/commands/mod/ban.js b/server/src/commands/mod/ban.js index ff601e8..8d6721d 100644 --- a/server/src/commands/mod/ban.js +++ b/server/src/commands/mod/ban.js @@ -4,6 +4,12 @@ */ import * as UAC from '../utility/UAC/_info'; +import { + Errors, +} from '../utility/_Constants'; +import { + findUser, +} from '../utility/_Channels'; // module main export async function run({ @@ -15,33 +21,38 @@ export async function run({ } // check user input - if (typeof payload.userid !== 'number') { + if (socket.hcProtocol === 1) { + if (typeof payload.nick !== 'string') { + return true; + } + + payload.channel = socket.channel; // eslint-disable-line no-param-reassign + } else if (typeof payload.userid !== 'number') { return true; } // find target user - let badClient = server.findSockets({ channel: socket.channel, userid: payload.userid }); - - if (badClient.length === 0) { + const targetUser = findUser(server, payload); + if (!targetUser) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id - text: 'Could not find user in channel', + cmd: 'warn', + text: 'Could not find user in that channel', + id: Errors.Global.UNKNOWN_USER, }, socket); } - - [badClient] = badClient; - const targetNick = badClient.nick; + const targetNick = targetUser.nick; // i guess banning mods or admins isn't the best idea? - if (badClient.level >= socket.level) { + if (targetUser.level >= socket.level) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'Cannot ban other users of the same level, how rude', + id: Errors.Global.PERMISSION, }, socket); } // commit arrest record - server.police.arrest(badClient.address, badClient.hash); + server.police.arrest(targetUser.address, targetUser.hash); console.log(`${socket.nick} [${socket.trip}] banned ${targetNick} in ${socket.channel}`); @@ -49,20 +60,20 @@ export async function run({ server.broadcast({ cmd: 'info', text: `Banned ${targetNick}`, - user: UAC.getUserDetails(badClient), + user: UAC.getUserDetails(targetUser), }, { channel: socket.channel, level: (level) => level < UAC.levels.moderator }); // notify mods server.broadcast({ cmd: 'info', - text: `${socket.nick}#${socket.trip} banned ${targetNick} in ${payload.channel}, userhash: ${badClient.hash}`, + text: `${socket.nick}#${socket.trip} banned ${targetNick} in ${payload.channel}, userhash: ${targetUser.hash}`, channel: payload.channel, - user: UAC.getUserDetails(badClient), + user: UAC.getUserDetails(targetUser), banner: UAC.getUserDetails(socket), }, { level: UAC.isModerator }); // force connection closed - badClient.terminate(); + targetUser.terminate(); // stats are fun core.stats.increment('users-banned'); diff --git a/server/src/commands/mod/dumb.js b/server/src/commands/mod/dumb.js index 3f8723f..a10d1cb 100644 --- a/server/src/commands/mod/dumb.js +++ b/server/src/commands/mod/dumb.js @@ -7,6 +7,12 @@ */ import * as UAC from '../utility/UAC/_info'; +import { + Errors, +} from '../utility/_Constants'; +import { + findUser, +} from '../utility/_Channels'; // module constructor export function init(core) { @@ -25,32 +31,37 @@ export async function run({ } // check user input - if (typeof payload.userid !== 'number') { + if (socket.hcProtocol === 1) { + if (typeof payload.nick !== 'string') { + return true; + } + + payload.channel = socket.channel; + } else if (typeof payload.userid !== 'number') { return true; } // find target user - let badClient = server.findSockets({ channel: payload.channel, userid: payload.userid }); - - if (badClient.length === 0) { + const targetUser = findUser(server, payload); + if (!targetUser) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id - text: 'Could not find user in channel', + cmd: 'warn', + text: 'Could not find user in that channel', + id: Errors.Global.UNKNOWN_USER, }, socket); } - [badClient] = badClient; - // likely dont need this, muting mods and admins is fine - if (badClient.level >= socket.level) { + if (targetUser.level >= socket.level) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'This trick wont work on users of the same level', + id: Errors.Global.PERMISSION, }, socket); } // store hash in mute list - const record = core.muzzledHashes[badClient.hash] = { + const record = core.muzzledHashes[targetUser.hash] = { dumb: true, }; @@ -62,7 +73,7 @@ export async function run({ // notify mods server.broadcast({ cmd: 'info', - text: `${socket.nick}#${socket.trip} muzzled ${badClient.nick} in ${payload.channel}, userhash: ${badClient.hash}`, + text: `${socket.nick}#${socket.trip} muzzled ${targetUser.nick} in ${payload.channel}, userhash: ${targetUser.hash}`, }, { level: UAC.isModerator }); return true; @@ -129,7 +140,7 @@ export function inviteCheck({ core, socket, payload }) { /* const nickValid = Invite.checkNickname(payload.nick); if (nickValid !== null) { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: nickValid, }, socket); return false; diff --git a/server/src/commands/mod/kick.js b/server/src/commands/mod/kick.js index 0c73e2a..3f93cd6 100644 --- a/server/src/commands/mod/kick.js +++ b/server/src/commands/mod/kick.js @@ -5,6 +5,12 @@ */ import * as UAC from '../utility/UAC/_info'; +import { + Errors, +} from '../utility/_Constants'; +import { + findUsers, +} from '../utility/_Channels'; // module main export async function run({ @@ -16,27 +22,28 @@ export async function run({ } // check user input - if (typeof payload.userid !== 'number') { + if (socket.hcProtocol === 1) { + if (typeof payload.nick !== 'string') { + if (typeof payload.nick !== 'object' && !Array.isArray(payload.nick)) { + return true; + } + } + + payload.channel = socket.channel; // eslint-disable-line no-param-reassign + } else if (typeof payload.userid !== 'number') { // @todo create multi-ban ui if (typeof payload.userid !== 'object' && !Array.isArray(payload.userid)) { return true; } } - let destChannel; - if (typeof payload.to === 'string' && !!payload.to.trim()) { - destChannel = payload.to; - } else { - destChannel = Math.random().toString(36).substr(2, 8); - } - // find target user(s) - const badClients = server.findSockets({ channel: payload.channel, userid: payload.userid }); - + const badClients = findUsers(server, payload); if (badClients.length === 0) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id - text: 'Could not find user(s) in channel', + cmd: 'warn', + text: 'Could not find user(s) in that channel', + id: Errors.Global.UNKNOWN_USER, }, socket); } @@ -45,8 +52,9 @@ export async function run({ for (let i = 0, j = badClients.length; i < j; i += 1) { if (badClients[i].level >= socket.level) { server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', text: 'Cannot kick other users with the same level, how rude', + id: Errors.Global.PERMISSION, }, socket); } else { kicked.push(badClients[i]); @@ -57,6 +65,13 @@ export async function run({ return true; } + let destChannel; + if (typeof payload.to === 'string' && !!payload.to.trim()) { + destChannel = payload.to; + } else { + destChannel = Math.random().toString(36).substr(2, 8); + } + // Announce the kicked clients arrival in destChannel and that they were kicked // Before they arrive, so they don't see they got moved for (let i = 0; i < kicked.length; i += 1) { @@ -64,12 +79,17 @@ export async function run({ cmd: 'onlineAdd', nick: kicked[i].nick, trip: kicked[i].trip || 'null', + uType: 'user', hash: kicked[i].hash, + level: UAC.levels.default, + userid: kicked[i].userid, + channel: destChannel, }, { channel: destChannel }); } // Move all kicked clients to the new channel for (let i = 0; i < kicked.length; i += 1) { + // @todo multi-channel update kicked[i].channel = destChannel; server.broadcast({ @@ -84,6 +104,7 @@ export async function run({ for (let i = 0, j = kicked.length; i < j; i += 1) { server.broadcast({ cmd: 'onlineRemove', + userid: kicked[i].userid, nick: kicked[i].nick, }, { channel: socket.channel }); } diff --git a/server/src/commands/mod/moveuser.js b/server/src/commands/mod/moveuser.js index 331dcae..7c248b6 100644 --- a/server/src/commands/mod/moveuser.js +++ b/server/src/commands/mod/moveuser.js @@ -1,5 +1,6 @@ /* Description: Removes the target socket from the current channel and forces a join event in another + @deprecated This module will be removed or replaced */ import * as UAC from '../utility/UAC/_info'; @@ -25,7 +26,7 @@ export async function run({ server, socket, payload }) { if (badClients.length === 0) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Could not find user in channel', }, socket); } @@ -34,7 +35,7 @@ export async function run({ server, socket, payload }) { if (badClient.level >= socket.level) { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: 'Cannot move other users of the same level, how rude', }, socket); } diff --git a/server/src/commands/mod/speak.js b/server/src/commands/mod/speak.js index 19774cc..35dcde3 100644 --- a/server/src/commands/mod/speak.js +++ b/server/src/commands/mod/speak.js @@ -26,7 +26,7 @@ export async function run({ // check user input if (typeof payload.ip !== 'string' && typeof payload.hash !== 'string') { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: "hash:'targethash' or ip:'1.2.3.4' is required", }, socket); } diff --git a/server/src/commands/mod/unban.js b/server/src/commands/mod/unban.js index 0c5cb50..e8dc042 100644 --- a/server/src/commands/mod/unban.js +++ b/server/src/commands/mod/unban.js @@ -18,7 +18,7 @@ export async function run({ // check user input if (typeof payload.ip !== 'string' && typeof payload.hash !== 'string') { return server.reply({ - cmd: 'warn', // @todo Remove english and change to numeric id + cmd: 'warn', // @todo Add numeric error code as `id` text: "hash:'targethash' or ip:'1.2.3.4' is required", }, socket); } diff --git a/server/src/commands/utility/UAC/_info.js b/server/src/commands/utility/UAC/_info.js index 7981afc..a43f0e6 100644 --- a/server/src/commands/utility/UAC/_info.js +++ b/server/src/commands/utility/UAC/_info.js @@ -1,3 +1,4 @@ +// NOTE: this has been moved into /utility/ as _UAC.js /** * User Account Control information containing level constants * and simple helper functions related to users diff --git a/server/src/commands/utility/_Channels.js b/server/src/commands/utility/_Channels.js new file mode 100644 index 0000000..15ba3f4 --- /dev/null +++ b/server/src/commands/utility/_Channels.js @@ -0,0 +1,85 @@ +import { + Errors, +} from './_Constants'; + +/** + * Checks if a client can join `channel`, returns numeric error code or true if + * able to join + * @public + * @param {string} channel Target channel + * @param {object} socket Target client to evaluate + * @return {boolean||error id} + */ +// eslint-disable-next-line no-unused-vars +export function canJoinChannel(channel, socket) { + if (typeof channel !== 'string') return Errors.Channel.INVALID_NAME; + if (channel === '') return Errors.Channel.INVALID_NAME; + if (channel.length > 120) return Errors.Channel.INVALID_LENGTH; + + return true; +} + +/** + * Returns an object containing info about the specified channel, + * including if it is owned, mods, permissions + * @public + * @param {string} config Server config object + * @param {string} channel Target channel + * @return {object} + */ +export function getChannelSettings(config, channel) { + if (typeof config.channels !== 'undefined') { + if (typeof config.channels[channel] !== 'undefined') { + return config.channels[channel]; + } + } + + return { + owned: false, + }; +} + +/** + * Returns an object containing info about the specified channel, + * including if it is owned, mods, permissions + * @public + * @param {MainServer} server Main server reference + * @param {object} payload Object containing `userid` or `nick` + * @param {number} limit Optional return limit + * @return {array} + */ +export function findUsers(server, payload, limit = 0) { + let targetClients; + + if (typeof payload.userid !== 'undefined') { + targetClients = server.findSockets({ + channel: payload.channel, + userid: payload.userid, + }); + } else if (typeof payload.nick !== 'undefined') { + targetClients = server.findSockets({ + channel: payload.channel, + nick: payload.nick, + }); + } else { + return []; + } + + if (limit !== 0 && targetClients.length > limit) { + return targetClients.splice(0, limit); + } + + return targetClients; +} + +/** + * Overload for `findUsers` when only 1 user is expected + * @public + * @param {MainServer} server Main server reference + * @param {object} payload Object containing `userid` or `nick` + * @param {number} limit Optional return limit + * @return {boolean||object} + */ +export function findUser(server, payload) { + return findUsers(server, payload, 1)[0] || false; +} diff --git a/server/src/commands/utility/_Constants.js b/server/src/commands/utility/_Constants.js new file mode 100644 index 0000000..1f18aa1 --- /dev/null +++ b/server/src/commands/utility/_Constants.js @@ -0,0 +1,34 @@ +/* Base error ranges */ +const GlobalErrors = 10; +const JoinErrors = 20; +const ChannelErrors = 30; +const InviteErrors = 40; + +/** + * Holds the numeric id values for each error type + * @typedef {object} Errors + */ +exports.Errors = { + Global: { + RATELIMIT: GlobalErrors + 1, + UNKNOWN_USER: GlobalErrors + 2, + PERMISSION: GlobalErrors + 3, + }, + + Join: { + RATELIMIT: JoinErrors + 1, + INVALID_NICK: JoinErrors + 2, + ALREADY_JOINED: JoinErrors + 3, + NAME_TAKEN: JoinErrors + 4, + }, + + Channel: { + INVALID_NAME: ChannelErrors + 1, + INVALID_LENGTH: ChannelErrors + 2, + }, + + Invite: { + RATELIMIT: InviteErrors + 1, + INVALID_LENGTH: InviteErrors + 2, + }, +}; diff --git a/server/src/commands/utility/_LegacyFunctions.js b/server/src/commands/utility/_LegacyFunctions.js new file mode 100644 index 0000000..a55987f --- /dev/null +++ b/server/src/commands/utility/_LegacyFunctions.js @@ -0,0 +1,90 @@ +/* eslint no-param-reassign: 0 */ + +import { + isAdmin, + isModerator, +} from './_UAC'; + +/** + * Marks the socket as using the legacy protocol and + * applies the missing `pass` property to the payload + * @param {MainServer} server Main server reference + * @param {WebSocket} socket Target client socket + * @param {object} payload The original `join` payload + * @returns {object} + */ +export function upgradeLegacyJoin(server, socket, payload) { + const newPayload = payload; + + // `join` is the legacy entry point, so apply protocol version + socket.hcProtocol = 1; + + // this would have been applied in the `session` module, apply it now + socket.hash = server.getSocketHash(socket); + + // pull the password from the nick + const nickArray = payload.nick.split('#', 2); + newPayload.nick = nickArray[0].trim(); + if (nickArray[1] && typeof payload.pass === 'undefined') { + newPayload.pass = nickArray[1]; // eslint-disable-line prefer-destructuring + } + + // dunno how this happened on the legacy version + if (typeof payload.password !== 'undefined') { + newPayload.pass = payload.password; + } + + // apply the missing `userid` prop + if (typeof socket.userid === 'undefined') { + socket.userid = Math.floor(Math.random() * 9999999999999); + } + + return newPayload; +} + +/** + * Return the correct `uType` label for the specific level + * @param {number} level Numeric level to find the label for + */ +export function legacyLevelToLabel(level) { + if (isAdmin(level)) return 'admin'; + if (isModerator(level)) return 'mod'; + + return 'user'; +} + +/** + * Alter the outgoing payload to an `info` cmd and add/change missing props + * @param {object} payload Numeric level to find the label for + * @param {string} nick Numeric level to find the label for + * @return {object} + */ +export function legacyInviteOut(payload, nick) { + return { + ...payload, + ...{ + cmd: 'info', + type: 'invite', + from: nick, + text: `${nick} invited you to ?${payload.inviteChannel}`, + }, + }; +} + +/** + * Alter the outgoing payload to an `info` cmd and add/change missing props + * @param {object} payload Numeric level to find the label for + * @param {string} nick Numeric level to find the label for + * @return {object} + */ +export function legacyInviteReply(payload, nick) { + return { + ...payload, + ...{ + cmd: 'info', + type: 'invite', + from: '', + text: `You invited ${nick} to ?${payload.inviteChannel}`, + }, + }; +} diff --git a/server/src/commands/utility/_UAC.js b/server/src/commands/utility/_UAC.js new file mode 100644 index 0000000..aee98d2 --- /dev/null +++ b/server/src/commands/utility/_UAC.js @@ -0,0 +1,186 @@ +/** + * User Account Control information containing level constants + * and simple helper functions related to users + * @property {Object} levels - Defines labels for default permission ranges + * @author MinusGix ( https://github.com/MinusGix ) + * @version v1.0.0 + * @license WTFPL ( http://www.wtfpl.net/txt/copying/ ) + */ + +import { + getChannelSettings, +} from './_Channels'; + +const crypto = require('crypto'); + +/** + * Object defining labels for default permission ranges + * @typedef {Object} levels + * @property {number} admin Global administrator range + * @property {number} moderator Global moderator range + * @property {number} channelOwner Local administrator range + * @property {number} channelModerator Local moderator range + * @property {number} channelTrusted Local (non-public) channel trusted + * @property {number} trustedUser Public channel trusted + * @property {number} default Default user level + */ +export const levels = { + admin: 9999999, + moderator: 999999, + + channelOwner: 99999, + channelModerator: 9999, + channelTrusted: 8999, + + trustedUser: 500, + default: 100, +}; + +/** + * Returns true if target level is equal or greater than the global admin level + * @public + * @param {number} level Level to verify + * @return {boolean} + */ +export function isAdmin(level) { + return level >= levels.admin; +} + +/** + * Returns true if target level is equal or greater than the global moderator level + * @public + * @param {number} level Level to verify + * @return {boolean} + */ +export function isModerator(level) { + return level >= levels.moderator; +} + +/** + * Returns true if target level is equal or greater than the channel owner level + * @public + * @param {number} level Level to verify + * @return {boolean} + */ +export function isChannelOwner(level) { + return level >= levels.channelOwner; +} + +/** + * Returns true if target level is equal or greater than the channel moderator level + * @public + * @param {number} level Level to verify + * @return {boolean} + */ +export function isChannelModerator(level) { + return level >= levels.channelModerator; +} + +/** + * Returns true if target level is equal or greater than the channel trust level + * @public + * @param {number} level Level to verify + * @return {boolean} + */ +export function isChannelTrusted(level) { + return level >= levels.channelTrusted; +} + +/** + * Returns true if target level is equal or greater than a trusted user + * @public + * @param {number} level Level to verify + * @return {boolean} + */ +export function isTrustedUser(level) { + return level >= levels.trustedUser; +} + +/** + * Return an object containing public information about the socket + * @public + * @param {WebSocket} socket Target client + * @return {Object} + */ +export function getUserDetails(socket) { + return { + uType: socket.uType, + nick: socket.nick, + trip: socket.trip || 'null', + hash: socket.hash, + level: socket.level, + userid: socket.userid, + }; +} + +/** + * Returns true if the nickname is valid + * @public + * @param {string} nick Nickname to verify + * @return {boolean} + */ +export function verifyNickname(nick) { + if (typeof nick === 'undefined') return false; + + return /^[a-zA-Z0-9_]{1,24}$/.test(nick); +} + +/** + * Hashes a user's password, returning a trip code + * or a blank string + * @public + * @param {string} pass User's password + * @param {string} config Server config object + * @param {string} channel Channel-level permissions check + * @return {string} + */ +export function getUserPerms(pass, config, channel) { + if (!pass) { + return { + trip: '', + level: levels.default, + }; + } + + const sha = crypto.createHash('sha256'); + sha.update(pass + config.tripSalt); + const trip = sha.digest('base64').substr(0, 6); + + // check if user is global admin + if (trip === config.adminTrip) { + return { + trip: 'Admin', + level: levels.admin, + }; + } + + // check if user is global mod + config.mods.forEach((mod) => { // eslint-disable-line consistent-return + if (trip === mod.trip) { + return { + trip, + level: levels.moderator, + }; + } + }); + + const channelSettings = getChannelSettings(config, channel); + if (channelSettings.owned) { + // check if user is channel owner + // @todo channel ownership patch + + // check if user is channel mod + // @todo channel ownership patch + + // check if user is channel trusted + // @todo channel ownership patch + } + + // check if user is global trusted + // @todo channel ownership patch + + return { + trip, + level: levels.default, + }; +}