├── Procfile ├── media ├── w.png ├── whir.png └── favicon.png ├── .prettierrc.json ├── .codeclimate.yml ├── .editorconfig ├── .env.sample ├── app ├── models │ ├── schemas │ │ ├── user.js │ │ └── channel.js │ └── index.js ├── index.js ├── config.js └── core │ ├── commander.js │ └── whir.js ├── .gitignore ├── app.json ├── .eslintrc.json ├── LICENSE ├── package.json └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: node app/index.js -------------------------------------------------------------------------------- /media/w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhirIO/Server/HEAD/media/w.png -------------------------------------------------------------------------------- /media/whir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhirIO/Server/HEAD/media/whir.png -------------------------------------------------------------------------------- /media/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhirIO/Server/HEAD/media/favicon.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | JavaScript: true 2 | 3 | engines: 4 | eslint: 5 | enabled: true 6 | ratings: 7 | paths: 8 | - "app/**/*" 9 | - "**.js" 10 | exclude_paths: 11 | - "media/**/*" 12 | - "**.json" 13 | - "**.md" 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | PORT=9000 2 | MONGO_URI=mongodb://127.0.0.1:27017/whir 3 | 4 | # If REDIS_URL is provided _HOST, _PORT and _PASSWORD will be ignored 5 | REDIS_URL=redis://[auth]@[host]:[port] 6 | 7 | REDIS_HOST=127.0.0.1 8 | REDIS_PORT=6379 9 | REDIS_PASSWORD= 10 | REDIS_PREFIX=WHIR. 11 | -------------------------------------------------------------------------------- /app/models/schemas/user.js: -------------------------------------------------------------------------------- 1 | module.exports = (mongoose) => { 2 | const schema = new mongoose.Schema( 3 | { 4 | username: { 5 | type: String, 6 | trim: true, 7 | lowercase: true, 8 | default: null 9 | }, 10 | gender: { 11 | type: String, 12 | default: null 13 | }, 14 | age: { 15 | type: String, 16 | default: null 17 | }, 18 | meta: { 19 | createdOn: { 20 | type: Date, 21 | default: Date.now 22 | } 23 | } 24 | }, 25 | { 26 | strict: true, 27 | versionKey: false 28 | } 29 | ); 30 | 31 | return mongoose.model('User', schema, 'users'); 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | .env 39 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whir.io", 3 | "description": "The whir.io chat server. [alpha]", 4 | "repository": "https://github.com/WhirIO/Server", 5 | "logo": "https://github.com/WhirIO/Server/raw/master/media/whir.png", 6 | "website": "https://whir.io", 7 | "success_url": "/", 8 | "keywords": [ 9 | "chat", 10 | "command", 11 | "cli", 12 | "socket", 13 | "websocket", 14 | "interface" 15 | ], 16 | "addons": [ 17 | { 18 | "plan": "mongolab:sandbox", 19 | "as": "MONGO" 20 | }, 21 | { 22 | "plan": "heroku-redis:hobby-dev", 23 | "as": "REDIS" 24 | } 25 | ], 26 | "env": { 27 | "REDIS_PREFIX": { 28 | "description": "Prefix for redis keys.", 29 | "value": "WHIR." 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "mocha": true 5 | }, 6 | "extends": ["airbnb-base", "plugin:prettier/recommended"], 7 | "plugins": ["import", "prettier"], 8 | "rules": { 9 | "arrow-parens": ["error", "always"], 10 | "no-underscore-dangle": 0, 11 | "no-console": "error", 12 | "prettier/prettier": [ 13 | "error", 14 | { 15 | "semi": true, 16 | "printWidth": 100, 17 | "singleQuote": true, 18 | "arrowParens": "always" 19 | } 20 | ], 21 | "func-names": ["error", "always"], 22 | "comma-dangle": ["error", "never"], 23 | "no-param-reassign": ["error", { "props": false }], 24 | "max-len": ["error", 100, { "ignoreRegExpLiterals": true }], 25 | "import/no-extraneous-dependencies": 0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const m = require('./models'); 3 | const Whir = require('./core/whir'); 4 | 5 | const color = { info: '\x1b[32m', warning: '\x1b[34m', error: '\x1b[33m' }; 6 | const log = (message, level) => { 7 | process.stdout.write(`${color[level]}[${level}] ${message}\x1b[0m\n\r`); 8 | if (level === 'error') { 9 | process.exit(); 10 | } 11 | }; 12 | 13 | /** 14 | * Pre-load the models, then boot the application. 15 | * @see models/index.js 16 | */ 17 | m 18 | .load(config.mongo) 19 | .then(() => { 20 | const whir = new Whir({ port: process.env.PORT, redisConf: config.redis }); 21 | whir.on('info', (message) => log(message, 'info')); 22 | whir.on('warning', (message) => log(message, 'warning')); 23 | whir.on('error', (message) => log(message, 'error')); 24 | }) 25 | .catch((error) => { 26 | log(error, 'error'); 27 | }); 28 | -------------------------------------------------------------------------------- /app/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | host: process.env.HOST, 3 | port: process.env.PORT, 4 | redis: { 5 | url: process.env.REDIS_URL || null, 6 | port: process.env.REDIS_PORT || null, 7 | host: process.env.REDIS_HOST || null, 8 | db: process.env.REDIS_DB || 0, 9 | password: process.env.REDIS_PASSWORD || '', 10 | prefix: process.env.REDIS_PREFIX || '', 11 | retry_strategy: (options) => { 12 | if (options.error && options.error.code === 'ECONNREFUSED') { 13 | return new Error('The server refused the connection.'); 14 | } 15 | 16 | if (options.total_retry_time > 1000 * 60 * 60) { 17 | return new Error('Retry time exhausted.'); 18 | } 19 | 20 | if (options.attempt > 5) { 21 | return new Error('Too many reconnection attempts.'); 22 | } 23 | 24 | return Math.min(options.attempt * 100, 1000); 25 | } 26 | }, 27 | mongo: { 28 | url: process.env.MONGO_URI, 29 | poolSize: parseInt(process.env.MONGO_POOL_SIZE, 10) || 10, 30 | socketOptions: { keepAlive: parseInt(process.env.MONGO_KEEP_ALIVE, 10) || 1 } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Stefan Aichholzer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whir.io", 3 | "version": "1.2.0", 4 | "description": "The whir.io chat server. [alpha]", 5 | "keywords": [ 6 | "chat", 7 | "server" 8 | ], 9 | "author": { 10 | "name": "Stefan Aichholzer", 11 | "email": "play@analogbird.com", 12 | "url": "https://github.com/aichholzer" 13 | }, 14 | "homepage": "https://github.com/WhirIO", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/WhirIO/Server" 18 | }, 19 | "engines": { 20 | "node": "8.9.4" 21 | }, 22 | "dependencies": { 23 | "bcrypt": "^2.0.1", 24 | "mongoose": "^5.0.17", 25 | "redis": "^2.8.0", 26 | "roli": "^0.2.1", 27 | "uws": "^9.148.0" 28 | }, 29 | "devDependencies": { 30 | "dotenv": "^5.0.1", 31 | "eslint": "^4.18.0", 32 | "eslint-config-airbnb-base": "^12.1.0", 33 | "eslint-config-prettier": "^2.9.0", 34 | "eslint-plugin-import": "^2.11.0", 35 | "eslint-plugin-prettier": "^2.6.0", 36 | "prettier": "^1.12.1" 37 | }, 38 | "scripts": { 39 | "eslint": "eslint --quiet .", 40 | "eslint:fix": "eslint --quiet --fix .", 41 | "start": "node ./app/index.js", 42 | "local": "node -r dotenv/config ./app/index.js", 43 | "test": "mocha -R spec -t 5000" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/models/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const mongoose = require('mongoose'); 3 | 4 | class Models { 5 | constructor() { 6 | this.schemas = {}; 7 | } 8 | 9 | /** 10 | * Load all existing models. 11 | * Should be called only at boot time. 12 | */ 13 | load(config) { 14 | return new Promise(async (yes, no) => { 15 | try { 16 | mongoose.Promise = global.Promise; 17 | await mongoose.connect(config.url, { poolSize: config.poolSize }); 18 | } catch (error) { 19 | return no(new Error(`I can't connect to the database server; ${error.message}`)); 20 | } 21 | 22 | const schemaPath = `${__dirname}/schemas/`; 23 | fs.readdirSync(schemaPath).forEach((file) => { 24 | if (file.match(/(.+)\.js$/)) { 25 | try { 26 | const schema = require.call(null, `${schemaPath}${file}`); 27 | this.schemas[file.replace('.js', '')] = schema(mongoose); 28 | } catch (error) { 29 | return no(new Error(`I can't load model: ${error.stack}`)); 30 | } 31 | } 32 | 33 | return true; 34 | }); 35 | 36 | return yes(); 37 | }); 38 | } 39 | 40 | /** 41 | * Get any model, available after boot. 42 | * @see Models.load() 43 | */ 44 | get(model) { 45 | return new Promise((yes, no) => { 46 | const loadedModel = this.schemas[model]; 47 | if (!loadedModel) { 48 | return no(new Error(`The "${model}" model does not exist.`)); 49 | } 50 | 51 | return yes(loadedModel); 52 | }); 53 | } 54 | } 55 | 56 | module.exports = new Proxy(new Models(), { 57 | get: (target, name) => { 58 | if (target.schemas[name]) { 59 | return target.schemas[name]; 60 | } 61 | 62 | return target[name]; 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 👏🏻 😎 🚀 😻 3 |
4 | 5 | 8 |9 | 10 | [](https://github.com/WhirIO/Server) 11 | [](https://github.com/WhirIO/Client) 12 | [](https://codebeat.co/projects/github-com-whirio-server-master) 13 | [](https://www.codacy.com/app/aichholzer/Server?utm_source=github.com&utm_medium=referral&utm_content=WhirIO/Server&utm_campaign=Badge_Grade) 14 | [](https://gemnasium.com/github.com/WhirIO/Server) 15 | 16 | ### Installation 17 | 18 | - Clone this repository (navigate to its location), 19 | - copy the `.env.example` to `.env` and fill the required values, 20 | - install what's needed: `npm i`, 21 | - run the application: `npm run local` 22 | 23 | **Whir** should now be running on the specified port. 24 | 25 | You can also start **whir** with `npm start`. In this case, however, you will need to export the right environment variables. (Same as defined in .env) 26 | 27 | 28 | ### Take it for a spin 29 | [](https://heroku.com/deploy) 30 | 31 | Start chatting as soon as your Heroku instance is deployed: 32 | * Get the **whir** client: `npm i -g whir.io`, 33 | * connect to your server: `whir.io -u [username] -h [your Heroku URL]`, 34 | * enjoy! 35 | 36 | 37 | ### Notes 38 | As you may have noticed, **whir** does not implement an actual HTTPS server, this is because **whir** was mainly written to run on Heroku and Heroku's SSL termination occurs at its load balancers; thus the application remains a non-HTTPS server. 39 | 40 | Visit [http://whir.io](http://whir.io) for more information. 41 | 42 | 43 | ### Contribute 44 | ``` 45 | fork https://github.com/WhirIO/Server 46 | ``` 47 | 48 | 49 | ### License 50 | 51 | [MIT](https://github.com/WhirIO/Server/blob/master/LICENSE) 52 | -------------------------------------------------------------------------------- /app/models/schemas/channel.js: -------------------------------------------------------------------------------- 1 | module.exports = (mongoose) => { 2 | const schema = new mongoose.Schema( 3 | { 4 | name: { 5 | type: String, 6 | trim: true, 7 | lowercase: true, 8 | required: true 9 | }, 10 | description: { 11 | type: String, 12 | trim: true, 13 | default: null 14 | }, 15 | maxUsers: { 16 | type: Number, 17 | default: 100 18 | }, 19 | connectedUsers: [ 20 | { 21 | user: { 22 | type: String, 23 | required: true 24 | }, 25 | session: { 26 | type: String, 27 | required: true 28 | }, 29 | meta: { 30 | joinedOn: { 31 | type: Date, 32 | default: Date.now 33 | } 34 | } 35 | } 36 | ], 37 | password: { 38 | type: String, 39 | default: null 40 | }, 41 | meta: { 42 | owner: { 43 | type: String, 44 | required: true 45 | }, 46 | createdOn: { 47 | type: Date, 48 | default: Date.now 49 | } 50 | } 51 | }, 52 | { 53 | strict: true, 54 | versionKey: false 55 | } 56 | ); 57 | 58 | schema.statics = { 59 | connect({ channel = null, session = null, password = null }) { 60 | return this.findOneAndUpdate( 61 | { name: channel }, 62 | { 63 | $setOnInsert: { 64 | name: channel, 65 | 'meta.owner': session, 66 | password 67 | } 68 | }, 69 | { upsert: true, new: true } 70 | ).exec(); 71 | }, 72 | 73 | fetch({ channel = null }) { 74 | return this.findOne({ name: channel }) 75 | .lean() 76 | .exec(); 77 | }, 78 | 79 | update({ channel = null, session = null }, update = '') { 80 | const query = { 81 | name: channel 82 | }; 83 | 84 | if (session) { 85 | query['meta.owner'] = session; 86 | } 87 | 88 | return this.findOneAndUpdate(query, update) 89 | .lean() 90 | .exec(); 91 | }, 92 | 93 | removeUser({ channel = null, user = null }) { 94 | return this.update({ channel }, { $pull: { connectedUsers: { user } } }); 95 | } 96 | }; 97 | 98 | return mongoose.model('Channel', schema, 'channels'); 99 | }; 100 | -------------------------------------------------------------------------------- /app/core/commander.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | const m = require('../models'); 3 | 4 | const getStats = (channel, redis) => 5 | new Promise((yes, no) => { 6 | const process = (data) => { 7 | const response = {}; 8 | for (let item = 0; item < data.length; item += 2) { 9 | if (data[item] !== 'channelMessages') { 10 | response.mostActive = data[item]; 11 | } 12 | response[data[item]] = data[item + 1]; 13 | } 14 | return response; 15 | }; 16 | 17 | redis.zrevrange(channel, 0, 1, 'withscores', (error, data) => { 18 | if (error) { 19 | return no(error); 20 | } 21 | return yes(process(data)); 22 | }); 23 | }); 24 | const response = (sucessMessage, channel, data) => { 25 | if (!channel) { 26 | data.alert = "You don't have permission to update this channel."; 27 | } else { 28 | data.message = sucessMessage; 29 | } 30 | }; 31 | 32 | class Commander { 33 | async run({ session = null, channel = null }, input) { 34 | this.data = {}; 35 | this.session = session; 36 | this.channel = channel; 37 | const match = input.match(/^\/([\w]+)\s?(.*)?/); 38 | 39 | try { 40 | ({ 1: this.command, 2: this.query } = match); 41 | await this[this.command](); 42 | } catch (error) { 43 | if (error.message.endsWith('not a function')) { 44 | this.data.message = `${this.command} is not a valid command.`; 45 | } else { 46 | this.data.message = error.message; 47 | } 48 | } 49 | 50 | return this.data; 51 | } 52 | 53 | help() { 54 | this.data.message = 'Available commands:'; 55 | this.data.payload = { 56 | showTitle: true, 57 | pad: '+3', 58 | items: { 59 | '/find': { type: 'string', value: '/find [starts with ...]' }, 60 | '/desc': { type: 'string', value: '/desc [channel description]' }, 61 | '/max': { type: 'string', value: '/max [number]' }, 62 | '/kick': { type: 'string', value: '/kick [username]' }, 63 | '/ban': { type: 'string', value: '/ban [username]' }, 64 | '/purge': { type: 'string', value: '/purge [minutes]' }, 65 | '/channel': { type: 'string', value: '/channel [public || private] [, password]' }, 66 | '/stats': { type: 'string', value: '' }, 67 | '/mute': { type: 'string', value: '' }, 68 | '/unmute': { type: 'string', value: '' }, 69 | '/clear': { type: 'string', value: '' }, 70 | '/exit': { type: 'string', value: '' } 71 | } 72 | }; 73 | } 74 | 75 | async stats() { 76 | const channel = await m.channel.fetch({ channel: this.channel }); 77 | const stats = await getStats(this.channel, this.redis); 78 | this.data.message = 'Channel statistics:'; 79 | this.data.payload = { 80 | showTitle: true, 81 | pad: '+2', 82 | items: { 83 | 'Name:': { type: 'string', value: channel.name }, 84 | 'Description:': { type: 'string', value: channel.description || '...' }, 85 | 'Public:': { type: 'string', value: channel.password ? 'Yes' : 'No' }, 86 | 'Users online:': { type: 'number', value: channel.connectedUsers.length }, 87 | 'Users allowed:': { type: 'number', value: channel.maxUsers }, 88 | 'Online since:': { type: 'date', value: channel.meta.createdOn }, 89 | 'Messages sent:': { type: 'number', value: stats.channelMessages || 0 }, 90 | 'Most active user:': { 91 | type: 'string', 92 | value: `${stats.mostActive} (${stats[stats.mostActive]})` 93 | } 94 | } 95 | }; 96 | } 97 | 98 | async find() { 99 | const channel = await m.channel.fetch({ 100 | channel: this.channel 101 | }); 102 | 103 | const users = channel.connectedUsers 104 | .filter((item) => item.user.indexOf(this.query) >= 0) 105 | .sort(); 106 | this.data.message = !users.length ? 'No matches found.' : `${users.length} matches:`; 107 | this.data.payload = { 108 | showTitle: true, 109 | items: {} 110 | }; 111 | 112 | const usersToShow = users.length >= 10 ? 10 : users.length; 113 | for (let user = 0; user < usersToShow; user += 1) { 114 | this.data.payload.items[users[user].user] = { type: 'string', value: '' }; 115 | } 116 | 117 | if (users.length > usersToShow) { 118 | this.data.payload.items['...'] = { type: 'string', value: '' }; 119 | } 120 | } 121 | 122 | async desc() { 123 | const channel = await m.channel.update( 124 | { 125 | channel: this.channel, 126 | session: this.session 127 | }, 128 | { description: this.query } 129 | ); 130 | 131 | response('Description updated.', channel, this.data); 132 | } 133 | 134 | async max() { 135 | let maxUsers = Math.abs(parseInt(this.query, 10)); 136 | if (!maxUsers) { 137 | throw new Error('You must provide a valid number.'); 138 | } 139 | 140 | maxUsers = maxUsers < 2 ? 2 : maxUsers; 141 | maxUsers = maxUsers > 500 ? 500 : maxUsers; 142 | const channel = await m.channel.update( 143 | { 144 | channel: this.channel, 145 | session: this.session 146 | }, 147 | { maxUsers } 148 | ); 149 | 150 | response(`Max. users set to _${maxUsers}_.`, channel, this.data); 151 | } 152 | 153 | async private() { 154 | const hash = await bcrypt.hash(this.query); 155 | const channel = await m.channel.update( 156 | { 157 | channel: this.channel, 158 | session: this.session 159 | }, 160 | { 'access.public': false, 'access.password': hash } 161 | ); 162 | 163 | response('The channel is now private.', channel, this.data); 164 | } 165 | 166 | async public() { 167 | const channel = await m.channel.update( 168 | { 169 | channel: this.channel, 170 | session: this.session 171 | }, 172 | { 'access.public': true, 'access.password': null } 173 | ); 174 | 175 | response('The channel is now public.', channel, this.data); 176 | } 177 | } 178 | 179 | module.exports = new Commander(); 180 | -------------------------------------------------------------------------------- /app/core/whir.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | const commander = require('./commander'); 3 | const Emitter = require('events').EventEmitter; 4 | const m = require('../models'); 5 | const redis = require('redis'); 6 | const roli = require('roli'); 7 | const WS = require('uws'); 8 | 9 | const reg = (message, client) => 10 | message.replace(/:([\w]+):/g, (match, property) => client.current[property] || match); 11 | const closeSocket = (data, socket) => { 12 | const socketData = typeof data === 'string' ? { message: data } : data; 13 | socketData.channel = socket.current.channel; 14 | socketData.message = reg(socketData.message, socket); 15 | socket.close(1011, JSON.stringify(socketData)); 16 | socket.current = null; 17 | return null; 18 | }; 19 | const socketState = async (socket, channel) => { 20 | if (!socket.current.session) { 21 | return closeSocket('You need a valid session.', socket); 22 | } 23 | 24 | const plainPassword = socket.current.password; 25 | socket.current.password = socket.current.password 26 | ? await bcrypt.hash(socket.current.password, 12) 27 | : null; 28 | if (channel.connectedUsers.length === channel.maxUsers) { 29 | return closeSocket('This channel does not accept more users.', socket); 30 | } 31 | 32 | if (channel.password) { 33 | if (!socket.current.password) { 34 | return closeSocket('This is a private channel; you need a password.', socket); 35 | } 36 | 37 | const match = await bcrypt.compare(plainPassword, channel.password); 38 | if (!match) { 39 | return closeSocket("Your password does not match this channel's.", socket); 40 | } 41 | } 42 | 43 | if (channel.connectedUsers.find((user) => user.user === socket.current.user)) { 44 | return closeSocket('This username (:user:) is already in use in this channel.', socket); 45 | } 46 | 47 | return true; 48 | }; 49 | 50 | class Whir extends Emitter { 51 | constructor({ port, redisConf }) { 52 | super(); 53 | 54 | this.wss = new WS.Server({ port }, () => { 55 | this.emit('info', `Whir: Listening on port ${port}`); 56 | }); 57 | 58 | try { 59 | this.redis = redis.createClient(redisConf.url || redisConf); 60 | this.redis.prefix = redisConf.prefix; 61 | this.redis.on('error', (error) => { 62 | const message = error.origin ? error.origin.message : error.message; 63 | this.emit('error', `Redis: ${message}`); 64 | }); 65 | 66 | this.redis.on('warning', (error) => { 67 | this.emit('warning', error); 68 | }); 69 | 70 | commander.redis = this.redis; 71 | this.serverEvents(); 72 | } catch (error) { 73 | this.emit('error', `Redis: ${error.message}`); 74 | } 75 | } 76 | 77 | serverEvents() { 78 | this.wss.on('connection', async (socket) => { 79 | this.socketEvents(socket); 80 | const channel = await m.channel.connect(socket.current); 81 | if (!(await socketState(socket, channel))) { 82 | return null; 83 | } 84 | 85 | channel.connectedUsers.push(socket.current); 86 | await channel.save(); 87 | this.redis.zadd(socket.current.channel, 'NX', 1, 'channelMessages'); 88 | this.send( 89 | { 90 | message: 'Welcome to the _:channel:_ channel!', 91 | currentUsers: channel.connectedUsers 92 | .map((user) => { 93 | const current = user.user; 94 | return current !== socket.current.user ? current : null; 95 | }) 96 | .filter((user) => user) 97 | }, 98 | socket 99 | ); 100 | 101 | this.broadcast({ message: '-I joined the channel.-', action: 'join' }, socket); 102 | 103 | return true; 104 | }); 105 | 106 | this.wss.on('close', (socket) => { 107 | this.emit('info', `A socket has been closed: ${socket}`); 108 | }); 109 | } 110 | 111 | socketEvents(socket) { 112 | socket.current = { 113 | channel: socket.upgradeReq.headers['x-whir-channel'] || roli({ case: 'lower' }), 114 | user: socket.upgradeReq.headers['x-whir-user'], 115 | password: socket.upgradeReq.headers['x-whir-pass'] || null, 116 | session: socket.upgradeReq.headers['x-whir-session'] || null 117 | }; 118 | 119 | socket.on('message', this.messageHandler.bind(this, socket)); 120 | socket.on('close', async () => { 121 | if (!socket.current) { 122 | return; 123 | } 124 | 125 | await m.channel.removeUser(socket.current); 126 | this.broadcast( 127 | { 128 | user: socket.current.user, 129 | message: '-I left the channel.-', 130 | action: 'leave' 131 | }, 132 | socket 133 | ); 134 | }); 135 | } 136 | 137 | async messageHandler(socket, data) { 138 | try { 139 | const payload = Buffer.from(data).toString('utf8'); 140 | let parsedData = JSON.parse(payload); 141 | if (parsedData.message.match(/^\/[\w]/)) { 142 | parsedData = await commander.run(socket.current, parsedData.message); 143 | return this.send(parsedData, socket); 144 | } 145 | 146 | return this.broadcast(parsedData, socket); 147 | } catch (error) { 148 | return this.emit('error', `Incoming message: ${error.message}`); 149 | } 150 | } 151 | 152 | send(data, client) { 153 | data.channel = client.current.channel; 154 | if (data.alert) { 155 | data.message = data.alert; 156 | data.alert = true; 157 | } 158 | data.message = reg(data.message, client); 159 | client.send(JSON.stringify(data), { binary: true, mask: true }, (error) => { 160 | if (error) { 161 | this.emit('error', `Outgoing message: ${error.message}`); 162 | } 163 | }); 164 | return this; 165 | } 166 | 167 | /** 168 | * Broadcast a message to all connected clients, except the 169 | * one initiating the request. 170 | * 171 | * @param data - The data being broadcast. 172 | * @param client - The current client. 173 | */ 174 | broadcast(data, client) { 175 | if (this.wss && this.wss.clients) { 176 | if (this.redis) { 177 | this.redis.zincrby(client.current.channel, 1, 'channelMessages'); 178 | this.redis.zincrby(client.current.channel, 1, client.current.user); 179 | } 180 | 181 | this.wss.clients.forEach((socket) => { 182 | if (socket.current.channel === client.current.channel && socket !== client) { 183 | data.user = client.current.user; 184 | this.send(data, socket); 185 | } 186 | }); 187 | } 188 | } 189 | } 190 | 191 | module.exports = Whir; 192 | --------------------------------------------------------------------------------