diff options
Diffstat (limited to '')
-rw-r--r-- | CHANGELOG.md | 21 | ||||
-rw-r--r-- | client/client.js | 5 | ||||
-rw-r--r-- | server/package.json | 2 | ||||
-rw-r--r-- | server/src/commands/core/changenick.js | 90 | ||||
-rw-r--r-- | server/src/commands/core/disconnect.js | 23 | ||||
-rw-r--r-- | server/src/commands/core/invite.js | 20 | ||||
-rw-r--r-- | server/src/commands/core/join.js | 52 | ||||
-rw-r--r-- | server/src/commands/core/morestats.js | 1 | ||||
-rw-r--r-- | server/src/commands/core/move.js | 85 | ||||
-rw-r--r-- | server/src/commands/mod/kick.js | 74 | ||||
-rw-r--r-- | server/src/core/server.js | 69 |
11 files changed, 354 insertions, 88 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd3ec4..d40e383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,24 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -## [1.0.0] - 2018-04-12 +## [2.0.1] - 2018-04-18 +### Added +- `users-kicked` tracking to `morestats` command +- Server-side ping interval +- `move` command to change channels without reconnecting +- `disconnect` command module to free core server from protocol dependency +- `changenick` command to change client nick without reconnecting + +### Changed +- Filter object of the `findSockets` function now accepts more complex parameters, including functions and arrays +- `kick` command now accepts an array as the `nick` argument allowing multiple simultaneous kicks +- `join` command now takes advantage of the new filter object +- Core server disconnect handler now calls the `disconnect` module instead of broadcasting hard coded `onlineRemove` + +### Removed +- Client-side ping interval + +## [2.0.0] - 2018-04-12 ### Added - CHANGELOG.md - `index.html` files to `katex` directories @@ -12,4 +29,4 @@ All notable changes to this project will be documented in this file. - Updated client html KaTeX libraries to v0.9.0 ### Removed -- Uneeded files under `katex` directories
\ No newline at end of file +- Uneeded files under `katex` directories diff --git a/client/client.js b/client/client.js index 85e3ecb..ff2decc 100644 --- a/client/client.js +++ b/client/client.js @@ -50,11 +50,6 @@ var myChannel = window.location.search.replace(/^\?/, ''); var lastSent = [""]; var lastSentPos = 0; -// Ping server every 50 seconds to retain WebSocket connection -window.setInterval(function () { - send({ cmd: 'ping' }); -}, 50000); - function join(channel) { if (document.domain == 'hack.chat') { // For https://hack.chat/ diff --git a/server/package.json b/server/package.json index 219c6b3..8417c13 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "hack.chat-v2", - "version": "2.0.0", + "version": "2.0.1", "description": "a minimal distraction free chat application", "main": "main.js", "repository": { diff --git a/server/src/commands/core/changenick.js b/server/src/commands/core/changenick.js new file mode 100644 index 0000000..460f811 --- /dev/null +++ b/server/src/commands/core/changenick.js @@ -0,0 +1,90 @@ +/* + Description: Generates a semi-unique channel name then broadcasts it to each client +*/ + +'use strict'; + +const verifyNickname = (nick) => { + return /^[a-zA-Z0-9_]{1,24}$/.test(nick); +}; + +exports.run = async (core, server, socket, data) => { + if (server._police.frisk(socket.remoteAddress, 6)) { + server.reply({ + cmd: 'warn', + text: 'You are changing nicknames too fast. Wait a moment before trying again.' + }, socket); + + return; + } + + if (typeof data.nick !== 'string') { + return; + } + + let newNick = data.nick.trim(); + + if (!verifyNickname(newNick)) { + server.reply({ + cmd: 'warn', + text: 'Nickname must consist of up to 24 letters, numbers, and underscores' + }, socket); + + return; + } + + if (newNick.toLowerCase() == core.config.adminName.toLowerCase()) { + server._police.frisk(socket.remoteAddress, 4); + + server.reply({ + cmd: 'warn', + text: 'Gtfo' + }, socket); + + return; + } + + let userExists = server.findSockets({ + channel: data.channel, + nick: (targetNick) => targetNick.toLowerCase() === newNick.toLowerCase() + }); + + if (userExists.length > 0) { + // That nickname is already in that channel + server.reply({ + cmd: 'warn', + text: 'Nickname taken' + }, socket); + + return; + } + + let peerList = server.findSockets({ channel: socket.channel }); + let leaveNotice = { + cmd: 'onlineRemove', + nick: socket.nick + }; + let joinNotice = { + cmd: 'onlineAdd', + nick: newNick, + trip: socket.trip || 'null', + hash: server.getSocketHash(socket) + }; + + server.broadcast( leaveNotice, { channel: socket.channel }); + server.broadcast( joinNotice, { channel: socket.channel }); + server.broadcast( { + cmd: 'info', + text: `${socket.nick} is now ${newNick}` + }, { channel: socket.channel }); + + socket.nick = newNick; +}; + +exports.requiredData = ['nick']; + +exports.info = { + name: 'changenick', + usage: 'changenick {nick}', + description: 'This will change your current connections nickname' +}; diff --git a/server/src/commands/core/disconnect.js b/server/src/commands/core/disconnect.js new file mode 100644 index 0000000..1a9c635 --- /dev/null +++ b/server/src/commands/core/disconnect.js @@ -0,0 +1,23 @@ +/* + Description: This module will be directly called by the server event handler + when a socket connection is closed or lost. It can calso be called + by a client to have the connection severed. +*/ + +'use strict'; + +exports.run = async (core, server, socket, data) => { + if (socket.channel) { + server.broadcast({ + cmd: 'onlineRemove', + nick: socket.nick + }, { channel: socket.channel }); + } + + socket.terminate(); +}; + +exports.info = { + name: 'disconnect', + description: 'Event handler or force disconnect (if your into that kind of thing)' +}; diff --git a/server/src/commands/core/invite.js b/server/src/commands/core/invite.js index a6412e1..bd85812 100644 --- a/server/src/commands/core/invite.js +++ b/server/src/commands/core/invite.js @@ -9,6 +9,15 @@ const verifyNickname = (nick) => { }; exports.run = async (core, server, socket, data) => { + if (server._police.frisk(socket.remoteAddress, 2)) { + server.reply({ + cmd: 'warn', + text: 'You are sending invites too fast. Wait a moment before trying again.' + }, socket); + + return; + } + if (typeof data.nick !== 'string') { return; } @@ -22,16 +31,7 @@ exports.run = async (core, server, socket, data) => { // They invited themself return; } - - if (server._police.frisk(socket.remoteAddress, 2)) { - server.reply({ - cmd: 'warn', - text: 'You are sending invites too fast. Wait a moment before trying again.' - }, socket); - - return; - } - + let channel = Math.random().toString(36).substr(2, 8); let payload = { diff --git a/server/src/commands/core/join.js b/server/src/commands/core/join.js index e896361..82b48d2 100644 --- a/server/src/commands/core/join.js +++ b/server/src/commands/core/join.js @@ -28,7 +28,6 @@ exports.run = async (core, server, socket, data) => { if (typeof socket.channel !== 'undefined') { // Calling socket already in a channel - // TODO: allow changing of channel without reconnection return; } @@ -56,17 +55,19 @@ exports.run = async (core, server, socket, data) => { return; } - for (let client of server.clients) { - if (client.channel === channel) { - if (client.nick.toLowerCase() === nick.toLowerCase()) { - server.reply({ - cmd: 'warn', - text: 'Nickname taken' - }, socket); + let userExists = server.findSockets({ + channel: data.channel, + nick: (targetNick) => targetNick.toLowerCase() === nick.toLowerCase() + }); - return; - } - } + if (userExists.length > 0) { + // That nickname is already in that channel + 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. @@ -75,6 +76,8 @@ exports.run = async (core, server, socket, data) => { 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' @@ -83,7 +86,7 @@ exports.run = async (core, server, socket, data) => { return; } else { uType = 'admin'; - trip = hash(password + core.config.tripSalt); + trip = 'Admin'; } } else if (password) { trip = hash(password + core.config.tripSalt); @@ -91,30 +94,31 @@ exports.run = async (core, server, socket, data) => { // TODO: Disallow moderator impersonation for (let mod of core.config.mods) { - if (trip === mod.trip) + if (trip === mod.trip) { uType = 'mod'; + } } - // Announce the new user - server.broadcast({ + // Reply with online user list + let newPeerList = server.findSockets({ channel: data.channel }); + let joinAnnouncement = { cmd: 'onlineAdd', nick: nick, trip: trip || 'null', hash: server.getSocketHash(socket) - }, { channel: channel }); + }; + let nicks = []; + + for (let i = 0, l = newPeerList.length; i < l; i++) { + server.reply(joinAnnouncement, newPeerList[i]); + nicks.push(newPeerList[i].nick); + } socket.uType = uType; socket.nick = nick; socket.channel = channel; if (trip !== null) socket.trip = trip; - - // Reply with online user list - let nicks = []; - for (let client of server.clients) { - if (client.channel === channel) { - nicks.push(client.nick); - } - } + nicks.push(socket.nick); server.reply({ cmd: 'onlineSet', diff --git a/server/src/commands/core/morestats.js b/server/src/commands/core/morestats.js index 887d663..d8bc23d 100644 --- a/server/src/commands/core/morestats.js +++ b/server/src/commands/core/morestats.js @@ -41,6 +41,7 @@ exports.run = async (core, server, socket, data) => { invites-sent: ${(core.managers.stats.get('invites-sent') || 0)} messages-sent: ${(core.managers.stats.get('messages-sent') || 0)} users-banned: ${(core.managers.stats.get('users-banned') || 0)} + users-kicked: ${(core.managers.stats.get('users-kicked') || 0)} stats-requested: ${(core.managers.stats.get('stats-requested') || 0)} server-uptime: ${formatTime(process.hrtime(core.managers.stats.get('start-time')))}` }, socket); diff --git a/server/src/commands/core/move.js b/server/src/commands/core/move.js new file mode 100644 index 0000000..862025c --- /dev/null +++ b/server/src/commands/core/move.js @@ -0,0 +1,85 @@ +/* + Description: Generates a semi-unique channel name then broadcasts it to each client +*/ + +'use strict'; + +exports.run = async (core, server, socket, data) => { + if (server._police.frisk(socket.remoteAddress, 6)) { + server.reply({ + cmd: 'warn', + text: 'You are changing channels too fast. Wait a moment before trying again.' + }, socket); + + return; + } + + if (typeof data.channel !== 'string') { + return; + } + + if (data.channel === socket.channel) { + // They are trying to rejoin the channel + return; + } + + const currentNick = socket.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 + }, socket); + + if (socket.nick !== peerList[i].nick){ + server.reply({ + cmd: 'onlineRemove', + nick: socket.nick + }, peerList[i]); + } + } + } + + let newPeerList = server.findSockets({ channel: data.channel }); + let moveAnnouncement = { + cmd: 'onlineAdd', + nick: socket.nick, + trip: socket.trip || 'null', + hash: server.getSocketHash(socket) + }; + let nicks = []; + + for (let i = 0, l = newPeerList.length; i < l; i++) { + server.reply(moveAnnouncement, newPeerList[i]); + nicks.push(newPeerList[i].nick); + } + + nicks.push(socket.nick); + + server.reply({ + cmd: 'onlineSet', + nicks: nicks + }, socket); + + socket.channel = data.channel; +}; + +exports.requiredData = ['channel']; + +exports.info = { + name: 'move', + usage: 'move {channel}', + description: 'This will change the current channel to the new one provided' +}; diff --git a/server/src/commands/mod/kick.js b/server/src/commands/mod/kick.js index 1958ef3..f32eaf5 100644 --- a/server/src/commands/mod/kick.js +++ b/server/src/commands/mod/kick.js @@ -5,62 +5,70 @@ 'use strict'; exports.run = async (core, server, socket, data) => { - if (socket.uType == 'user') { + if (socket.uType === 'user') { // ignore if not mod or admin return; } if (typeof data.nick !== 'string') { - return; + if (typeof data.nick !== 'object' && !Array.isArray(data.nick)) { + return; + } } - let targetNick = data.nick; - let badClient = server.findSockets({ channel: socket.channel, nick: targetNick }); + let badClients = server.findSockets({ channel: socket.channel, nick: data.nick }); - if (badClient.length === 0) { + if (badClients.length === 0) { server.reply({ cmd: 'warn', - text: 'Could not find user in channel' + text: 'Could not find user(s) in channel' }, socket); return; } - badClient = badClient[0]; - - if (badClient.uType !== 'user') { - server.reply({ - cmd: 'warn', - text: 'Cannot kick other mods, how rude' - }, socket); + let newChannel = ''; + let kicked = []; + for (let i = 0, j = badClients.length; i < j; i++) { + if (badClients[i].uType !== 'user') { + server.reply({ + cmd: 'warn', + text: 'Cannot kick other mods, how rude' + }, socket); + } else { + newChannel = Math.random().toString(36).substr(2, 8); + badClients[i].channel = newChannel; + + // inform mods with where they were sent + server.broadcast({ + cmd: 'info', + text: `${badClients[i].nick} was banished to ?${newChannel}` + }, { channel: socket.channel, uType: 'mod' }); + + kicked.push(badClients[i].nick); + console.log(`${socket.nick} [${socket.trip}] kicked ${badClients[i].nick} in ${socket.channel}`); + } + } + if (kicked.length === 0) { return; } - let newChannel = Math.random().toString(36).substr(2, 8); - badClient.channel = newChannel; - - console.log(`${socket.nick} [${socket.trip}] kicked ${targetNick} in ${socket.channel}`); - - // remove socket from same-channel client - server.broadcast({ - cmd: 'onlineRemove', - nick: targetNick - }, { channel: socket.channel }); + // broadcast client leave event + for (let i = 0, j = kicked.length; i < j; i++) { + server.broadcast({ + cmd: 'onlineRemove', + nick: kicked[i] + }, { channel: socket.channel }); + } - // publicly broadcast event + // publicly broadcast kick event server.broadcast({ cmd: 'info', - text: `Kicked ${targetNick}` + text: `Kicked ${kicked.join(', ')}` }, { channel: socket.channel, uType: 'user' }); - // inform mods with where they were sent - server.broadcast({ - cmd: 'info', - text: `${targetNick} was banished to ?${newChannel}` - }, { channel: socket.channel, uType: 'mod' }); - - core.managers.stats.increment('users-banned'); + core.managers.stats.increment('users-kicked', kicked.length); }; exports.requiredData = ['nick']; @@ -68,5 +76,5 @@ exports.requiredData = ['nick']; exports.info = { name: 'kick', usage: 'kick {nick}', - description: 'Forces target client into another channel without announcing change' + description: 'Silently forces target client(s) into another channel. `nick` may be string or array of strings' }; diff --git a/server/src/core/server.js b/server/src/core/server.js index 753ed70..9bea738 100644 --- a/server/src/core/server.js +++ b/server/src/core/server.js @@ -14,6 +14,7 @@ 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 Police = require('./rateLimiter'); +const pulseSpeed = 16000; // ping all clients every X ms class server extends wsServer { /** @@ -27,6 +28,9 @@ class server extends wsServer { this._core = core; this._police = new Police(); this._cmdBlacklist = {}; + this._heartBeat = setInterval(((data) => { + this.beatHeart(); + }).bind(this), pulseSpeed); this.on('error', (err) => { this.handleError('server', err); @@ -38,6 +42,26 @@ class server extends wsServer { } /** + * Send empty `ping` frame to each client + * + */ + beatHeart () { + let targetSockets = this.findSockets({}); + + if (targetSockets.length === 0) { + return; + } + + for (let i = 0, l = targetSockets.length; i < l; i++) { + try { + if (targetSockets[i].readyState === socketReady) { + targetSockets[i].ping(); + } + } catch (e) { } + } + } + + /** * Bind listeners for the new socket created on connection to this class * * @param {Object} socket New socket object @@ -120,16 +144,7 @@ class server extends wsServer { * @param {Object} socket Closing socket object */ handleClose (socket) { - try { - if (socket.channel) { - this.broadcast({ - cmd: 'onlineRemove', - nick: socket.nick - }, { channel: socket.channel }); - } - } catch (err) { - console.log(`Server, handle close event error: ${err}`); - } + this._core.commands.handleCommand(this, socket, { cmd: 'disconnect' }); } /** @@ -206,9 +221,37 @@ class server extends wsServer { for ( let socket of this.clients ) { curMatch = 0; - for( let i = 0; i < reqCount; i++ ) { - if (typeof socket[filterAttribs[i]] !== 'undefined' && socket[filterAttribs[i]] === filter[filterAttribs[i]]) - curMatch++; + for (let i = 0; i < reqCount; i++) { + if (typeof socket[filterAttribs[i]] !== 'undefined') { + switch(typeof filter[filterAttribs[i]]) { + case 'object': { + if (Array.isArray(filter[filterAttribs[i]])) { + if (filter[filterAttribs[i]].indexOf(socket[filterAttribs[i]]) !== -1) { + curMatch++; + } + } else { + if (socket[filterAttribs[i]] === filter[filterAttribs[i]]) { + curMatch++; + } + } + break; + } + + case 'function': { + if (filter[filterAttribs[i]](socket[filterAttribs[i]])) { + curMatch++; + } + break; + } + + default: { + if (socket[filterAttribs[i]] === filter[filterAttribs[i]]) { + curMatch++; + } + break; + } + } + } } if (curMatch === reqCount) { |