Эх сурвалжийг харах

Merge pull request #41 from Musare/experimental

Merge from experimental to staging
Vos 5 жил өмнө
parent
commit
12574f4e44
100 өөрчлөгдсөн 5304 нэмэгдсэн , 6608 устгасан
  1. 4 1
      .gitignore
  2. 0 2
      .travis.yml
  3. 56 34
      README.md
  4. 9 2
      backend/config/template.json
  5. 83 0
      backend/core.js
  6. 178 205
      backend/index.js
  7. 54 9
      backend/logic/actions/apis.js
  8. 9 6
      backend/logic/actions/hooks/adminRequired.js
  9. 8 5
      backend/logic/actions/hooks/loginRequired.js
  10. 10 7
      backend/logic/actions/hooks/ownerRequired.js
  11. 16 14
      backend/logic/actions/news.js
  12. 34 32
      backend/logic/actions/playlists.js
  13. 22 24
      backend/logic/actions/punishments.js
  14. 23 20
      backend/logic/actions/queueSongs.js
  15. 27 18
      backend/logic/actions/reports.js
  16. 33 30
      backend/logic/actions/songs.js
  17. 170 92
      backend/logic/actions/stations.js
  18. 129 65
      backend/logic/actions/users.js
  19. 32 26
      backend/logic/api.js
  20. 220 224
      backend/logic/app.js
  21. 101 91
      backend/logic/cache/index.js
  22. 198 179
      backend/logic/db/index.js
  23. 2 1
      backend/logic/db/schemas/queueSong.js
  24. 4 1
      backend/logic/db/schemas/report.js
  25. 2 1
      backend/logic/db/schemas/song.js
  26. 1 0
      backend/logic/db/schemas/user.js
  27. 78 94
      backend/logic/discord.js
  28. 161 138
      backend/logic/io.js
  29. 151 178
      backend/logic/logger.js
  30. 30 35
      backend/logic/mail/index.js
  31. 4 1
      backend/logic/mail/schemas/passwordRequest.js
  32. 4 1
      backend/logic/mail/schemas/resetPasswordRequest.js
  33. 4 1
      backend/logic/mail/schemas/verifyEmail.js
  34. 110 58
      backend/logic/notifications.js
  35. 79 74
      backend/logic/playlists.js
  36. 87 79
      backend/logic/punishments.js
  37. 80 72
      backend/logic/songs.js
  38. 61 50
      backend/logic/spotify.js
  39. 194 180
      backend/logic/stations.js
  40. 127 120
      backend/logic/tasks.js
  41. 208 127
      backend/logic/utils.js
  42. 4 2
      backend/package.json
  43. 0 2017
      backend/yarn.lock
  44. 1 1
      docker-compose.yml
  45. 1 3
      frontend/.eslintrc
  46. 70 50
      frontend/App.vue
  47. 18 1
      frontend/api/auth.js
  48. 3 4
      frontend/auth.js
  49. 4 0
      frontend/components/404.vue
  50. 46 43
      frontend/components/Admin/EditStation.vue
  51. 25 40
      frontend/components/Admin/News.vue
  52. 16 13
      frontend/components/Admin/Punishments.vue
  53. 40 37
      frontend/components/Admin/QueueSongs.vue
  54. 19 14
      frontend/components/Admin/Reports.vue
  55. 71 42
      frontend/components/Admin/Songs.vue
  56. 32 17
      frontend/components/Admin/Stations.vue
  57. 11 10
      frontend/components/Admin/Statistics.vue
  58. 10 12
      frontend/components/Admin/Users.vue
  59. 7 5
      frontend/components/MainFooter.vue
  60. 23 21
      frontend/components/MainHeader.vue
  61. 35 34
      frontend/components/Modals/AddSongToPlaylist.vue
  62. 34 34
      frontend/components/Modals/AddSongToQueue.vue
  63. 2 5
      frontend/components/Modals/CreateCommunityStation.vue
  64. 65 38
      frontend/components/Modals/EditNews.vue
  65. 744 267
      frontend/components/Modals/EditSong.vue
  66. 26 27
      frontend/components/Modals/EditStation.vue
  67. 9 5
      frontend/components/Modals/EditUser.vue
  68. 2 0
      frontend/components/Modals/IssuesModal.vue
  69. 15 7
      frontend/components/Modals/Login.vue
  70. 4 3
      frontend/components/Modals/MobileAlert.vue
  71. 5 6
      frontend/components/Modals/Playlists/Create.vue
  72. 68 76
      frontend/components/Modals/Playlists/Edit.vue
  73. 23 9
      frontend/components/Modals/Register.vue
  74. 24 27
      frontend/components/Modals/Report.vue
  75. 33 13
      frontend/components/Modals/ViewPunishment.vue
  76. 10 7
      frontend/components/Modals/WhatIsNew.vue
  77. 37 32
      frontend/components/Sidebars/Playlist.vue
  78. 42 40
      frontend/components/Sidebars/SongsList.vue
  79. 18 5
      frontend/components/Sidebars/UsersList.vue
  80. 0 461
      frontend/components/Station/CommunityHeader.vue
  81. 279 306
      frontend/components/Station/Station.vue
  82. 74 67
      frontend/components/Station/StationHeader.vue
  83. 5 3
      frontend/components/User/ResetPassword.vue
  84. 34 30
      frontend/components/User/Settings.vue
  85. 19 12
      frontend/components/User/Show.vue
  86. 9 13
      frontend/components/UserIdToUsername.vue
  87. 3 0
      frontend/components/pages/About.vue
  88. 27 34
      frontend/components/pages/Admin.vue
  89. 14 7
      frontend/components/pages/Banned.vue
  90. 407 393
      frontend/components/pages/Home.vue
  91. 21 17
      frontend/components/pages/News.vue
  92. 1 0
      frontend/components/pages/Privacy.vue
  93. 4 1
      frontend/components/pages/Team.vue
  94. 1 0
      frontend/components/pages/Terms.vue
  95. 3 0
      frontend/dist/assets/arrow_down.svg
  96. 3 0
      frontend/dist/assets/arrow_up.svg
  97. 0 0
      frontend/dist/assets/favicon/android-chrome-144x144.png
  98. 0 0
      frontend/dist/assets/favicon/android-chrome-192x192.png
  99. 0 0
      frontend/dist/assets/favicon/android-chrome-36x36.png
  100. 0 0
      frontend/dist/assets/favicon/android-chrome-48x48.png

+ 4 - 1
.gitignore

@@ -5,6 +5,7 @@ Thumbs.db
 .vscode/
 .vagrant/
 
+.env
 startRedis.cmd
 startMongo.cmd
 .database
@@ -19,6 +20,8 @@ backend/config/default.json
 
 # Frontend
 frontend/yarn-error.log
+frontend/bundle-stats.json
+frontend/bundle-report.html
 frontend/node_modules/
 frontend/dist/build/
 !frontend/dist/lofig.min.js
@@ -26,7 +29,7 @@ frontend/dist/index.html
 frontend/dist/config/default.json
 
 npm
+node_modules
 
 # Logs
 log/
-.env

+ 0 - 2
.travis.yml

@@ -32,11 +32,9 @@ jobs:
         - docker-compose build frontend # build frontend
         - docker-compose up -d frontend # start frontend
         - docker-compose exec frontend /bin/bash -c "cd app && yarn lint" # using eslint to check for formatting/linting issues
-        - docker-compose exec frontend /bin/bash -c "cd app && snyk test --dev" # scan for dependency/dev. dependency vunerabilities
     - stage: backend
       script:
         - docker-compose up -d mongo # start mongo (users automatically setup)
         - docker-compose up -d redis # start redis
         - docker-compose build backend # build backend
         - docker-compose up -d backend # start backend
-        - docker-compose exec backend /bin/bash -c "cd app && snyk test --dev" # scan for dependency/dev. dependency vunerabilities

+ 56 - 34
README.md

@@ -1,4 +1,5 @@
 
+  
 # MusareNode
 
 Based off of the original [Musare](https://github.com/Musare/MusareMeteor), which utilized Meteor.
@@ -56,47 +57,58 @@ Once you've installed the required tools:
 
 3. `cp backend/config/template.json backend/config/default.json`
 
-   Values:
-   The `mode` should be either "development" or "production". No more explanation needed.  
-   The `secret` key can be whatever. It's used by express's session module.  
-   The `domain` should be the url where the site will be accessible from, usually `http://localhost` for non-Docker.  
-   The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.  
-   The `serverPort` should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.  
-   `isDocker` if you are using Docker or not.  
-   The `apis.youtube.key` value can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key.  
-   The `apis.recaptcha.secret` value can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).  
-   The `apis.github` values can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.  
-   The `apis.discord.token` is the token for the Discord bot.  
-   The `apis.discord.loggingServer` is the Discord logging server id.  
-   The `apis.discord.loggingChannel` is the Discord logging channel id.  
-   The `apis.mailgun` values can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it.  
-   The `apis.spotify` values can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.  
-   The `redis.url` url should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.  
-   The `redis.password` should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.  
-   The `mongo.url` needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.  
-   The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.  
-   The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
+|Property|Description|
+|--|--|
+|`mode`|Should be either `development` or `production`. No more explanation needed.|
+|`secret`|Whatever you want - used by express's session module.|
+|`domain`|Should be the url where the site will be accessible from,usually `http://localhost` for non-Docker.|
+|`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
+|`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
+|`isDocker`|Self-explanatory. Are you using Docker?|
+|`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
+|`apis.youtube.key`|Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key.|
+|`apis.recaptcha.secret`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
+|`apis.github`|Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.|
+|`apis.discord.token`|Token for the Discord bot.|
+|`apis.discord.loggingServer`|Server ID of the Discord logging server.|
+|`apis.discord.loggingChannel`|ID of the channel to be used in the Discord logging server.|
+|`apis.mailgun`|Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it.|
+|`apis.spotify`|Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.|
+|`apis.discogs`|Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it.|
+|`redis.url`|Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.|
+|`redis.password`|Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.|
+|`mongo.url`|Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.|
+|`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
+|`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
 
 4. `cp frontend/build/config/template.json frontend/build/config/default.json`
 
-   Values:  
-   The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.
-   The `frontendDomain` should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker.
-   The `frontendPort` should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker.
-   The `recaptcha.key` value can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).
-   The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.
-   The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.
-   The `siteSettings.logo` should be the path to the logo image, by default it is `/assets/wordmark.png`.
-   The `siteSettings.siteName` should be the name of the site.
-   The `siteSettings.socialLinks.` `github`,`twitter`,`facebook` and `github` are set to the official Musare accounts by default but can be changed. 
+|Property|Description|
+|--|--|
+|`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
+|`frontendDomain`|Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker.|
+|`frontendPort`|Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker.|
+|`recaptcha.key`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
+|`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
+|`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
+|`siteSettings.logo`|Path to the logo image, by default it is `/assets/wordmark.png`.|
+|`siteSettings.siteName`|Should be the name of the site.|
+|`siteSettings.socialLinks`|`github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed.|
 
-Now you have different paths here.
+5. Simply `cp .env.example .env` to setup your environment variables.
+
+6. To setup [snyk](https://snyk.io/) (which is what we use for our precommit git-hooks), you will need to:
+- Setup an account
+- Go to [settings](https://app.snyk.io/account)
+- Copy the API token and set it as your `SNYK_TOKEN` environment variable.
+
+We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
 
 ### Installing with Docker
 
 _Configuration_
 
-To configure docker simply `cp .env.example .env` and configure the .env file to match your settings in `backend/config/default.json`.  
+To configure docker configure the `.env` file to match your settings in `backend/config/default.json`.  
 The configurable ports will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. 
 `COMPOSE_PROJECT_NAME` should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine.
 `FRONTEND_MODE` should be either `dev` or `prod` (self-explanatory).
@@ -268,9 +280,9 @@ Run this command in your shell. You will have to do this command for every shell
 
    `yarn global add node-gyp`.
 
-5. In both `frontend` and `backend` folders, do `yarn install`.
+5. Run `yarn run bootstrap` to install dependencies and dev-dependencies for both the frontend and backend.
 
-6. `nodemon backend/index.js`
+6. Either execute `yarn run dev:frontend` and `yarn run dev:backend` separately, or in parallel with `yarn dev`.
 
 ### Calling Toasts
 
@@ -291,3 +303,13 @@ docker-compose exec mongo mongo admin
 use musare
 db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
 db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
+```
+
+### Adding a package
+
+We use lerna to add an additional package to either the frontend or the backend.
+
+For example, this is how we would to add the `webpack-bundle-analyser` package as a dev-dependency to the frontend:
+```
+npx lerna add webpack-bundle-analyser --scope=musare-frontend --dev
+```

+ 9 - 2
backend/config/template.json

@@ -5,7 +5,8 @@
 	"frontendPort": 80,
 	"serverDomain": "http://localhost:8080",
   	"serverPort": 8080,
-  	"isDocker": true,
+	"isDocker": true,
+	"fancyConsole": true,
 	"apis": {
 		"youtube": {
 			"key": ""
@@ -32,6 +33,11 @@
 			"client": "",
 			"secret": "",
 			"enabled": false
+		},
+		"discogs": {
+			"client": "",
+			"secret": "",
+			"enabled": false
 		}
 	},
 	"cors": {
@@ -50,6 +56,7 @@
 	},
   	"cookie": {
 	  	"domain": "localhost",
-	  	"secure": false
+		"secure": false,
+		"SIDname": "SID"  
 	}
 }

+ 83 - 0
backend/core.js

@@ -0,0 +1,83 @@
+const EventEmitter = require('events');
+
+const bus = new EventEmitter();
+
+module.exports = class {
+	constructor(name, moduleManager) {
+		this.name = name;
+		this.moduleManager = moduleManager;
+		this.lockdown = false;
+		this.dependsOn = [];
+		this.eventHandlers = [];
+		this.state = "NOT_INITIALIZED";
+		this.stage = 0;
+		this.lastTime = 0;
+		this.totalTimeInitialize = 0;
+		this.timeDifferences = [];
+		this.failed = false;
+	}
+
+	_initialize() {
+		this.logger = this.moduleManager.modules["logger"];
+		this.setState("INITIALIZING");
+
+		this.initialize().then(() => {
+			this.setState("INITIALIZED");
+			this.setStage(0);
+			this.moduleManager.printStatus();
+		}).catch(async (err) => {			
+			this.failed = true;
+
+			this.logger.error(err.stack);
+
+			this.moduleManager.aModuleFailed(this);
+		});
+	}
+
+	_onInitialize() {
+		return new Promise(resolve => bus.once(`stateChange:${this.name}:INITIALIZED`, resolve));
+	}
+
+	_isInitialized() {
+		return new Promise(resolve => {
+			if (this.state === "INITIALIZED") resolve();
+		});
+	}
+
+	_isNotLocked() {
+		return new Promise((resolve, reject) => {
+			if (this.state === "LOCKDOWN") reject();
+			else resolve();
+		});
+	}
+
+	setState(state) {
+		this.state = state;
+		bus.emit(`stateChange:${this.name}:${state}`);
+		this.logger.info(`MODULE_STATE`, `${state}: ${this.name}`);
+	}
+
+	setStage(stage) {
+		if (stage !== 1)
+			this.totalTimeInitialize += (Date.now() - this.lastTime);
+		//this.timeDifferences.push(this.stage + ": " + (Date.now() - this.lastTime) + "ms");
+		this.timeDifferences.push(Date.now() - this.lastTime);
+
+		this.lastTime = Date.now();
+		this.stage = stage;
+		this.moduleManager.printStatus();
+	}
+
+	_validateHook() {
+		return Promise.race([this._onInitialize, this._isInitialized]).then(
+			() => this._isNotLocked()
+		);
+	}
+
+	_lockdown() {
+		if (this.lockdown) return;
+		this.lockdown = true;
+		this.setState("LOCKDOWN");
+		this.moduleManager.printStatus();
+	}
+}

+ 178 - 205
backend/index.js

@@ -1,223 +1,196 @@
 'use strict';
 
+const util = require("util");
+
 process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 
-const async = require('async');
-const fs = require('fs');
-
-//const Discord = require("discord.js");
-//const client = new Discord.Client();
-const db = require('./logic/db');
-const app = require('./logic/app');
-const mail = require('./logic/mail');
-const api = require('./logic/api');
-const io = require('./logic/io');
-const stations = require('./logic/stations');
-const songs = require('./logic/songs');
-const spotify = require('./logic/spotify');
-const playlists = require('./logic/playlists');
-const cache = require('./logic/cache');
-const discord = require('./logic/discord');
-const notifications = require('./logic/notifications');
-const punishments = require('./logic/punishments');
-const logger = require('./logic/logger');
-const tasks = require('./logic/tasks');
-const config = require('config');
-
-let currentComponent;
-let initializedComponents = [];
-let lockdownB = false;
+const config = require("config");
 
 process.on('uncaughtException', err => {
-	if (lockdownB || err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
+	if (err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
 	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 
-const getError = (err) => {
-	let error = 'An error occurred.';
-	if (typeof err === "string") error = err;
-	else if (err.message) {
-		if (err.message !== 'Validation failed') error = err.message;
-		else error = err.errors[Object.keys(err.errors)].message;
+const fancyConsole = config.get("fancyConsole");
+
+class ModuleManager {
+	constructor() {
+		this.modules = {};
+		this.modulesInitialized = 0;
+		this.totalModules = 0;
+		this.modulesLeft = [];
+		this.i = 0;
+		this.lockdown = false;
+		this.fancyConsole = fancyConsole;
 	}
-	return error;
-};
-
-function lockdown() {
-	if (lockdownB) return;
-	lockdownB = true;
-	initializedComponents.forEach((component) => {
-		component._lockdown();
-	});
-	console.log("Backend locked down.");
-}
 
-function errorCb(message, err, component) {
-	err = getError(err);
-	lockdown();
-	discord.sendAdminAlertMessage(message, "#FF0000", message, true, [{name: "Error:", value: err, inline: false}, {name: "Component:", value: component, inline: true}]); //TODO Maybe due to lockdown this won't work, and what if the Discord module was the one that failed?
-}
+	addModule(moduleName) {
+		console.log("add module", moduleName);
+		const moduleClass = new require(`./logic/${moduleName}`);
+		this.modules[moduleName] = new moduleClass(moduleName, this);
+		this.totalModules++;
+		this.modulesLeft.push(moduleName);
+	}
 
-function moduleStartFunction() {
-	logger.info("MODULE_START", `Starting to initialize component '${currentComponent}'`);
-}
+	initialize() {
+		if (!this.modules["logger"]) return console.error("There is no logger module");
+		this.logger = this.modules["logger"];
+		if (this.fancyConsole) {
+			this.replaceConsoleWithLogger();
+			this.logger.reservedLines = Object.keys(this.modules).length + 5;
+		}
+		
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			if (this.lockdown) break;
+
+			module._onInitialize().then(() => {
+				this.moduleInitialized(moduleName);
+			});
+
+			let dependenciesInitializedPromises = [];
+			
+			module.dependsOn.forEach(dependencyName => {
+				let dependency = this.modules[dependencyName];
+				dependenciesInitializedPromises.push(dependency._onInitialize());
+			});
+
+			module.lastTime = Date.now();
 
-async.waterfall([
-
-	// setup our Discord module
-	(next) => {
-		currentComponent = 'Discord';
-		moduleStartFunction();
-		discord.init(config.get('apis.discord').token, config.get('apis.discord').loggingChannel, errorCb, () => {
-			next();
-		});
-	},
-
-	// setup our Redis cache
-	(next) => {
-		currentComponent = 'Cache';
-		moduleStartFunction();
-		cache.init(config.get('redis').url, config.get('redis').password, errorCb, () => {
-			next();
-		});
-	},
-
-	// setup our MongoDB database
-	(next) => {
-		initializedComponents.push(cache);
-		currentComponent = 'DB';
-		moduleStartFunction();
-		db.init(config.get("mongo").url, errorCb, next);
-	},
-
-	// setup the express server
-	(next) => {
-		initializedComponents.push(db);
-		currentComponent = 'App';
-		moduleStartFunction();
-		app.init(next);
-	},
-
-	// setup the mail
-	(next) => {
-		initializedComponents.push(app);
-		currentComponent = 'Mail';
-		moduleStartFunction();
-		mail.init(next);
-	},
-
-	// setup the Spotify
-	(next) => {
-		initializedComponents.push(mail);
-		currentComponent = 'Spotify';
-		moduleStartFunction();
-		spotify.init(next);
-	},
-
-	// setup the socket.io server (all client / server communication is done over this)
-	(next) => {
-		initializedComponents.push(spotify);
-		currentComponent = 'IO';
-		moduleStartFunction();
-		io.init(next);
-	},
-
-	// setup the punishment system
-	(next) => {
-		initializedComponents.push(io);
-		currentComponent = 'Punishments';
-		moduleStartFunction();
-		punishments.init(next);
-	},
-
-	// setup the notifications
-	(next) => {
-		initializedComponents.push(punishments);
-		currentComponent = 'Notifications';
-		moduleStartFunction();
-		notifications.init(config.get('redis').url, config.get('redis').password, errorCb, next);
-	},
-
-	// setup the stations
-	(next) => {
-		initializedComponents.push(notifications);
-		currentComponent = 'Stations';
-		moduleStartFunction();
-		stations.init(next)
-	},
-
-	// setup the songs
-	(next) => {
-		initializedComponents.push(stations);
-		currentComponent = 'Songs';
-		moduleStartFunction();
-		songs.init(next)
-	},
-
-	// setup the playlists
-	(next) => {
-		initializedComponents.push(songs);
-		currentComponent = 'Playlists';
-		moduleStartFunction();
-		playlists.init(next)
-	},
-
-	// setup the API
-	(next) => {
-		initializedComponents.push(playlists);
-		currentComponent = 'API';
-		moduleStartFunction();
-		api.init(next)
-	},
-
-	// setup the logger
-	(next) => {
-		initializedComponents.push(api);
-		currentComponent = 'Logger';
-		moduleStartFunction();
-		logger.init(next)
-	},
-
-	// setup the tasks system
-	(next) => {
-		initializedComponents.push(logger);
-		currentComponent = 'Tasks';
-		moduleStartFunction();
-		tasks.init(next)
-	},
-
-	// setup the frontend for local setups
-	(next) => {
-		initializedComponents.push(tasks);
-		currentComponent = 'Windows';
-		moduleStartFunction();
-		if (!config.get("isDocker") && !(config.get("mode") === "development" || config.get("mode") === "dev")) {
-			const express = require('express');
-			const app = express();
-			app.listen(config.get("frontendPort"));
-			const rootDir = __dirname.substr(0, __dirname.lastIndexOf("backend")) + "frontend/dist/build";
-
-			app.use(express.static(rootDir, {
-				setHeaders: function(res, path) {
-					if (path.indexOf('.html') !== -1) res.setHeader('Cache-Control', 'public, max-age=0');
-					else res.setHeader('Cache-Control', 'public, max-age=2628000');
-				}
-			}));
-
-			app.get("/*", (req, res) => {
-				res.sendFile(`${rootDir}/index.html`);
+			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+				if (this.lockdown) return;
+				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+				module._initialize();
 			});
 		}
-		if (lockdownB) return;
-		next();
 	}
-], (err) => {
-	if (err && err !== true) {
-		lockdown();
-		discord.sendAdminAlertMessage("An error occurred while initializing the backend server.", "#FF0000", "Startup error", true, [{name: "Error:", value: err, inline: false}, {name: "Component:", value: currentComponent, inline: true}]);
-		console.error('An error occurred while initializing the backend server');
-	} else {
-		discord.sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
-		console.info('Backend server has been successfully started');
+
+	async printStatus() {
+		try { await Promise.race([this.logger._onInitialize, this.logger._isInitialized]); } catch { return; }
+		if (!this.fancyConsole) return;
+		
+		let colors = this.logger.colors;
+
+		const rows = process.stdout.rows;
+
+		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+		process.stdout.clearScreenDown();
+
+		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
+
+		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
+
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
+
+			let tabs = Array(tabsAmount).fill(`\t`).join("");
+
+			let timing = module.timeDifferences.map((timeDifference) => {
+				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
+			}).join(", ");
+
+			let stateColor;
+			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
+			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
+			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
+			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
+			else stateColor = colors.FgYellow;
+			
+			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
+		}
+	}
+
+	moduleInitialized(moduleName) {
+		this.modulesInitialized++;
+		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
+
+		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
+
+		if (this.modulesLeft.length === 0) this.allModulesInitialized();
+	}
+
+	allModulesInitialized() {
+		this.logger.success("MODULE_MANAGER", "All modules have started!");
+		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
+	}
+
+	aModuleFailed(failedModule) {
+		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
+		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
+
+		this._lockdown();
+	}
+
+	replaceConsoleWithLogger() {
+		this.oldConsole = {
+			log: console.log,
+			debug: console.debug,
+			info: console.info,
+			warn: console.warn,
+			error: console.error
+		};
+		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
+	}
+
+	replaceLoggerWithConsole() {
+		console.log = this.oldConsole.log;
+		console.debug = this.oldConsole.debug;
+		console.info = this.oldConsole.info;
+		console.warn = this.oldConsole.warn;
+		console.error = this.oldConsole.error;
+	}
+
+	_lockdown() {
+		this.lockdown = true;
+		
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			if (module.lockdownImmune) continue;
+			module._lockdown();
+		}
 	}
+}
+
+const moduleManager = new ModuleManager();
+
+module.exports = moduleManager;
+
+moduleManager.addModule("cache");
+moduleManager.addModule("db");
+moduleManager.addModule("mail");
+moduleManager.addModule("api");
+moduleManager.addModule("app");
+moduleManager.addModule("discord");
+moduleManager.addModule("io");
+moduleManager.addModule("logger");
+moduleManager.addModule("notifications");
+moduleManager.addModule("playlists");
+moduleManager.addModule("punishments");
+moduleManager.addModule("songs");
+moduleManager.addModule("spotify");
+moduleManager.addModule("stations");
+moduleManager.addModule("tasks");
+moduleManager.addModule("utils");
+
+moduleManager.initialize();
+
+process.stdin.on("data", function (data) {
+    if(data.toString() === "lockdown\r\n"){
+        console.log("Locking down.");
+       	moduleManager._lockdown();
+    }
 });
+
+
+if (fancyConsole) {
+	const rows = process.stdout.rows;
+
+	for(let i = 0; i < rows; i++) {
+		process.stdout.write("\n");
+	}
+}

+ 54 - 9
backend/logic/actions/apis.js

@@ -1,11 +1,14 @@
 'use strict';
 
-const 	request = require('request'),
-		config  = require('config'),
-		async 	= require('async'),
-		utils 	= require('../utils'),
-		logger 	= require('../logger'),
-		hooks 	= require('./hooks');
+const request = require("request");
+const config = require("config");
+const async = require("async");
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 module.exports = {
 
@@ -34,11 +37,11 @@ module.exports = {
 			(res, body, next) => {
 				next(null, JSON.parse(body));
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			console.log(data.error);
 			if (err || data.error) {
 				if (!err) err = data.error.message;
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -66,6 +69,48 @@ module.exports = {
 		});
 	}),
 
+	/**
+	 * Gets Discogs data
+	 *
+	 * @param session
+	 * @param query - the query
+	 * @param cb
+	 */
+	searchDiscogs: hooks.adminRequired((session, query, page, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				const params = [
+					`q=${encodeURIComponent(query)}`,
+					`per_page=20`,
+					`page=${page}`
+				].join('&');
+		
+				const options = {
+					url: `https://api.discogs.com/database/search?${params}`,
+					headers: {
+						"User-Agent": "Request",
+						"Authorization": `Discogs key=${config.get("apis.discogs.client")}, secret=${config.get("apis.discogs.secret")}`
+					}
+				};
+		
+				request(options, (err, res, body) => {
+					if (err) next(err);
+					body = JSON.parse(body);
+					next(null, body);
+					if (body.error) next(body.error);
+				});
+			}
+		], async (err, body) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("APIS_SEARCH_DISCOGS", `Searching discogs failed with query "${query}". "${err}"`);
+				return cb({status: 'failure', message: err});
+			}
+			logger.success('APIS_SEARCH_DISCOGS', `User "${userId}" searched Discogs succesfully for query "${query}".`);
+			cb({status: 'success', results: body.results, pages: body.pagination.pages});
+		});
+	}),
+
 	/**
 	 * Joins a room
 	 *
@@ -88,7 +133,7 @@ module.exports = {
 	 * @param cb
 	 */
 	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users' || page === 'statistics') {
+		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users' || page === 'statistics' || page === 'punishments') {
 			utils.socketJoinRoom(session.socketId, `admin.${page}`);
 		}
 		cb({});

+ 9 - 6
backend/logic/actions/hooks/adminRequired.js

@@ -1,9 +1,12 @@
-const cache = require('../../cache');
-const db = require('../../db');
-const utils = require('../../utils');
-const logger = require('../../logger');
 const async = require('async');
 
+const moduleManager = require("../../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 module.exports = function(next) {
 	return function(session) {
 		let args = [];
@@ -23,9 +26,9 @@ module.exports = function(next) {
 				if (user.role !== 'admin') return next('Insufficient permissions.');
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}

+ 8 - 5
backend/logic/actions/hooks/loginRequired.js

@@ -1,8 +1,11 @@
-const cache = require('../../cache');
-const utils = require('../../utils');
-const logger = require('../../logger');
 const async = require('async');
 
+const moduleManager = require("../../../index");
+
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 module.exports = function(next) {
 	return function(session) {
 		let args = [];
@@ -17,9 +20,9 @@ module.exports = function(next) {
 				this.session = session;
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("LOGIN_REQUIRED", `User failed to pass login required check.`);
 				return cb({status: 'failure', message: err});
 			}

+ 10 - 7
backend/logic/actions/hooks/ownerRequired.js

@@ -1,9 +1,12 @@
-const cache = require('../../cache');
-const db = require('../../db');
-const utils = require('../../utils');
-const logger = require('../../logger');
 const async = require('async');
-const stations = require('../../stations');
+
+const moduleManager = require("../../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const stations = moduleManager.modules["stations"];
 
 module.exports = function(next) {
 	return function(session, stationId) {
@@ -29,9 +32,9 @@ module.exports = function(next) {
 				if (station.type === 'community' && station.owner === session.userId) return next(true);
 				next('Invalid permissions.');
 			}
-		], (err) => {
+		], async (err) => {
 			if (err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("OWNER_REQUIRED", `User failed to pass owner required check for station "${stationId}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}

+ 16 - 14
backend/logic/actions/news.js

@@ -2,11 +2,13 @@
 
 const async = require('async');
 
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
 const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 cache.sub('news.create', news => {
 	utils.socketsFromUser(news.createdBy, sockets => {
@@ -45,9 +47,9 @@ module.exports = {
 			(next) => {
 				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -71,9 +73,9 @@ module.exports = {
 				data.createdAt = Date.now();
 				db.models.news.create(data, next);
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_CREATE", `Creating news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -94,9 +96,9 @@ module.exports = {
 			(next) => {
 				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -115,9 +117,9 @@ module.exports = {
 	//TODO Pass in an id, not an object
 	//TODO Fix this
 	remove: hooks.adminRequired((session, news, cb, userId) => {
-		db.models.news.deleteOne({ _id: news._id }, err => {
+		db.models.news.deleteOne({ _id: news._id }, async err => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
@@ -138,9 +140,9 @@ module.exports = {
 	 */
 	//TODO Fix this
 	update: hooks.adminRequired((session, _id, news, cb, userId) => {
-		db.models.news.updateOne({ _id }, news, { upsert: true }, err => {
+		db.models.news.updateOne({ _id }, news, { upsert: true }, async err => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			} else {

+ 34 - 32
backend/logic/actions/playlists.js

@@ -1,14 +1,16 @@
 'use strict';
 
-const db = require('../db');
-const io = require('../io');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
-const hooks = require('./hooks');
 const async = require('async');
-const playlists = require('../playlists');
-const songs = require('../songs');
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const playlists = moduleManager.modules["playlists"];
+const songs = moduleManager.modules["songs"];
 
 cache.sub('playlist.create', playlistId => {
 	playlists.getPlaylist(playlistId, (err, playlist) => {
@@ -90,9 +92,9 @@ let lib = {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
 				next(null, playlist.songs[0]);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -116,9 +118,9 @@ let lib = {
 			(next) => {
 				db.models.playlist.find({ createdBy: userId }, next);
 			}
-		], (err, playlists) => {
+		], async (err, playlists) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${userId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -155,9 +157,9 @@ let lib = {
 				}, next);
 			}
 
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -187,9 +189,9 @@ let lib = {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -220,9 +222,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next)
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -280,9 +282,9 @@ let lib = {
 				});
 			}
 		],
-		(err, playlist, newSong) => {
+		async (err, playlist, newSong) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
@@ -329,9 +331,9 @@ let lib = {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
@@ -370,9 +372,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
@@ -400,9 +402,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -459,9 +461,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -515,9 +517,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -540,9 +542,9 @@ let lib = {
 			(next) => {
 				playlists.deletePlaylist(playlistId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}

+ 22 - 24
backend/logic/actions/punishments.js

@@ -1,17 +1,20 @@
 'use strict';
 
-const 	hooks 	    = require('./hooks'),
-	 	async 	    = require('async'),
-	 	logger 	    = require('../logger'),
-	 	utils 	    = require('../utils'),
-		cache       = require('../cache'),
-	 	db 	        = require('../db'),
-		punishments = require('../punishments');
+const async = require('async');
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const logger = moduleManager.modules["logger"];
+const utils = moduleManager.modules["utils"];
+const cache = moduleManager.modules["cache"];
+const db = moduleManager.modules["db"];
+const punishments = moduleManager.modules["punishments"];
 
 cache.sub('ip.ban', data => {
+	utils.emitToRoom('admin.punishments', 'event:admin.punishment.added', data.punishment);
 	utils.socketsFromIP(data.ip, sockets => {
 		sockets.forEach(socket => {
-			socket.emit('keep.event:banned', data.punishment);
 			socket.disconnect(true);
 		});
 	});
@@ -30,9 +33,9 @@ module.exports = {
 			(next) => {
 				db.models.punishment.find({}, next);
 			}
-		], (err, punishments) => {
+		], async (err, punishments) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			}
@@ -99,24 +102,19 @@ module.exports = {
 
 			(next) => {
 				punishments.addPunishment('banUserIp', value, reason, expiresAt, userId, next)
-			},
-
-			(punishment, next) => {
-				cache.pub('ip.ban', {ip: value, punishment});
-				next();
-			},
-		], (err) => {
+			}
+		], async (err, punishment) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("BAN_IP", `User ${userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
 				cb({ status: 'failure', message: err });
-			} else {
-				logger.success("BAN_IP", `User ${userId} has successfully banned Ip address ${value} with the reason ${reason}.`);
-				cb({
-					status: 'success',
-					message: 'Successfully banned IP address.'
-				});
 			}
+			logger.success("BAN_IP", `User ${userId} has successfully banned IP address ${value} with the reason ${reason}.`);
+			cache.pub('ip.ban', { ip: value, punishment });
+			return cb({
+				status: 'success',
+				message: 'Successfully banned IP address.'
+			});
 		});
 	}),
 

+ 23 - 20
backend/logic/actions/queueSongs.js

@@ -1,17 +1,20 @@
 'use strict';
 
-const db = require('../db');
-const utils = require('../utils');
-const logger = require('../logger');
-const notifications = require('../notifications');
-const cache = require('../cache');
-const async = require('async');
 const config = require('config');
+const async = require('async');
 const request = require('request');
+
 const hooks = require('./hooks');
 
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const cache = moduleManager.modules["cache"];
+
 cache.sub('queue.newSong', songId => {
-	db.models.queueSong.findOne({songId}, (err, song) => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
 	});
 });
@@ -21,7 +24,7 @@ cache.sub('queue.removedSong', songId => {
 });
 
 cache.sub('queue.update', songId => {
-	db.models.queueSong.findOne({songId}, (err, song) => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
 	});
 });
@@ -39,9 +42,9 @@ let lib = {
 			(next) => {
 				db.models.queueSong.find({}, next);
 			}
-		], (err, songs) => {
+		], async (err, songs) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_INDEX", `Indexing queuesongs failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			} else {
@@ -93,9 +96,9 @@ let lib = {
 				if (!updated) return next('No properties changed');
 				db.models.queueSong.updateOne({_id: songId}, {$set}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await  utils.getError(err);
 				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -118,9 +121,9 @@ let lib = {
 			(next) => {
 				db.models.queueSong.deleteOne({_id: songId}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -166,12 +169,12 @@ let lib = {
 					next(null, song);
 				});
 			},
-			(newSong, next) => {
+			/*(newSong, next) => {
 				utils.getSongFromSpotify(newSong, (err, song) => {
 					if (!song) next(null, newSong);
 					else next(err, song);
 				});
-			},
+			},*/
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
 				song.save((err, song) => {
@@ -191,9 +194,9 @@ let lib = {
 					}
 				});
 			}
-		], (err, newSong) => {
+		], async (err, newSong) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -230,9 +233,9 @@ let lib = {
 					});
 				}
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {

+ 27 - 18
backend/logic/actions/reports.js

@@ -2,12 +2,16 @@
 
 const async = require('async');
 
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
 const hooks = require('./hooks');
-const songs = require('../songs');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const songs = moduleManager.modules["songs"];
+
 const reportableIssues = [
 	{
 		name: 'Video',
@@ -71,9 +75,9 @@ module.exports = {
 			(next) => {
 				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
 			}
-		], (err, reports) => {
+		], async (err, reports) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			}
@@ -94,9 +98,9 @@ module.exports = {
 			(next) => {
 				db.models.report.findOne({ _id: reportId }).exec(next);
 			}
-		], (err, report) => {
+		], async (err, report) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -106,7 +110,7 @@ module.exports = {
 	}),
 
 	/**
-	 * Gets all reports for a songId
+	 * Gets all reports for a songId (_id)
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} songId - the id of the song to index reports for
@@ -115,7 +119,7 @@ module.exports = {
 	getReportsForSong: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.report.find({ songId, resolved: false }).sort({ released: 'desc' }).exec(next);
+				db.models.report.find({ song: { _id: songId }, resolved: false }).sort({ released: 'desc' }).exec(next);
 			},
 
 			(reports, next) => {
@@ -125,9 +129,9 @@ module.exports = {
 				}
 				next(null, data);
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			} else {
@@ -159,9 +163,9 @@ module.exports = {
 					else next();
 				});
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await  utils.getError(err);
 				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			} else {
@@ -195,6 +199,11 @@ module.exports = {
 			(song, next) => {
 				if (!song) return next('Song not found.');
 
+				delete data.songId;
+				data.song = {
+					_id: song._id,
+					songId: song.songId
+				}
 
 				for (let z = 0; z < data.issues.length; z++) {
 					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
@@ -227,10 +236,10 @@ module.exports = {
 				db.models.report.create(data, next);
 			}
 
-		], (err, report) => {
+		], async (err, report) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("REPORTS_CREATE", `Creating report for "${data.songId}" failed by user "${userId}". "${err}"`);
+				err = await utils.getError(err);
+				logger.error("REPORTS_CREATE", `Creating report for "${data.song._id}" failed by user "${userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 				cache.pub('report.create', report);

+ 33 - 30
backend/logic/actions/songs.js

@@ -1,27 +1,30 @@
 'use strict';
 
-const db = require('../db');
-const io = require('../io');
-const songs = require('../songs');
-const cache = require('../cache');
 const async = require('async');
-const utils = require('../utils');
-const logger = require('../logger');
+
 const hooks = require('./hooks');
 const queueSongs = require('./queueSongs');
 
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const songs = moduleManager.modules["songs"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 cache.sub('song.removed', songId => {
 	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
 });
 
 cache.sub('song.added', songId => {
-	db.models.song.findOne({songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
 	});
 });
 
 cache.sub('song.updated', songId => {
-	db.models.song.findOne({songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
 	});
 });
@@ -75,9 +78,9 @@ module.exports = {
 			(next) => {
 				db.models.song.countDocuments({}, next);
 			}
-		], (err, count) => {
+		], async (err, count) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -98,9 +101,9 @@ module.exports = {
 			(next) => {
 				db.models.song.find({}).limit(15 * set).exec(next);
 			}
-		], (err, songs) => {
+		], async (err, songs) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -125,9 +128,9 @@ module.exports = {
 			(next) => {
 				db.models.song.findOne({ songId }).exec(next);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_GET_SONG", `Failed to get song ${songId}. "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -154,9 +157,9 @@ module.exports = {
 			(res, next) => {
 				songs.updateSong(songId, next);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -182,9 +185,9 @@ module.exports = {
 			(res, next) => {//TODO Check if res gets returned from above
 				cache.hdel('songs', songId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -225,9 +228,9 @@ module.exports = {
 					next();
 				});
 			},
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_ADD", `User "${userId}" failed to add song. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -256,9 +259,9 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_LIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -304,9 +307,9 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_DISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -352,9 +355,9 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UNDISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -421,9 +424,9 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UNLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -469,9 +472,9 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_GET_OWN_RATINGS", `User "${userId}" failed to get ratings for ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}

+ 170 - 92
backend/logic/actions/stations.js

@@ -5,15 +5,18 @@ const async   = require('async'),
 	  config  = require('config'),
 	  _		  =  require('underscore')._;
 
-const io = require('../io');
-const db = require('../db');
-const cache = require('../cache');
-const notifications = require('../notifications');
-const utils = require('../utils');
-const logger = require('../logger');
-const stations = require('../stations');
-const songs = require('../songs');
 const hooks = require('./hooks');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const notifications = moduleManager.modules["notifications"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const stations = moduleManager.modules["stations"];
+const songs = moduleManager.modules["songs"];
+
 let userList = {};
 let usersPerStation = {};
 let usersPerStationCount = {};
@@ -29,39 +32,40 @@ setInterval(() => {
 	usersPerStationCount = {};
 
 	async.each(Object.keys(userList), function(socketId, next) {
-		let socket = utils.socketFromSession(socketId);
-		let stationId = userList[socketId];
-		if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
-			if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-			if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-			delete userList[socketId];
-			return next();
-		}
-		if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
-		usersPerStationCount[stationId]++;
-		if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
-
-		async.waterfall([
-			(next) => {
-				if (!socket.session || !socket.session.sessionId) return next('No session found.');
-				cache.hget('sessions', socket.session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found.');
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
-				next(null, user.username);
-			}
-		], (err, username) => {
-			if (!err) {
-				usersPerStation[stationId].push(username);
+		utils.socketFromSession(socketId).then((socket) => {
+			let stationId = userList[socketId];
+			if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
+				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
+				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
+				delete userList[socketId];
+				return next();
 			}
-			next();
+			if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
+			usersPerStationCount[stationId]++;
+			if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
+
+			async.waterfall([
+				(next) => {
+					if (!socket.session || !socket.session.sessionId) return next('No session found.');
+					cache.hget('sessions', socket.session.sessionId, next);
+				},
+
+				(session, next) => {
+					if (!session) return next('Session not found.');
+					db.models.user.findOne({_id: session.userId}, next);
+				},
+
+				(user, next) => {
+					if (!user) return next('User not found.');
+					if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
+					next(null, user.username);
+				}
+			], (err, username) => {
+				if (!err) {
+					usersPerStation[stationId].push(username);
+				}
+				next();
+			});
 		});
 		//TODO Code to show users
 	}, (err) => {
@@ -99,10 +103,10 @@ cache.sub('station.updateUsers', stationId => {
 cache.sub('station.updateUserCount', stationId => {
 	let count = usersPerStationCount[stationId] || 0;
 	utils.emitToRoom(`station.${stationId}`, "event:userCount.updated", count);
-	stations.getStation(stationId, (err, station) => {
+	stations.getStation(stationId, async (err, station) => {
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
@@ -161,14 +165,14 @@ cache.sub('station.remove', stationId => {
 });
 
 cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, (err, station) => {
+	stations.initializeStation(stationId, async (err, station) => {
 		station.userCount = usersPerStationCount[stationId] || 0;
 		if (err) console.error(err);
 		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
 		// TODO If community, check if on whitelist
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
@@ -241,9 +245,9 @@ module.exports = {
 					next(null, resultStations);
 				});
 			}
-		], (err, stations) => {
+		], async (err, stations) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -269,9 +273,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next(null, station);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_FIND_BY_NAME", `Finding station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -307,9 +311,9 @@ module.exports = {
 				if (!playlist) return next('Playlist not found.');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -353,9 +357,9 @@ module.exports = {
 						if (station.owner === session.userId) return next(true);
 						next('An error occurred while joining the station.');
 					}
-				], (err) => {
+				], async (err) => {
 					if (err === true) return next(null, station);
-					next(utils.getError(err));
+					next(await utils.getError(err));
 				});
 			},
 
@@ -398,9 +402,9 @@ module.exports = {
 					next(null, data);
 				});
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -429,9 +433,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_LOCKED_STATUS", `Toggling the queue lock for station "${stationId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -482,9 +486,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next(null, station);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -512,9 +516,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -543,9 +547,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next();
 			}
-		], (err, userCount) => {
+		], async (err, userCount) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -573,9 +577,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -601,9 +605,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -629,9 +633,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DESCRIPTION", `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -657,9 +661,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_PRIVACY", `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -685,9 +689,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_GENRES", `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -713,9 +717,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -747,9 +751,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_PARTY_MODE", `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -782,9 +786,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -818,9 +822,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -846,9 +850,9 @@ module.exports = {
 			(next) => {
 				cache.hdel('stations', stationId, err => next(err));
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -913,9 +917,9 @@ module.exports = {
 					}, next);
 				}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1037,9 +1041,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_ADD_SONG_TO_QUEUE", `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1084,9 +1088,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE_SONG_TO_QUEUE", `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1121,9 +1125,9 @@ module.exports = {
 					return next('Insufficient permissions.');
 				});
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1163,9 +1167,9 @@ module.exports = {
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1176,4 +1180,78 @@ module.exports = {
 			return cb({'status': 'success', 'message': 'Successfully selected playlist.'});
 		});
 	}),
+
+	favoriteStation: hooks.loginRequired((session, stationId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				async.waterfall([
+					(next) => {
+						if (station.privacy !== 'private') return next(true);
+						if (!session.userId) return next("You're not allowed to favorite this station.");
+						next();
+					},
+
+					(next) => {
+						db.models.user.findOne({ _id: userId }, next);
+					},
+
+					(user, next) => {
+						if (!user) return next("You're not allowed to favorite this station.");
+						if (user.role === 'admin') return next(true);
+						if (station.type === 'official') return next("You're not allowed to favorite this station.");
+						if (station.owner === session.userId) return next(true);
+						next("You're not allowed to favorite this station.");
+					}
+				], (err) => {
+					if (err === true) return next(null);
+					next(utils.getError(err));
+				});
+			},
+
+			(next) => {
+				db.models.user.updateOne({ _id: userId }, { $addToSet: { favoriteStations: stationId } }, next);
+			},
+
+			(res, next) => {
+				if (res.nModified === 0) return next("The station was already favorited.");
+				next();
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
+			cache.pub('user.favoritedStation', { userId, stationId });
+			return cb({'status': 'success', 'message': 'Succesfully favorited station.'});
+		});
+	}),
+
+	unfavoriteStation: hooks.loginRequired((session, stationId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.updateOne({ _id: userId }, { $pull: { favoriteStations: stationId } }, next);
+			},
+
+			(res, next) => {
+				if (res.nModified === 0) return next("The station wasn't favorited.");
+				next();
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
+			cache.pub('user.unfavoritedStation', { userId, stationId });
+			return cb({'status': 'success', 'message': 'Succesfully unfavorited station.'});
+		});
+	}),
 };

+ 129 - 65
backend/logic/actions/users.js

@@ -4,15 +4,18 @@ const async = require('async');
 const config = require('config');
 const request = require('request');
 const bcrypt = require('bcrypt');
+const sha256 = require('sha256');
 
-const db = require('../db');
-const mail = require('../mail');
-const cache = require('../cache');
-const punishments = require('../punishments');
-const utils = require('../utils');
 const hooks = require('./hooks');
-const sha256 = require('sha256');
-const logger = require('../logger');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const mail = moduleManager.modules["mail"];
+const cache = moduleManager.modules["cache"];
+const punishments = moduleManager.modules["punishments"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 cache.sub('user.updateUsername', user => {
 	utils.socketsFromUser(user._id, sockets => {
@@ -71,6 +74,22 @@ cache.sub('user.ban', data => {
 	});
 });
 
+cache.sub('user.favoritedStation', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.favoritedStation', data.stationId);
+		});
+	});
+});
+
+cache.sub('user.unfavoritedStation', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.unfavoritedStation', data.stationId);
+		});
+	});
+});
+
 module.exports = {
 
 	/**
@@ -84,9 +103,9 @@ module.exports = {
 			(next) => {
 				db.models.user.find({}).exec(next);
 			}
-		], (err, users) => {
+		], async (err, users) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			} else {
@@ -147,16 +166,21 @@ module.exports = {
 			},
 
 			(user, next) => {
-				let sessionId = utils.guid();
+				utils.guid().then((sessionId) => {
+					next(null, user, sessionId);
+				});
+			},
+
+			(user, sessionId, next) => {
 				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
 					if (err) return next(err);
 					next(null, sessionId);
 				});
 			}
 
-		], (err, sessionId) => {
+		], async (err, sessionId) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -176,9 +200,9 @@ module.exports = {
 	 * @param {Object} recaptcha - the recaptcha data
 	 * @param {Function} cb - gets called with the result
 	 */
-	register: function(session, username, email, password, recaptcha, cb) {
+	register: async function(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 
 			// verify the request with google recaptcha
@@ -225,10 +249,16 @@ module.exports = {
 				bcrypt.hash(sha256(password), salt, next)
 			},
 
-			// save the new user to the database
 			(hash, next) => {
+				utils.generateRandomString(12).then((_id) => {
+					next(null, hash, _id);
+				});
+			},
+
+			// save the new user to the database
+			(hash, _id, next) => {
 				db.models.user.create({
-					_id: utils.generateRandomString(12),//TODO Check if exists
+					_id,
 					username,
 					email: {
 						address: email,
@@ -250,9 +280,9 @@ module.exports = {
 				});
 			}
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -290,9 +320,9 @@ module.exports = {
 			(session, next) => {
 				cache.hdel('sessions', session.sessionId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
 				cb({ status: 'failure', message: err });
 			} else {
@@ -349,9 +379,9 @@ module.exports = {
 				});
 			}
 
-		], err => {
+		], async err => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REMOVE_SESSIONS_FOR_USER", `Couldn't remove all sessions for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -379,9 +409,9 @@ module.exports = {
 				if (!account) return next('User not found.');
 				next(null, account);
 			}
-		], (err, account) => {
+		], async (err, account) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -413,14 +443,23 @@ module.exports = {
 	 */
 	getUsernameFromId: (session, userId, cb) => {
 		db.models.user.findById(userId).then(user => {
-			logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
-			return cb({
-				status: 'success',
-				data: user.username
-			});
-		}).catch(err => {
+			if (user) {
+				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+				return cb({
+					status: 'success',
+					data: user.username
+				});
+			} else {
+				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. User not found.`);
+				cb({
+					status: 'failure',
+					message: "Couldn't find the user."
+				});
+			}
+			
+		}).catch(async err => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
 				cb({ status: 'failure', message: err });
 			}
@@ -453,9 +492,9 @@ module.exports = {
 				if (!user) return next('User not found.');
 				next(null, user);
 			}
-		], (err, user) => {
+		], async (err, user) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -516,9 +555,9 @@ module.exports = {
 			(next) => {
 				db.models.user.updateOne({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_USERNAME", `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -541,9 +580,9 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	updateEmail: hooks.loginRequired((session, updatingUserId, newEmail, cb, userId) => {
+	updateEmail: hooks.loginRequired(async (session, updatingUserId, newEmail, cb, userId) => {
 		newEmail = newEmail.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 			(next) => {
 				if (updatingUserId === userId) return next(null, true);
@@ -584,9 +623,9 @@ module.exports = {
 					next();
 				});
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_EMAIL", `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -622,9 +661,9 @@ module.exports = {
 				db.models.user.updateOne({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
 			}
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_ROLE", `User "${userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -673,9 +712,9 @@ module.exports = {
 			(hashedPassword, next) => {
 				db.models.user.updateOne({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${err}'.`);
 				return cb({ status: 'failure', message: err });
 			}
@@ -696,8 +735,8 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	requestPassword: hooks.loginRequired((session, cb, userId) => {
-		let code = utils.generateRandomString(8);
+	requestPassword: hooks.loginRequired(async (session, cb, userId) => {
+		let code = await utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
 				db.models.user.findOne({_id: userId}, next);
@@ -718,9 +757,9 @@ module.exports = {
 			(user, next) => {
 				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REQUEST_PASSWORD", `UserId '${userId}' failed to request password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -753,9 +792,9 @@ module.exports = {
 				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
 				next(null);
 			}
-		], (err) => {
+		], async(err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -807,9 +846,9 @@ module.exports = {
 			(hashedPassword, next) => {
 				db.models.user.updateOne({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -841,9 +880,9 @@ module.exports = {
 				if (!user.services.github || !user.services.github.id) return next('You can\'t remove password login without having GitHub login.');
 				db.models.user.updateOne({_id: userId}, {$unset: {"services.password": ''}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${userId}'. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -875,9 +914,9 @@ module.exports = {
 				if (!user.services.password || !user.services.password.password) return next('You can\'t remove GitHub login without having password login.');
 				db.models.user.updateOne({_id: userId}, {$unset: {"services.github": ''}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${userId}'. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -898,8 +937,8 @@ module.exports = {
 	 * @param {String} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestPasswordReset: (session, email, cb) => {
-		let code = utils.generateRandomString(8);
+	requestPasswordReset: async (session, email, cb) => {
+		let code = await utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
 				if (!email || typeof email !== 'string') return next('Invalid email.');
@@ -922,9 +961,9 @@ module.exports = {
 			(user, next) => {
 				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -956,9 +995,9 @@ module.exports = {
 				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
 				next(null);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -1009,9 +1048,9 @@ module.exports = {
 			(hashedPassword, next) => {
 				db.models.user.updateOne({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -1088,9 +1127,9 @@ module.exports = {
 				cache.pub('user.ban', {userId: value, punishment});
 				next();
 			},
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("BAN_USER_BY_ID", `User ${userId} failed to ban user ${value} with the reason ${reason}. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -1101,5 +1140,30 @@ module.exports = {
 				});
 			}
 		});
+	}),
+
+	getFavoriteStations: hooks.loginRequired((session, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ _id: userId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next("User not found.");
+				next(null, user);
+			}
+		], async (err, user) => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("GET_FAVORITE_STATIONS", `User ${userId} failed to get favorite stations. '${err}'`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("GET_FAVORITE_STATIONS", `User ${userId} got favorite stations.`);
+				cb({
+					status: 'success',
+					favoriteStations: user.favoriteStations
+				});
+			}
+		});
 	})
 };

+ 32 - 26
backend/logic/api.js

@@ -1,34 +1,40 @@
-let lockdown = false;
+const coreClass = require("../core");
 
-module.exports = {
-	init: (cb) => {
-		const { app } = require('./app.js');
-		const actions = require('./actions');
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-		app.get('/', (req, res) => {
-			res.json({
-				status: 'success',
-				message: 'Coming Soon'
-			});
-		});
+		this.dependsOn = ["app", "db", "cache"];
+	}
 
-		Object.keys(actions).forEach((namespace) => {
-			Object.keys(actions[namespace]).forEach((action) => {
-				let name = `/${namespace}/${action}`;
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
 
-				app.get(name, (req, res) => {
-					actions[namespace][action](null, (result) => {
-						if (typeof cb === 'function') return res.json(result);
-					});
+			this.app = this.moduleManager.modules["app"];
+
+			this.app.app.get('/', (req, res) => {
+				res.json({
+					status: 'success',
+					message: 'Coming Soon'
 				});
-			})
-		});
+			});
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+			const actions = require("../logic/actions");
+	
+			Object.keys(actions).forEach((namespace) => {
+				Object.keys(actions[namespace]).forEach((action) => {
+					let name = `/${namespace}/${action}`;
+	
+					this.app.app.get(name, (req, res) => {
+						actions[namespace][action](null, (result) => {
+							if (typeof cb === 'function') return res.json(result);
+						});
+					});
+				})
+			});
 
-	_lockdown: () => {
-		lockdown = true;
+			resolve();
+		});
 	}
-}
+}

+ 220 - 224
backend/logic/app.js

@@ -1,251 +1,247 @@
 'use strict';
 
+const coreClass = require("../core");
+
 const express = require('express');
 const bodyParser = require('body-parser');
 const cookieParser = require('cookie-parser');
 const cors = require('cors');
 const config = require('config');
 const async = require('async');
-const logger = require('./logger');
-const mail = require('./mail');
 const request = require('request');
 const OAuth2 = require('oauth').OAuth2;
 
-const api = require('./api');
-const cache = require('./cache');
-const db = require('./db');
-
-let utils;
-let initialized = false;
-let lockdown = false;
-
-const lib = {
-
-	app: null,
-	server: null,
-
-	init: (cb) => {
-
-		utils = require('./utils');
-
-		let app = lib.app = express();
-
-		lib.server = app.listen(config.get('serverPort'));
-
-		app.use(cookieParser());
-
-		app.use(bodyParser.json());
-		app.use(bodyParser.urlencoded({ extended: true }));
-
-		let corsOptions = Object.assign({}, config.get('cors'));
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			const 	logger 	= this.logger,
+					mail	= this.moduleManager.modules["mail"],
+					cache	= this.moduleManager.modules["cache"],
+					db		= this.moduleManager.modules["db"];
+			
+			this.utils = this.moduleManager.modules["utils"];
+
+			let app = this.app = express();
+			const SIDname = config.get("cookie.SIDname");
+			this.server = app.listen(config.get('serverPort'));
+
+			app.use(cookieParser());
+
+			app.use(bodyParser.json());
+			app.use(bodyParser.urlencoded({ extended: true }));
+
+			let corsOptions = Object.assign({}, config.get('cors'));
+
+			app.use(cors(corsOptions));
+			app.options('*', cors(corsOptions));
+
+			let oauth2 = new OAuth2(
+				config.get('apis.github.client'),
+				config.get('apis.github.secret'),
+				'https://github.com/',
+				'login/oauth/authorize',
+				'login/oauth/access_token',
+				null
+			);
+
+			let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
+
+			app.get('/auth/github/authorize', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
+				let params = [
+					`client_id=${config.get('apis.github.client')}`,
+					`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
+					`scope=user:email`
+				].join('&');
+				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
 
-		app.use(cors(corsOptions));
-		app.options('*', cors(corsOptions));
+			app.get('/auth/github/link', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
+				let params = [
+					`client_id=${config.get('apis.github.client')}`,
+					`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
+					`scope=user:email`,
+					`state=${req.cookies[SIDname]}`
+				].join('&');
+				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
 
-		let oauth2 = new OAuth2(
-			config.get('apis.github.client'),
-			config.get('apis.github.secret'),
-			'https://github.com/',
-			'login/oauth/authorize',
-			'login/oauth/access_token',
-			null
-		);
+			function redirectOnErr (res, err){
+				return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
+			}
+
+			app.get('/auth/github/authorize/callback', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
+				let code = req.query.code;
+				let access_token;
+				let body;
+				let address;
+				const state = req.query.state;
+
+				async.waterfall([
+					(next) => {
+						if (req.query.error) return next(req.query.error_description);
+						next();
+					},
+
+					(next) => {
+						oauth2.getOAuthAccessToken(code, {redirect_uri}, next);
+					},
+
+					(_access_token, refresh_token, results, next) => {
+						if (results.error) return next(results.error_description);
+						access_token = _access_token;
+						request.get({
+							url: `https://api.github.com/user?access_token=${access_token}`,
+							headers: {'User-Agent': 'request'}
+						}, next);
+					},
+
+					(httpResponse, _body, next) => {
+						body = _body = JSON.parse(_body);
+						if (httpResponse.statusCode !== 200) return next(body.message);
+						if (state) {
+							return async.waterfall([
+								(next) => {
+									cache.hget('sessions', state, next);
+								},
+
+								(session, next) => {
+									if (!session) return next('Invalid session.');
+									db.models.user.findOne({_id: session.userId}, next);
+								},
+
+								(user, next) => {
+									if (!user) return next('User not found.');
+									if (user.services.github && user.services.github.id) return next('Account already has GitHub linked.');
+									db.models.user.updateOne({_id: user._id}, {$set: {"services.github": {id: body.id, access_token}}}, {runValidators: true}, (err) => {
+										if (err) return next(err);
+										next(null, user, body);
+									});
+								},
+
+								(user) => {
+									cache.pub('user.linkGitHub', user._id);
+									res.redirect(`${config.get('domain')}/settings`);
+								}
+							], next);
+						}
+						if (!body.id) return next("Something went wrong, no id.");
+						db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
+							next(err, user, body);
+						});
+					},
+
+					(user, body, next) => {
+						if (user) {
+							user.services.github.access_token = access_token;
+							return user.save(() => {
+								next(true, user._id);
+							});
+						}
+						db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i' )}, (err, user) => {
+							next(err, user);
+						});
+					},
+
+					(user, next) => {
+						if (user) return next('An account with that username already exists.');
+						request.get({
+							url: `https://api.github.com/user/emails?access_token=${access_token}`,
+							headers: {'User-Agent': 'request'}
+						}, next);
+					},
+
+					(httpResponse, body2, next) => {
+						body2 = JSON.parse(body2);
+						if (!Array.isArray(body2)) return next(body2.message);
+						body2.forEach(email => {
+							if (email.primary) address = email.email.toLowerCase();
+						});
+						db.models.user.findOne({'email.address': address}, next);
+					},
+
+					async (user, next) => {
+						const verificationToken = await this.utils.generateRandomString(64);
+						if (user) return next('An account with that email address already exists.');
+						db.models.user.create({
+							_id: await this.utils.generateRandomString(12),//TODO Check if exists
+							username: body.login,
+							email: {
+								address,
+								verificationToken: verificationToken
+							},
+							services: {
+								github: {id: body.id, access_token}
+							}
+						}, next);
+					},
 
-		let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
+					(user, next) => {
+						mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
+						next(null, user._id);
+					}
+				], async (err, userId) => {
+					if (err && err !== true) {
+						err = await this.utils.getError(err);
+						logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
+						return redirectOnErr(res, err);
+					}
 
-		app.get('/auth/github/authorize', (req, res) => {
-			if (lockdown) return res.json({status: 'failure', message: 'Lockdown'});
-			let params = [
-				`client_id=${config.get('apis.github.client')}`,
-				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-				`scope=user:email`
-			].join('&');
-			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-		});
+					const sessionId = await this.utils.guid();
+					cache.hset('sessions', sessionId, cache.schemas.session(sessionId, userId), err => {
+						if (err) return redirectOnErr(res, err.message);
+						let date = new Date();
+						date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+						res.cookie(SIDname, sessionId, {
+							expires: date,
+							secure: config.get("cookie.secure"),
+							path: "/",
+							domain: config.get("cookie.domain")
+						});
+						logger.success('AUTH_GITHUB_AUTHORIZE_CALLBACK', `User "${userId}" successfully authorized with GitHub.`);
+						res.redirect(`${config.get('domain')}/`);
+					});
+				});
+			});
 
-		app.get('/auth/github/link', (req, res) => {
-			if (lockdown) return res.json({status: 'failure', message: 'Lockdown'});
-			let params = [
-				`client_id=${config.get('apis.github.client')}`,
-				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-				`scope=user:email`,
-				`state=${req.cookies.SID}`
-			].join('&');
-			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-		});
+			app.get('/auth/verify_email', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
 
-		function redirectOnErr (res, err){
-			return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
-		}
-
-		app.get('/auth/github/authorize/callback', (req, res) => {
-			if (lockdown) return res.json({status: 'failure', message: 'Lockdown'});
-			let code = req.query.code;
-			let access_token;
-			let body;
-			let address;
-			const state = req.query.state;
-
-			async.waterfall([
-				(next) => {
-					oauth2.getOAuthAccessToken(code, {redirect_uri}, next);
-				},
-
-				(_access_token, refresh_token, results, next) => {
-					access_token = _access_token;
-					request.get({
-						url: `https://api.github.com/user?access_token=${access_token}`,
-						headers: {'User-Agent': 'request'}
-					}, next);
-				},
-
-				(httpResponse, _body, next) => {
-					body = _body = JSON.parse(_body);
-					if (state) {
-						return async.waterfall([
-							(next) => {
-								cache.hget('sessions', state, next);
-							},
+				let code = req.query.code;
 
-							(session, next) => {
-								if (!session) return next('Invalid session.');
-								db.models.user.findOne({_id: session.userId}, next);
-							},
+				async.waterfall([
+					(next) => {
+						if (!code) return next('Invalid code.');
+						next();
+					},
 
-							(user, next) => {
-								if (!user) return next('User not found.');
-								if (user.services.github && user.services.github.id) return next('Account already has GitHub linked.');
-								db.models.user.updateOne({_id: user._id}, {$set: {"services.github": {id: body.id, access_token}}}, {runValidators: true}, (err) => {
-									if (err) return next(err);
-									next(null, user, body);
-								});
-							},
+					(next) => {
+						db.models.user.findOne({"email.verificationToken": code}, next);
+					},
 
-							(user) => {
-								cache.pub('user.linkGitHub', user._id);
-								res.redirect(`${config.get('domain')}/settings`);
-							}
-						], next);
+					(user, next) => {
+						if (!user) return next('User not found.');
+						if (user.email.verified) return next('This email is already verified.');
+						db.models.user.updateOne({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, {runValidators: true}, next);
 					}
-					db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
-						next(err, user, body);
-					});
-				},
-
-				(user, body, next) => {
-					if (user) {
-						user.services.github.access_token = access_token;
-						return user.save(() => {
-							next(true, user._id);
-						});
+				], (err) => {
+					if (err) {
+						let error = 'An error occurred.';
+						if (typeof err === "string") error = err;
+						else if (err.message) error = err.message;
+						logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
+						return res.json({ status: 'failure', message: error});
 					}
-					db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i' )}, (err, user) => {
-						next(err, user);
-					});
-				},
-
-				(user, next) => {
-					if (user) return next('An account with that username already exists.');
-					request.get({
-						url: `https://api.github.com/user/emails?access_token=${access_token}`,
-						headers: {'User-Agent': 'request'}
-					}, next);
-				},
-
-				(httpResponse, body2, next) => {
-					body2 = JSON.parse(body2);
-					if (!Array.isArray(body2)) return next(body2.message);
-					body2.forEach(email => {
-						if (email.primary) address = email.email.toLowerCase();
-					});
-					db.models.user.findOne({'email.address': address}, next);
-				},
-
-				(user, next) => {
-					const verificationToken = utils.generateRandomString(64);
-					if (user) return next('An account with that email address already exists.');
-					db.models.user.create({
-						_id: utils.generateRandomString(12),//TODO Check if exists
-						username: body.login,
-						email: {
-							address,
-							verificationToken: verificationToken
-						},
-						services: {
-							github: {id: body.id, access_token}
-						}
-					}, next);
-				},
-
-				(user, next) => {
-					mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
-					next(null, user._id);
-				}
-			], (err, userId) => {
-				if (err && err !== true) {
-					err = utils.getError(err);
-					logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
-					return redirectOnErr(res, err);
-				}
-
-				const sessionId = utils.guid();
-				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, userId), err => {
-					if (err) return redirectOnErr(res, err.message);
-					let date = new Date();
-					date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-					res.cookie('SID', sessionId, {
-						expires: date,
-						secure: config.get("cookie.secure"),
-						path: "/",
-						domain: config.get("cookie.domain")
-					});
-					logger.success('AUTH_GITHUB_AUTHORIZE_CALLBACK', `User "${userId}" successfully authorized with GitHub.`);
-					res.redirect(`${config.get('domain')}/`);
+					logger.success("VERIFY_EMAIL", `Successfully verified email.`);
+					res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
 				});
 			});
-		});
 
-		app.get('/auth/verify_email', (req, res) => {
-			let code = req.query.code;
-
-			async.waterfall([
-				(next) => {
-					if (!code) return next('Invalid code.');
-					next();
-				},
-
-				(next) => {
-					db.models.user.findOne({"email.verificationToken": code}, next);
-				},
-
-				(user, next) => {
-					if (!user) return next('User not found.');
-					if (user.email.verified) return next('This email is already verified.');
-					db.models.user.updateOne({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, {runValidators: true}, next);
-				}
-			], (err) => {
-				if (err) {
-					let error = 'An error occurred.';
-					if (typeof err === "string") error = err;
-					else if (err.message) error = err.message;
-					logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-					return res.json({ status: 'failure', message: error});
-				}
-				logger.success("VERIFY_EMAIL", `Successfully verified email.`);
-				res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
-			});
+			resolve();
 		});
-
-		initialized = true;
-
-		if (lockdown) return this._lockdown();
-		cb();
-	},
-
-	_lockdown: () => {
-		lib.server.close();
-		lockdown = true;
 	}
-};
-
-module.exports = lib;
+}

+ 101 - 91
backend/logic/cache/index.js

@@ -1,67 +1,84 @@
 'use strict';
 
+const coreClass = require("../../core");
+
 const redis = require('redis');
+const config = require('config');
 const mongoose = require('mongoose');
 
 // Lightweight / convenience wrapper around redis module for our needs
 
 const pubs = {}, subs = {};
-let callbacks = [];
-let initialized = false;
-let lockdown = false;
-
-const lib = {
-
-	client: null,
-	errorCb: null,
-	url: '',
-	schemas: {
-		session: require('./schemas/session'),
-		station: require('./schemas/station'),
-		playlist: require('./schemas/playlist'),
-		officialPlaylist: require('./schemas/officialPlaylist'),
-		song: require('./schemas/song'),
-		punishment: require('./schemas/punishment')
-	},
 
-	/**
-	 * Initializes the cache module
-	 *
-	 * @param {String} url - the url of the redis server
-	 * @param {String} password - the password of the redis server
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: (url, password, errorCb, cb) => {
-		lib.errorCb = errorCb;
-		lib.url = url;
-		lib.password = password;
-
-		lib.client = redis.createClient({ url: lib.url, password: lib.password });
-		lib.client.on('error', (err) => {
-			if (lockdown) return;
-			errorCb('Cache connection error.', err, 'Cache');
-		});
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.schemas = {
+				session: require('./schemas/session'),
+				station: require('./schemas/station'),
+				playlist: require('./schemas/playlist'),
+				officialPlaylist: require('./schemas/officialPlaylist'),
+				song: require('./schemas/song'),
+				punishment: require('./schemas/punishment')
+			}
 
-		callbacks.forEach((callback) => {
-			callback();
-		});
+			this.url = config.get("redis").url;
+			this.password = config.get("redis").password;
+
+			this.logger.info("REDIS", "Connecting...");
+
+			this.client = redis.createClient({
+				url: this.url,
+				password: this.password,
+				retry_strategy: (options) => {
+					if (this.state === "LOCKDOWN") return;
+					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
+
+					this.logger.info("CACHE_MODULE", `Attempting to reconnect.`);
 
-		initialized = true;
+					if (options.attempt >= 10) {
+						this.logger.error("CACHE_MODULE", `Stopped trying to reconnect.`);
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+						this.failed = true;
+						this._lockdown();
+
+						return undefined;
+					}
+
+					return 3000;
+				}
+			});
+
+			this.client.on('error', err => {
+				if (this.state === "INITIALIZING") reject(err);
+				if(this.state === "LOCKDOWN") return;
+
+				this.logger.error("CACHE_MODULE", `Error ${err.message}.`);
+			});
+
+			this.client.on("connect", () => {
+				this.logger.info("CACHE_MODULE", "Connected succesfully.");
+
+				if (this.state === "INITIALIZING") resolve();
+				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
+			});
+		});
+	}
 
 	/**
 	 * Gracefully closes all the Redis client connections
 	 */
-	quit: () => {
-		if (lib.client.connected) {
-			lib.client.quit();
+	async quit() {
+		try { await this._validateHook(); } catch { return; }
+
+		if (this.client.connected) {
+			this.client.quit();
 			Object.keys(pubs).forEach((channel) => pubs[channel].quit());
 			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
 		}
-	},
+	}
 
 	/**
 	 * Sets a single value in a table
@@ -72,19 +89,20 @@ const lib = {
 	 * @param {Function} cb - gets called when the value has been set in Redis
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 */
-	hset: (table, key, value, cb, stringifyJson = true) => {
-		if (lockdown) return cb('Lockdown');
+	async hset(table, key, value, cb, stringifyJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		// automatically stringify objects and arrays into JSON
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 
-		lib.client.hset(table, key, value, err => {
+		this.client.hset(table, key, value, err => {
 			if (cb !== undefined) {
 				if (err) return cb(err);
 				cb(null, JSON.parse(value));
 			}
 		});
-	},
+	}
 
 	/**
 	 * Gets a single value from a table
@@ -94,11 +112,13 @@ const lib = {
 	 * @param {Function} cb - gets called when the value is returned from Redis
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 */
-	hget: (table, key, cb, parseJson = true) => {
-		if (lockdown) return cb('Lockdown');
+	async hget(table, key, cb, parseJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!key || !table) return typeof cb === 'function' ? cb(null, null) : null;
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-		lib.client.hget(table, key, (err, value) => {
+
+		this.client.hget(table, key, (err, value) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (parseJson) try {
 				value = JSON.parse(value);
@@ -106,7 +126,7 @@ const lib = {
 			}
 			if (typeof cb === 'function') cb(null, value);
 		});
-	},
+	}
 
 	/**
 	 * Deletes a single value from a table
@@ -115,15 +135,17 @@ const lib = {
 	 * @param {String} key - name of the key to delete
 	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
 	 */
-	hdel: (table, key, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async hdel(table, key, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!key || !table) return cb(null, null);
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-		lib.client.hdel(table, key, (err) => {
+
+		this.client.hdel(table, key, (err) => {
 			if (err) return cb(err);
 			else return cb(null);
 		});
-	},
+	}
 
 	/**
 	 * Returns all the keys for a table
@@ -132,16 +154,18 @@ const lib = {
 	 * @param {Function} cb - gets called when the values are returned from Redis
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 */
-	hgetall: (table, cb, parseJson = true) => {
-		if (lockdown) return cb('Lockdown');
+	async hgetall(table, cb, parseJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!table) return cb(null, null);
-		lib.client.hgetall(table, (err, obj) => {
+
+		this.client.hgetall(table, (err, obj) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (parseJson && obj) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });
 			if (parseJson && !obj) obj = [];
 			cb(null, obj);
 		});
-	},
+	}
 
 	/**
 	 * Publish a message to a channel, caches the redis client connection
@@ -150,18 +174,18 @@ const lib = {
 	 * @param {*} value - the value we want to send
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 */
-	pub: (channel, value, stringifyJson = true) => {
-
+	async pub(channel, value, stringifyJson = true) {
+		try { await this._validateHook(); } catch { return; }
 		/*if (pubs[channel] === undefined) {
-		 pubs[channel] = redis.createClient({ url: lib.url });
+		 pubs[channel] = redis.createClient({ url: this.url });
 		 pubs[channel].on('error', (err) => console.error);
 		 }*/
 
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 
 		//pubs[channel].publish(channel, value);
-		lib.client.publish(channel, value);
-	},
+		this.client.publish(channel, value);
+	}
 
 	/**
 	 * Subscribe to a channel, caches the redis client connection
@@ -170,32 +194,18 @@ const lib = {
 	 * @param {Function} cb - gets called when a message is received
 	 * @param {Boolean} [parseJson=true] - parse the message as JSON
 	 */
-	sub: (channel, cb, parseJson = true) => {
-		if (lockdown) return;
-		if (initialized) subToChannel();
-		else {
-			callbacks.push(() => {
-				subToChannel();
+	async sub(channel, cb, parseJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (subs[channel] === undefined) {
+			subs[channel] = { client: redis.createClient({ url: this.url, password: this.password }), cbs: [] };
+			subs[channel].client.on('message', (channel, message) => {
+				if (parseJson) try { message = JSON.parse(message); } catch (e) {}
+				subs[channel].cbs.forEach((cb) => cb(message));
 			});
+			subs[channel].client.subscribe(channel);
 		}
-		function subToChannel() {
-			if (subs[channel] === undefined) {
-				subs[channel] = { client: redis.createClient({ url: lib.url, password: lib.password }), cbs: [] };
-				subs[channel].client.on('message', (channel, message) => {
-					if (parseJson) try { message = JSON.parse(message); } catch (e) {}
-					subs[channel].cbs.forEach((cb) => cb(message));
-				});
-				subs[channel].client.subscribe(channel);
-			}
 
-			subs[channel].cbs.push(cb);
-		}
-	},
-
-	_lockdown: () => {
-		lib.quit();
-		lockdown = true;
+		subs[channel].cbs.push(cb);
 	}
-};
-
-module.exports = lib;
+}

+ 198 - 179
backend/logic/db/index.js

@@ -1,10 +1,10 @@
 'use strict';
 
+const coreClass = require("../../core");
+
 const mongoose = require('mongoose');
 const config = require('config');
 
-const bluebird = require('bluebird');
-
 const regex = {
 	azAZ09_: /^[A-Za-z0-9_]+$/,
 	az09_: /^[a-z0-9_]+$/,
@@ -17,194 +17,213 @@ const isLength = (string, min, max) => {
 	return !(typeof string !== 'string' || string.length < min || string.length > max);
 }
 
+const bluebird = require('bluebird');
+
 mongoose.Promise = bluebird;
 
-let initialized = false;
-let lockdown = false;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
 
-let lib = {
+			this.schemas = {};
+			this.models = {};
 
-	connection: null,
-	schemas: {},
-	models: {},
+			const mongoUrl = config.get("mongo").url;
 
-	init: (url, errorCb,  cb) => {
-		mongoose.connect(url, {
-			useNewUrlParser: true,
-			useCreateIndex: true
-		})
-			.then(() => {
-				lib.schemas = {
-					song: new mongoose.Schema(require(`./schemas/song`)),
-					queueSong: new mongoose.Schema(require(`./schemas/queueSong`)),
-					station: new mongoose.Schema(require(`./schemas/station`)),
-					user: new mongoose.Schema(require(`./schemas/user`)),
-					playlist: new mongoose.Schema(require(`./schemas/playlist`)),
-					news: new mongoose.Schema(require(`./schemas/news`)),
-					report: new mongoose.Schema(require(`./schemas/report`)),
-					punishment: new mongoose.Schema(require(`./schemas/punishment`))
-				};
-	
-				lib.models = {
-					song: mongoose.model('song', lib.schemas.song),
-					queueSong: mongoose.model('queueSong', lib.schemas.queueSong),
-					station: mongoose.model('station', lib.schemas.station),
-					user: mongoose.model('user', lib.schemas.user),
-					playlist: mongoose.model('playlist', lib.schemas.playlist),
-					news: mongoose.model('news', lib.schemas.news),
-					report: mongoose.model('report', lib.schemas.report),
-					punishment: mongoose.model('punishment', lib.schemas.punishment)
-				};
-	
-				// lib.schemas.user.path('username').validate((username) => {
-				// 	return (isLength(username, 2, 32) && regex.azAZ09_.test(username));
-				// }, 'Invalid username.');
-	
-				lib.schemas.user.path('email.address').validate((email) => {
-					if (!isLength(email, 3, 254)) return false;
-					if (email.indexOf('@') !== email.lastIndexOf('@')) return false;
-					return regex.emailSimple.test(email);
-				}, 'Invalid email.');
-	
-				lib.schemas.station.path('name').validate((id) => {
-					return (isLength(id, 2, 16) && regex.az09_.test(id));
-				}, 'Invalid station name.');
-	
-				lib.schemas.station.path('displayName').validate((displayName) => {
-					return (isLength(displayName, 2, 32) && regex.azAZ09_.test(displayName));
-				}, 'Invalid display name.');
-	
-				lib.schemas.station.path('description').validate((description) => {
-					if (!isLength(description, 2, 200)) return false;
-					let characters = description.split("");
-					return characters.filter((character) => {
-						return character.charCodeAt(0) === 21328;
-					}).length === 0;
-				}, 'Invalid display name.');
-	
-	
-				lib.schemas.station.path('owner').validate((owner, callback) => {
-					lib.models.station.countDocuments({ owner: owner }, (err, c) => {
-						callback(!(err || c >= 3));
+			mongoose.connect(mongoUrl, {
+				useNewUrlParser: true,
+				useCreateIndex: true,
+				reconnectInterval: 3000,
+				reconnectTries: 10
+			})
+				.then(() => {
+					this.schemas = {
+						song: new mongoose.Schema(require(`./schemas/song`)),
+						queueSong: new mongoose.Schema(require(`./schemas/queueSong`)),
+						station: new mongoose.Schema(require(`./schemas/station`)),
+						user: new mongoose.Schema(require(`./schemas/user`)),
+						playlist: new mongoose.Schema(require(`./schemas/playlist`)),
+						news: new mongoose.Schema(require(`./schemas/news`)),
+						report: new mongoose.Schema(require(`./schemas/report`)),
+						punishment: new mongoose.Schema(require(`./schemas/punishment`))
+					};
+		
+					this.models = {
+						song: mongoose.model('song', this.schemas.song),
+						queueSong: mongoose.model('queueSong', this.schemas.queueSong),
+						station: mongoose.model('station', this.schemas.station),
+						user: mongoose.model('user', this.schemas.user),
+						playlist: mongoose.model('playlist', this.schemas.playlist),
+						news: mongoose.model('news', this.schemas.news),
+						report: mongoose.model('report', this.schemas.report),
+						punishment: mongoose.model('punishment', this.schemas.punishment)
+					};
+
+					mongoose.connection.on('error', err => {
+						this.logger.error("DB_MODULE", err);
 					});
-				}, 'User already has 3 stations.');
-	
-				/*
-				lib.schemas.station.path('queue').validate((queue, callback) => {
-					let totalDuration = 0;
-					queue.forEach((song) => {
-						totalDuration += song.duration;
+
+					mongoose.connection.on('disconnected', () => {
+						this.logger.error("DB_MODULE", "Disconnected, going to try to reconnect...");
+						this.setState("RECONNECTING");
 					});
-					return callback(totalDuration <= 3600 * 3);
-				}, 'The max length of the queue is 3 hours.');
-	
-				lib.schemas.station.path('queue').validate((queue, callback) => {
-					if (queue.length === 0) return callback(true);
-					let totalDuration = 0;
-					const userId = queue[queue.length - 1].requestedBy;
-					queue.forEach((song) => {
-						if (userId === song.requestedBy) {
-							totalDuration += song.duration;
-						}
+
+					mongoose.connection.on('reconnected', () => {
+						this.logger.success("DB_MODULE", "Reconnected.");
+						this.setState("INITIALIZED");
 					});
-					return callback(totalDuration <= 900);
-				}, 'The max length of songs per user is 15 minutes.');
-	
-				lib.schemas.station.path('queue').validate((queue, callback) => {
-					if (queue.length === 0) return callback(true);
-					let totalSongs = 0;
-					const userId = queue[queue.length - 1].requestedBy;
-					queue.forEach((song) => {
-						if (userId === song.requestedBy) {
-							totalSongs++;
-						}
+
+					mongoose.connection.on('reconnectFailed', () => {
+						this.logger.error("DB_MODULE", "Reconnect failed, stopping reconnecting.");
+						this.failed = true;
+						this._lockdown();
 					});
-					if (totalSongs <= 2) return callback(true);
-					if (totalSongs > 3) return callback(false);
-					if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return callback(true);
-					return callback(false);
-				}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
-				*/
-	
-				let songTitle = (title) => {
-					return isLength(title, 1, 100);
-				};
-				lib.schemas.song.path('title').validate(songTitle, 'Invalid title.');
-				lib.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
-	
-				lib.schemas.song.path('artists').validate((artists) => {
-					return !(artists.length < 1 || artists.length > 10);
-				}, 'Invalid artists.');
-				lib.schemas.queueSong.path('artists').validate((artists) => {
-					return !(artists.length < 0 || artists.length > 10);
-				}, 'Invalid artists.');
-	
-				let songArtists = (artists) => {
-					return artists.filter((artist) => {
-							return (isLength(artist, 1, 32) && regex.ascii.test(artist) && artist !== "NONE");
-						}).length === artists.length;
-				};
-				lib.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
-				lib.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
-	
-				let songGenres = (genres) => {
-					return genres.filter((genre) => {
-							return (isLength(genre, 1, 16) && regex.az09_.test(genre));
-						}).length === genres.length;
-				};
-				lib.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
-				lib.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');
-	
-				lib.schemas.song.path('thumbnail').validate((thumbnail) => {
-					return isLength(thumbnail, 8, 256);
-				}, 'Invalid thumbnail.');
-				lib.schemas.queueSong.path('thumbnail').validate((thumbnail) => {
-					return isLength(thumbnail, 0, 256);
-				}, 'Invalid thumbnail.');
-	
-				lib.schemas.playlist.path('displayName').validate((displayName) => {
-					return (isLength(displayName, 1, 16) && regex.ascii.test(displayName));
-				}, 'Invalid display name.');
-	
-				lib.schemas.playlist.path('createdBy').validate((createdBy) => {
-					lib.models.playlist.countDocuments({ createdBy: createdBy }, (err, c) => {
-						return !(err || c >= 10);
+		
+					// this.schemas.user.path('username').validate((username) => {
+					// 	return (isLength(username, 2, 32) && regex.azAZ09_.test(username));
+					// }, 'Invalid username.');
+		
+					this.schemas.user.path('email.address').validate((email) => {
+						if (!isLength(email, 3, 254)) return false;
+						if (email.indexOf('@') !== email.lastIndexOf('@')) return false;
+						return regex.emailSimple.test(email);
+					}, 'Invalid email.');
+		
+					this.schemas.station.path('name').validate((id) => {
+						return (isLength(id, 2, 16) && regex.az09_.test(id));
+					}, 'Invalid station name.');
+		
+					this.schemas.station.path('displayName').validate((displayName) => {
+						return (isLength(displayName, 2, 32) && regex.azAZ09_.test(displayName));
+					}, 'Invalid display name.');
+		
+					this.schemas.station.path('description').validate((description) => {
+						if (!isLength(description, 2, 200)) return false;
+						let characters = description.split("");
+						return characters.filter((character) => {
+							return character.charCodeAt(0) === 21328;
+						}).length === 0;
+					}, 'Invalid display name.');
+		
+		
+					this.schemas.station.path('owner').validate({
+						isAsync: true,
+						validator: (owner, callback) => {
+							this.models.station.countDocuments({ owner: owner }, (err, c) => {
+								callback(!(err || c >= 3))
+							});
+						},
+						message: 'User already has 3 stations.'
 					});
-				}, 'Max 10 playlists per user.');
-	
-				lib.schemas.playlist.path('songs').validate((songs) => {
-					return songs.length <= 2000;
-				}, 'Max 2000 songs per playlist.');
-	
-				lib.schemas.playlist.path('songs').validate((songs) => {
-					if (songs.length === 0) return true;
-					return songs[0].duration <= 10800;
-				}, 'Max 3 hours per song.');
-	
-				lib.schemas.report.path('description').validate((description) => {
-					return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
-				}, 'Invalid description.');
-	
-				initialized = true;
-	
-				if (lockdown) return this._lockdown();
-				cb();
-			})
-			.catch(err => {
-				console.error(err);
-				errorCb('Database connection error.', err, 'DB');
-			});
-	},
+		
+					/*
+					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
+						let totalDuration = 0;
+						queue.forEach((song) => {
+							totalDuration += song.duration;
+						});
+						return callback(totalDuration <= 3600 * 3);
+					}, 'The max length of the queue is 3 hours.');
+		
+					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
+						if (queue.length === 0) return callback(true);
+						let totalDuration = 0;
+						const userId = queue[queue.length - 1].requestedBy;
+						queue.forEach((song) => {
+							if (userId === song.requestedBy) {
+								totalDuration += song.duration;
+							}
+						});
+						return callback(totalDuration <= 900);
+					}, 'The max length of songs per user is 15 minutes.');
+		
+					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
+						if (queue.length === 0) return callback(true);
+						let totalSongs = 0;
+						const userId = queue[queue.length - 1].requestedBy;
+						queue.forEach((song) => {
+							if (userId === song.requestedBy) {
+								totalSongs++;
+							}
+						});
+						if (totalSongs <= 2) return callback(true);
+						if (totalSongs > 3) return callback(false);
+						if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return callback(true);
+						return callback(false);
+					}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
+					*/
+		
+					let songTitle = (title) => {
+						return isLength(title, 1, 100);
+					};
+					this.schemas.song.path('title').validate(songTitle, 'Invalid title.');
+					this.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
+		
+					this.schemas.song.path('artists').validate((artists) => {
+						return !(artists.length < 1 || artists.length > 10);
+					}, 'Invalid artists.');
+					this.schemas.queueSong.path('artists').validate((artists) => {
+						return !(artists.length < 0 || artists.length > 10);
+					}, 'Invalid artists.');
+		
+					let songArtists = (artists) => {
+						return artists.filter((artist) => {
+								return (isLength(artist, 1, 32) && regex.ascii.test(artist) && artist !== "NONE");
+							}).length === artists.length;
+					};
+					this.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
+					this.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
+		
+					/*let songGenres = (genres) => {
+						return genres.filter((genre) => {
+								return (isLength(genre, 1, 16) && regex.azAZ09_.test(genre));
+							}).length === genres.length;
+					};
+					this.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
+					this.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');*/
+		
+					this.schemas.song.path('thumbnail').validate((thumbnail) => {
+						return isLength(thumbnail, 8, 256);
+					}, 'Invalid thumbnail.');
+					this.schemas.queueSong.path('thumbnail').validate((thumbnail) => {
+						return isLength(thumbnail, 0, 256);
+					}, 'Invalid thumbnail.');
+		
+					this.schemas.playlist.path('displayName').validate((displayName) => {
+						return (isLength(displayName, 1, 16) && regex.ascii.test(displayName));
+					}, 'Invalid display name.');
+		
+					this.schemas.playlist.path('createdBy').validate((createdBy) => {
+						this.models.playlist.countDocuments({ createdBy: createdBy }, (err, c) => {
+							return !(err || c >= 10);
+						});
+					}, 'Max 10 playlists per user.');
+		
+					this.schemas.playlist.path('songs').validate((songs) => {
+						return songs.length <= 2000;
+					}, 'Max 2000 songs per playlist.');
+		
+					this.schemas.playlist.path('songs').validate((songs) => {
+						if (songs.length === 0) return true;
+						return songs[0].duration <= 10800;
+					}, 'Max 3 hours per song.');
+		
+					this.schemas.report.path('description').validate((description) => {
+						return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
+					}, 'Invalid description.');
+
+					resolve();
+				})
+				.catch(err => {
+					this.logger.error("DB_MODULE", err);
+					reject(err);
+				});
+		})
+	}
 
-	passwordValid: (password) => {
+	passwordValid(password) {
 		if (!isLength(password, 6, 200)) return false;
 		return regex.password.test(password);
-	},
-
-	_lockdown: () => {
-		lib.connection.close();
-		lockdown = true;
 	}
-};
-
-module.exports = lib;
+}

+ 2 - 1
backend/logic/db/schemas/queueSong.js

@@ -8,5 +8,6 @@ module.exports = {
 	thumbnail: { type: String, required: true },
 	explicit: { type: Boolean, required: true },
 	requestedBy: { type: String, required: true },
-	requestedAt: { type: Date, default: Date.now(), required: true }
+	requestedAt: { type: Date, default: Date.now(), required: true },
+	discogs: { type: Object }
 };

+ 4 - 1
backend/logic/db/schemas/report.js

@@ -1,6 +1,9 @@
 module.exports = {
 	resolved: { type: Boolean, default: false, required: true },
-	songId: { type: String, required: true },
+	song: {
+		_id: { type: String, required: true },
+		songId: { type: String, required: true },
+	},
 	description: { type: String },
 	issues: [{
 		name: String,

+ 2 - 1
backend/logic/db/schemas/song.js

@@ -12,5 +12,6 @@ module.exports = {
 	requestedBy: { type: String, required: true },
 	requestedAt: { type: Date, required: true },
 	acceptedBy: { type: String, required: true },
-	acceptedAt: { type: Date, default: Date.now(), required: true }
+	acceptedAt: { type: Date, default: Date.now(), required: true },
+	discogs: { type: Object }
 };

+ 1 - 0
backend/logic/db/schemas/user.js

@@ -28,5 +28,6 @@ module.exports = {
 	},
 	liked: [{ type: String }],
 	disliked: [{ type: String }],
+	favoriteStations: [{ type: String }],
 	createdAt: { type: Date, default: Date.now() }
 };

+ 78 - 94
backend/logic/discord.js

@@ -1,107 +1,91 @@
-let lockdown = false;
+const coreClass = require("../core");
 
+const EventEmitter = require('events');
 const Discord = require("discord.js");
-const logger = require("./logger");
 const config = require("config");
 
-const client = new Discord.Client();
+const bus = new EventEmitter();
 
-let messagesToSend = [];
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
 
-let connected = false;
+			this.client = new Discord.Client();
+			this.adminAlertChannelId = config.get("apis.discord").loggingChannel;
+			
+			this.client.on("ready", () => {
+				this.logger.info("DISCORD_MODULE", `Logged in as ${this.client.user.tag}!`);
 
-// TODO Maybe we need to only finish init when ready is called, or maybe we don't wait for it
-module.exports = {
-  adminAlertChannelId: "",
+				if (this.state === "INITIALIZING") resolve();
+				else {
+					this.logger.info("DISCORD_MODULE", `Discord client reconnected.`);
+					this.setState("INITIALIZED");
+				}
+			});
+		  
+			this.client.on("disconnect", () => {
+				this.logger.info("DISCORD_MODULE", `Discord client disconnected.`);
 
-  init: function(discordToken, adminAlertChannelId, errorCb, cb) {
-    this.adminAlertChannelId = adminAlertChannelId;
+				if (this.state === "INITIALIZING") reject();
+				else {
+					this.failed = true;
+					this._lockdown;
+				} 
+			});
 
-    client.on("ready", () => {
-      logger.info("DISCORD_READY", `Logged in as ${client.user.tag}!`);
-      connected = true;
-      messagesToSend.forEach(message => {
-        this.sendAdminAlertMessage(message.message, message.color, message.type, message.critical, message.extraFields);
-      });
-      messagesToSend = [];
-    });
+			this.client.on("reconnecting", () => {
+				this.logger.info("DISCORD_MODULE", `Discord client reconnecting.`);
+				this.setState("RECONNECTING");
+			});
+		
+			this.client.on("error", err => {
+				this.logger.info("DISCORD_MODULE", `Discord client encountered an error: ${err.message}.`);
+			});
 
-    client.on("invalidated", () => {
-      logger.info("DISCORD_INVALIDATED", `Discord client was invalidated.`);
-      connected = false;
-    });
+			this.client.login(config.get("apis.discord").token);
+		});
+	}
 
-    client.on("disconnected", () => {
-      logger.info("DISCORD_DISCONNECTED", `Discord client was disconnected.`);
-      connected = false;
-    });
+	async sendAdminAlertMessage(message, color, type, critical, extraFields) {
+		try { await this._validateHook(); } catch { return; }
 
-    client.on("error", err => {
-      logger.info(
-        "DISCORD_ERROR",
-        `Discord client encountered an error: ${err.message}.`
-      );
-    });
+		const channel = this.client.channels.find("id", this.adminAlertChannelId);
+		if (channel !== null) {
+			let richEmbed = new Discord.RichEmbed();
+			richEmbed.setAuthor(
+				"Musare Logger",
+				`${config.get("domain")}/favicon-194x194.png`,
+				config.get("domain")
+			);
+			richEmbed.setColor(color);
+			richEmbed.setDescription(message);
+			//richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
+			//richEmbed.setImage("https://musare.com/favicon-194x194.png");
+			//richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
+			richEmbed.setTimestamp(new Date());
+			richEmbed.setTitle("MUSARE ALERT");
+			richEmbed.setURL(config.get("domain"));
+			richEmbed.addField("Type:", type, true);
+			richEmbed.addField("Critical:", critical ? "True" : "False", true);
+			extraFields.forEach(extraField => {
+				richEmbed.addField(
+					extraField.name,
+					extraField.value,
+					extraField.inline
+				);
+			});
 
-    client.login(discordToken);
-
-    if (lockdown) return this._lockdown();
-    cb();
-  },
-
-  sendAdminAlertMessage: function(message, color, type, critical, extraFields) {
-    if (!connected) return messagesToSend.push({message, color, type, critical, extraFields});
-    else {
-      let channel = client.channels.find("id", this.adminAlertChannelId);
-      if (channel !== null) {
-        let richEmbed = new Discord.RichEmbed();
-        richEmbed.setAuthor(
-          "Musare Logger",
-          config.get("domain") + "/favicon-194x194.png",
-          config.get("domain")
-        );
-        richEmbed.setColor(color);
-        richEmbed.setDescription(message);
-        //richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
-        //richEmbed.setImage("https://musare.com/favicon-194x194.png");
-        //richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
-        richEmbed.setTimestamp(new Date());
-        richEmbed.setTitle("MUSARE ALERT");
-        richEmbed.setURL(config.get("domain"));
-        richEmbed.addField("Type:", type, true);
-        richEmbed.addField("Critical:", critical ? "True" : "False", true);
-        extraFields.forEach(extraField => {
-          richEmbed.addField(
-            extraField.name,
-            extraField.value,
-            extraField.inline
-          );
-        });
-
-        channel
-          .send(message, { embed: richEmbed })
-          .then(message =>
-            logger.success(
-              "SEND_ADMIN_ALERT_MESSAGE",
-              `Sent admin alert message: ${message}`
-            )
-          )
-          .catch(() =>
-            logger.error(
-              "SEND_ADMIN_ALERT_MESSAGE",
-              "Couldn't send admin alert message"
-            )
-          );
-      } else {
-        logger.error(
-          "SEND_ADMIN_ALERT_MESSAGE",
-          "Couldn't send admin alert message, channel was not found."
-        );
-      }
-    }
-  },
-
-  _lockdown: () => {
-    lockdown = true;
-  }
-};
+			channel
+			.send(message, { embed: richEmbed })
+			.then(message =>
+				this.logger.success("SEND_ADMIN_ALERT_MESSAGE", `Sent admin alert message: ${message}`)
+			)
+			.catch(() =>
+				this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message")
+			);
+		} else {
+			this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message, channel was not found.");
+		}
+	}
+}

+ 161 - 138
backend/logic/io.js

@@ -2,165 +2,188 @@
 
 // This file contains all the logic for Socket.IO
 
-const app = require('./app');
-const actions = require('./actions');
-const async = require('async');
-const cache = require('./cache');
-const utils = require('./utils');
-const db = require('./db');
-const logger = require('./logger');
-const punishments = require('./punishments');
+const coreClass = require("../core");
 
-let initialized = false;
-let lockdown = false;
+const socketio = require("socket.io");
+const async = require("async");
+const config = require("config");
 
-module.exports = {
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-	io: null,
+		this.dependsOn = ["app", "db", "cache", "utils"];
+	}
 
-	init: (cb) => {
-		//TODO Check every 30s/60s, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
-		this.io = require('socket.io')(app.server);
+	initialize() {
+		return new Promise(resolve => {
+			this.setStage(1);
 
-		this.io.use((socket, next) => {
-			if (lockdown) return;
-			let cookies = socket.request.headers.cookie;
-			let SID = utils.cookies.parseCookies(cookies).SID;
+			const 	logger		= this.logger,
+					app			= this.moduleManager.modules["app"],
+					cache		= this.moduleManager.modules["cache"],
+					utils		= this.moduleManager.modules["utils"],
+					db			= this.moduleManager.modules["db"],
+					punishments	= this.moduleManager.modules["punishments"];
+			
+			const actions = require('../logic/actions');
 
-			socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
+			const SIDname = config.get("cookie.SIDname");
 
-			async.waterfall([
-				(next) => {
-					if (!SID) return next('No SID.');
-					next();
-				},
-				(next) => {
-					cache.hget('sessions', SID, next);
-				},
-				(session, next) => {
-					if (!session) return next('No session found.');
-					session.refreshDate = Date.now();
-					socket.session = session;
-					cache.hset('sessions', SID, session, next);
-				},
-				(res, next) => {
-					punishments.getPunishments((err, punishments) => {
-						const isLoggedIn = !!(socket.session && socket.session.refreshDate);
-						const userId = (isLoggedIn) ? socket.session.userId : null;
-						let ban = 0;
-						let banned = false;
-						punishments.forEach(punishment => {
-							if (punishment.expiresAt > ban) ban = punishment;
-							if (punishment.type === 'banUserId' && isLoggedIn && punishment.value === userId) banned = true;
-							if (punishment.type === 'banUserIp' && punishment.value === socket.ip) banned = true;
+			// TODO: Check every 30s/60s, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
+			this.io = socketio(app.server);
+
+			this.io.use(async (socket, next) => {
+				try { await this._validateHook(); } catch { return; }
+
+				let SID;
+
+				socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
+
+				async.waterfall([
+					(next) => {
+						utils.parseCookies(
+							socket.request.headers.cookie
+						).then(res => {
+							SID = res[SIDname];
+							next(null);
 						});
-						socket.banned = banned;
-						socket.ban = ban;
+					},
+
+					(next) => {
+						if (!SID) return next('No SID.');
 						next();
-					});
-				}
-			], () => {
-				if (!socket.session) {
-					socket.session = { socketId: socket.id };
-				} else socket.session.socketId = socket.id;
-				next();
-			});
-		});
+					},
 
-		this.io.on('connection', socket => {
-			if (lockdown) return socket.disconnect(true);
-			let sessionInfo = '';
-			if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-			if (socket.banned) {
-				logger.info('IO_BANNED_CONNECTION', `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`);
-				socket.emit('keep.event:banned', socket.ban);
-				socket.disconnect(true);
-			} else {
-				logger.info('IO_CONNECTION', `User connected. IP: ${socket.ip}.${sessionInfo}`);
-
-				// catch when the socket has been disconnected
-				socket.on('disconnect', (reason) => {
-					let sessionInfo = '';
-					if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-					logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
-				});
+					(next) => {
+						cache.hget('sessions', SID, next);
+					},
 
-				// catch errors on the socket (internal to socket.io)
-				socket.on('error', err => console.error(err));
+					(session, next) => {
+						if (!session) return next('No session found.');
 
-				// have the socket listen for each action
-				Object.keys(actions).forEach((namespace) => {
-					Object.keys(actions[namespace]).forEach((action) => {
+						session.refreshDate = Date.now();
+						
+						socket.session = session;
+						cache.hset('sessions', SID, session, next);
+					},
 
-						// the full name of the action
-						let name = `${namespace}.${action}`;
+					(res, next) => {
+						// check if a session's user / IP is banned
+						punishments.getPunishments((err, punishments) => {
+							const isLoggedIn = !!(socket.session && socket.session.refreshDate);
+							const userId = (isLoggedIn) ? socket.session.userId : null;
 
-						// listen for this action to be called
-						socket.on(name, function () {
-							let args = Array.prototype.slice.call(arguments, 0, -1);
-							let cb = arguments[arguments.length - 1];
+							let banishment = { banned: false, ban: 0 };
 
-							if (lockdown) return cb({status: 'failure', message: 'Lockdown'});
+							punishments.forEach(punishment => {
+								if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
+								if (punishment.type === 'banUserId' && isLoggedIn && punishment.value === userId) banishment.banned = true;
+								if (punishment.type === 'banUserIp' && punishment.value === socket.ip) banishment.banned = true;
+							});
+							
+							socket.banishment = banishment;
 
-							// load the session from the cache
-							cache.hget('sessions', socket.session.sessionId, (err, session) => {
-								if (err && err !== true) {
-									if (typeof cb === 'function') return cb({
-										status: 'error',
-										message: 'An error occurred while obtaining your session'
-									});
-								}
+							next();
+						});
+					}
+				], () => {
+					if (!socket.session) socket.session = { socketId: socket.id };
+					else socket.session.socketId = socket.id;
 
-								// make sure the sockets sessionId isn't set if there is no session
-								if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+					next();
+				});
+			});
+
+			this.io.on('connection', async socket => {
+				try { await this._validateHook(); } catch { return; }
+
+				let sessionInfo = '';
+				
+				if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+
+				// if session is banned
+				if (socket.banishment && socket.banishment.banned) {
+					logger.info('IO_BANNED_CONNECTION', `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`);
+					socket.emit('keep.event:banned', socket.banishment.ban);
+					socket.disconnect(true);
+				} else {
+					logger.info('IO_CONNECTION', `User connected. IP: ${socket.ip}.${sessionInfo}`);
+
+					// catch when the socket has been disconnected
+					socket.on('disconnect', () => {
+						if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+						logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
+					});
 
-								// call the action, passing it the session, and the arguments socket.io passed us
-								actions[namespace][action].apply(null, [socket.session].concat(args).concat([
-									(result) => {
-										// respond to the socket with our message
-										if (typeof cb === 'function') return cb(result);
+					// catch errors on the socket (internal to socket.io)
+					socket.on('error', console.error);
+
+					// have the socket listen for each action
+					Object.keys(actions).forEach(namespace => {
+						Object.keys(actions[namespace]).forEach(action => {
+
+							// the full name of the action
+							let name = `${namespace}.${action}`;
+
+							// listen for this action to be called
+							socket.on(name, async (...args) => {
+								let cb = args[args.length - 1];
+								if (typeof cb !== "function")
+									cb = () => {
+										this.logger.info("IO_MODULE", `There was no callback provided for ${name}.`);
 									}
-								]));
-							});
-						})
-					})
-				});
+								else args.pop();
 
-				if (socket.session.sessionId) {
-					cache.hget('sessions', socket.session.sessionId, (err, session) => {
-						if (err && err !== true) socket.emit('ready', false);
-						else if (session && session.userId) {
-							db.models.user.findOne({ _id: session.userId }, (err, user) => {
-								if (err || !user) return socket.emit('ready', false);
-								let role = '';
-								let username = '';
-								let userId = '';
-								if (user) {
-									role = user.role;
-									username = user.username;
-									userId = session.userId;
-								}
-								socket.emit('ready', true, role, username, userId);
-							});
-						} else socket.emit('ready', false);
-					})
-				} else socket.emit('ready', false);
-			}
-		});
+								try { await this._validateHook(); } catch { return cb({status: 'failure', message: 'Lockdown'}); } 
+
+								// load the session from the cache
+								cache.hget('sessions', socket.session.sessionId, (err, session) => {
+									if (err && err !== true) {
+										if (typeof cb === 'function') return cb({
+											status: 'error',
+											message: 'An error occurred while obtaining your session'
+										});
+									}
 
-		initialized = true;
+									// make sure the sockets sessionId isn't set if there is no session
+									if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+
+									// call the action, passing it the session, and the arguments socket.io passed us
+									actions[namespace][action].apply(null, [socket.session].concat(args).concat([
+										(result) => {
+											// respond to the socket with our message
+											if (typeof cb === 'function') return cb(result);
+										}
+									]));
+								});
+							})
+						})
+					});
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+					if (socket.session.sessionId) {
+						cache.hget('sessions', socket.session.sessionId, (err, session) => {
+							if (err && err !== true) socket.emit('ready', false);
+							else if (session && session.userId) {
+								db.models.user.findOne({ _id: session.userId }, (err, user) => {
+									if (err || !user) return socket.emit('ready', false);
+									let role = '';
+									let username = '';
+									let userId = '';
+									if (user) {
+										role = user.role;
+										username = user.username;
+										userId = session.userId;
+									}
+									socket.emit('ready', true, role, username, userId);
+								});
+							} else socket.emit('ready', false);
+						})
+					} else socket.emit('ready', false);
+				}
+			});
 
-	_lockdown: () => {
-		this.io.close();
-		let connected = this.io.of('/').connected;
-		for (let key in connected) {
-			connected[key].disconnect('Lockdown');
-		}
-		lockdown = true;
+			resolve();
+		});
 	}
-
-};
+}

+ 151 - 178
backend/logic/logger.js

@@ -1,78 +1,15 @@
 'use strict';
 
-const dir = `${__dirname}/../../log`;
-const fs = require('fs');
-const config = require('config');
-//const Discord = require("discord.js");
-let client;
-let utils;
-
-if (!config.isDocker && !fs.existsSync(`${dir}`)) {
-	fs.mkdirSync(dir);
-}
-
-let started;
-let success = 0;
-let successThisMinute = 0;
-let successThisHour = 0;
-let error = 0;
-let errorThisMinute = 0;
-let errorThisHour = 0;
-let info = 0;
-let infoThisMinute = 0;
-let infoThisHour = 0;
-
-let successUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
-let errorUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
-let infoUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
-
-let successUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
-let errorUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
-let infoUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
-
-function calculateUnits(units, unit) {
-	units.push(unit);
-	if (units.length > 10) units.shift();
-	return units;
-}
-
-function calculateHourUnits() {
-	successUnitsPerHour = calculateUnits(successUnitsPerHour, successThisHour);
-	errorUnitsPerHour = calculateUnits(errorUnitsPerHour, errorThisHour);
-	infoUnitsPerHour = calculateUnits(infoUnitsPerHour, infoThisHour);
+const coreClass = require("../core");
 
-	successThisHour = 0;
-	errorThisHour = 0;
-	infoThisHour = 0;
-
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.success.units.hour', successUnitsPerHour);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.error.units.hour', errorUnitsPerHour);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.info.units.hour', infoUnitsPerHour);
-
-	setTimeout(calculateHourUnits, 1000 * 60 * 60)
-}
-
-function calculateMinuteUnits() {
-	successUnitsPerMinute = calculateUnits(successUnitsPerMinute, successThisMinute);
-	errorUnitsPerMinute = calculateUnits(errorUnitsPerMinute, errorThisMinute);
-	infoUnitsPerMinute = calculateUnits(infoUnitsPerMinute, infoThisMinute);
-
-	successThisMinute = 0;
-	errorThisMinute = 0;
-	infoThisMinute = 0;
-
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.success.units.minute', successUnitsPerMinute);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.error.units.minute', errorUnitsPerMinute);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.info.units.minute', infoUnitsPerMinute);
-	
-	setTimeout(calculateMinuteUnits, 1000 * 60)
-}
+const config = require('config');
+const fs = require('fs');
 
-let twoDigits = (num) => {
+const twoDigits = (num) => {
 	return (num < 10) ? '0' + num : num;
 };
 
-let getTime = () => {
+const getTime = () => {
 	let time = new Date();
 	return {
 		year: time.getFullYear(),
@@ -84,121 +21,157 @@ let getTime = () => {
 	}
 };
 
-let getTimeFormatted = () => {
+const getTimeFormatted = () => {
 	let time = getTime();
 	return `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
 }
 
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
-	init: function(cb) {
-		utils = require('./utils');
-		started = Date.now();
-
-		setTimeout(calculateMinuteUnits, 1000 * 60);
-		setTimeout(calculateHourUnits, 1000 * 60 * 60);
-		setTimeout(this.calculate, 1000 * 30);
-
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/success.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/error.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/info.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/debugStation.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-
-		initialized = true;
-
-		if (lockdown) return this._lockdown();
-		cb();
-	},
-	success: (type, message, display = true) => {
-		if (lockdown) return;
-		success++;
-		successThisMinute++;
-		successThisHour++;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} SUCCESS - ${type} - ${message}\n`, ()=>{});
-		fs.appendFile(dir + '/success.log', `${time} SUCCESS - ${type} - ${message}\n`, ()=>{});
-		if (display) console.info('\x1b[32m', time, 'SUCCESS', '-', type, '-', message, '\x1b[0m');
-	},
-	error: (type, message, display = true) => {
-		if (lockdown) return;
-		error++;
-		errorThisMinute++;
-		errorThisHour++;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} ERROR - ${type} - ${message}\n`, ()=>{});
-		fs.appendFile(dir + '/error.log', `${time} ERROR - ${type} - ${message}\n`, ()=>{});
-		if (display) console.warn('\x1b[31m', time, 'ERROR', '-', type, '-', message, '\x1b[0m');
-	},
-	info: (type, message, display = true) => {
-		if (lockdown) return;
-		info++;
-		infoThisMinute++;
-		infoThisHour++;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} INFO - ${type} - ${message}\n`, ()=>{});
-		fs.appendFile(dir + '/info.log', `${time} INFO - ${type} - ${message}\n`, ()=>{});
-		if (display) console.info('\x1b[36m', time, 'INFO', '-', type, '-', message, '\x1b[0m');
-	},
-	stationIssue: (string, display = false) => {
-		if (lockdown) return;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/debugStation.log', `${time} - ${string}\n`, ()=>{});
-		if (display) console.info('\x1b[35m', time, '-', string, '\x1b[0m');
-	},
-	calculatePerSecond: function(number) {
-		if (lockdown) return;
-		let secondsRunning = Math.floor((Date.now() - started) / 1000);
-		let perSecond = number / secondsRunning;
-		return perSecond;
-	},
-	calculatePerMinute: function(number) {
-		if (lockdown) return;
-		let perMinute = this.calculatePerSecond(number) * 60;
-		return perMinute;
-	},
-	calculatePerHour: function(number) {
-		if (lockdown) return;
-		let perHour = this.calculatePerMinute(number) * 60;
-		return perHour;
-	},
-	calculatePerDay: function(number) {
-		if (lockdown) return;
-		let perDay = this.calculatePerHour(number) * 24;
-		return perDay;
-	},
-	calculate: function() {
-		if (lockdown) return;
-		let _this = module.exports;
-		utils.emitToRoom('admin.statistics', 'event:admin.statistics.logs', {
-			second: {
-				success: _this.calculatePerSecond(success),
-				error: _this.calculatePerSecond(error),
-				info: _this.calculatePerSecond(info)
-			},
-			minute: {
-				success: _this.calculatePerMinute(success),
-				error: _this.calculatePerMinute(error),
-				info: _this.calculatePerMinute(info)
-			},
-			hour: {
-				success: _this.calculatePerHour(success),
-				error: _this.calculatePerHour(error),
-				info: _this.calculatePerHour(info)
-			},
-			day: {
-				success: _this.calculatePerDay(success),
-				error: _this.calculatePerDay(error),
-				info: _this.calculatePerDay(info)
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
+		this.lockdownImmune = true;
+	}
+
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.configDirectory = `${__dirname}/../../log`;
+
+			if (!config.isDocker && !fs.existsSync(`${this.configDirectory}`))
+				fs.mkdirSync(this.configDirectory);
+
+			let time = getTimeFormatted();
+
+			this.logCbs = [];
+
+			this.colors = {
+				Reset: "\x1b[0m",
+				Bright: "\x1b[1m",
+				Dim: "\x1b[2m",
+				Underscore: "\x1b[4m",
+				Blink: "\x1b[5m",
+				Reverse: "\x1b[7m",
+				Hidden: "\x1b[8m",
+
+				FgBlack: "\x1b[30m",
+				FgRed: "\x1b[31m",
+				FgGreen: "\x1b[32m",
+				FgYellow: "\x1b[33m",
+				FgBlue: "\x1b[34m",
+				FgMagenta: "\x1b[35m",
+				FgCyan: "\x1b[36m",
+				FgWhite: "\x1b[37m",
+
+				BgBlack: "\x1b[40m",
+				BgRed: "\x1b[41m",
+				BgGreen: "\x1b[42m",
+				BgYellow: "\x1b[43m",
+				BgBlue: "\x1b[44m",
+				BgMagenta: "\x1b[45m",
+				BgCyan: "\x1b[46m",
+				BgWhite: "\x1b[47m"
+			};
+
+			fs.appendFile(this.configDirectory + '/all.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/success.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/error.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/info.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/debugStation.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+
+			if (this.moduleManager.fancyConsole) {
+				process.stdout.write(Array(this.reservedLines).fill(`\n`).join(""));
 			}
+
+			resolve();
 		});
-		setTimeout(_this.calculate, 1000 * 30);
-	},
+	}
+
+	async success(type, text, display = true) {
+		try { await this._validateHook(); } catch { return; }
 
-	_lockdown: () => {
-		lockdown = true;
+		const time = getTimeFormatted();
+		const message = `${time} SUCCESS - ${type} - ${text}`;
+
+		this.writeFile('all.log', message);
+		this.writeFile('success.log', message);
+
+		if (display) this.log(this.colors.FgGreen, message);
 	}
-};
+
+	async error(type, text, display = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} ERROR - ${type} - ${text}`;
+
+		this.writeFile('all.log', message);
+		this.writeFile('error.log', message);
+
+		if (display) this.log(this.colors.FgRed, message);
+	}
+
+	async info(type, text, display = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} INFO - ${type} - ${text}`;
+
+		this.writeFile('all.log', message);
+		this.writeFile('info.log', message);
+		if (display) this.log(this.colors.FgCyan, message);
+	}
+
+	async debug(text, display = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} DEBUG - ${text}`;
+
+		if (display) this.log(this.colors.FgMagenta, message);
+	}
+
+	async stationIssue(text, display = false) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} DEBUG_STATION - ${text}`;
+
+		this.writeFile('debugStation.log', message);
+
+		if (display) this.log(this.colors.FgMagenta, message);
+	}
+
+	log(color, message) {
+		if (this.moduleManager.fancyConsole) {
+			const rows = process.stdout.rows;
+			const columns = process.stdout.columns;
+			const lineNumber = rows - this.reservedLines;
+
+			
+			let lines = 0;
+			
+			message.split("\n").forEach((line) => {
+				lines += Math.floor(line.replace("\t", "    ").length / columns) + 1;
+			});
+
+			if (lines > this.logger.reservedLines)
+				lines = this.logger.reservedLines;
+
+			process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+			process.stdout.clearScreenDown();
+
+			process.stdout.cursorTo(0, lineNumber);
+			process.stdout.write(`${color}${message}${this.colors.Reset}\n`);
+
+			process.stdout.cursorTo(0, process.stdout.rows);
+			process.stdout.write(Array(lines).fill(`\n!`).join(""));
+
+			this.moduleManager.printStatus();
+		} else console.log(`${color}${message}${this.colors.Reset}`);
+	}
+
+	writeFile(fileName, message) {
+		fs.appendFile(`${this.configDirectory}/${fileName}`, `${message}\n`, ()=>{});
+	}
+}

+ 30 - 35
backend/logic/mail/index.js

@@ -1,45 +1,40 @@
 'use strict';
 
-const config = require('config');
-const enabled = config.get('apis.mailgun.enabled');
-let mailgun = null;
-if (enabled) {
-	mailgun = require('mailgun-js')({
-		apiKey: config.get("apis.mailgun.key"),
-		domain: config.get("apis.mailgun.domain")
-	});
-}
+const coreClass = require("../../core");
 
-let initialized = false;
-let lockdown = false;
-
-let lib = {
-
-	schemas: {},
+const config = require('config');
 
-	init: (cb) => {
-		lib.schemas = {
-			verifyEmail: require('./schemas/verifyEmail'),
-			resetPasswordRequest: require('./schemas/resetPasswordRequest'),
-			passwordRequest: require('./schemas/passwordRequest')
-		};
+let mailgun = null;
 
-		initialized = true;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.schemas = {
+				verifyEmail: require('./schemas/verifyEmail'),
+				resetPasswordRequest: require('./schemas/resetPasswordRequest'),
+				passwordRequest: require('./schemas/passwordRequest')
+			};
+
+			this.enabled = config.get('apis.mailgun.enabled');
+
+			if (this.enabled)
+				mailgun = require('mailgun-js')({
+					apiKey: config.get("apis.mailgun.key"),
+					domain: config.get("apis.mailgun.domain")
+				});
+			
+			resolve();
+		});
+	}
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+	async sendMail(data, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	sendMail: (data, cb) => {
-		if (lockdown) return cb('Lockdown');
 		if (!cb) cb = ()=>{};
-		if (enabled) mailgun.messages().send(data, cb);
-		else cb();
-	},
 
-	_lockdown: () => {
-		lockdown = true;
+		if (this.enabled) mailgun.messages().send(data, cb);
+		else cb();
 	}
-};
-
-module.exports = lib;
+}

+ 4 - 1
backend/logic/mail/schemas/passwordRequest.js

@@ -1,5 +1,8 @@
 const config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 /**
  * Sends a request password email

+ 4 - 1
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,5 +1,8 @@
 const config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 /**
  * Sends a request password reset email

+ 4 - 1
backend/logic/mail/schemas/verifyEmail.js

@@ -1,5 +1,8 @@
 const config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 /**
  * Sends a verify email email

+ 110 - 58
backend/logic/notifications.js

@@ -1,49 +1,104 @@
 'use strict';
 
+const coreClass = require("../core");
+
 const crypto = require('crypto');
 const redis = require('redis');
-const logger = require('./logger');
+const config = require('config');
 
 const subscriptions = [];
 
-let initialized = false;
-let lockdown = false;
-let errorCb;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
 
-const lib = {
+			const url = this.url = config.get("redis").url;
+			const password = this.password = config.get("redis").password;
 
-	pub: null,
-	sub: null,
-	errorCb: null,
+			this.pub = redis.createClient({
+				url,
+				password,
+				retry_strategy: (options) => {
+					if (this.state === "LOCKDOWN") return;
+					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
 
-	/**
-	 * Initializes the notifications module
-	 *
-	 * @param {String} url - the url of the redis server
-	 * @param {String} password - the password of the redis server
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: (url, password, errorCb, cb) => {
-		lib.errorCb = errorCb;
-		lib.pub = redis.createClient({ url, password });
-		lib.sub = redis.createClient({ url, password });
-		lib.sub.on('error', (err) => {
-			errorCb('Cache connection error.', err, 'Notifications');
-		});
-		lib.sub.on('pmessage', (pattern, channel, expiredKey) => {
-			logger.stationIssue(`PMESSAGE - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
-			subscriptions.forEach((sub) => {
-				if (sub.name !== expiredKey) return;
-				sub.cb();
+					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect pub.`);
+
+					if (options.attempt >= 10) {
+						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect pub.`);
+
+						this.failed = true;
+						this._lockdown();
+
+						return undefined;
+					}
+
+					return 3000;
+				}
 			});
-		});
-		lib.sub.psubscribe('__keyevent@0__:expired');
+			this.sub = redis.createClient({
+				url,
+				password,
+				retry_strategy: (options) => {
+					if (this.state === "LOCKDOWN") return;
+					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
 
-		initialized = true;
+					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect sub.`);
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+					if (options.attempt >= 10) {
+						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect sub.`);
+
+						this.failed = true;
+						this._lockdown();
+
+						return undefined;
+					}
+
+					return 3000;
+				}
+			});
+
+			this.sub.on('error', (err) => {
+				if (this.state === "INITIALIZING") reject(err);
+				if(this.state === "LOCKDOWN") return;
+
+				this.logger.error("NOTIFICATIONS_MODULE", `Sub error ${err.message}.`);
+			});
+
+			this.pub.on('error', (err) => {
+				if (this.state === "INITIALIZING") reject(err);
+				if(this.state === "LOCKDOWN") return; 
+
+				this.logger.error("NOTIFICATIONS_MODULE", `Pub error ${err.message}.`);
+			});
+
+			this.sub.on("connect", () => {
+				this.logger.info("NOTIFICATIONS_MODULE", "Sub connected succesfully.");
+
+				if (this.state === "INITIALIZING") resolve();
+				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
+				
+			});
+
+			this.pub.on("connect", () => {
+				this.logger.info("NOTIFICATIONS_MODULE", "Pub connected succesfully.");
+
+				if (this.state === "INITIALIZING") resolve();
+				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
+			});
+
+			this.sub.on('pmessage', (pattern, channel, expiredKey) => {
+				this.logger.stationIssue(`PMESSAGE - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
+				subscriptions.forEach((sub) => {
+					if (sub.name !== expiredKey) return;
+					sub.cb();
+				});
+			});
+
+			this.sub.psubscribe('__keyevent@0__:expired');
+		});
+	}
 
 	/**
 	 * Schedules a notification to be dispatched in a specific amount of milliseconds,
@@ -54,13 +109,15 @@ const lib = {
 	 * @param {Integer} time - how long in milliseconds until the notification should be fired
 	 * @param {Function} cb - gets called when the notification has been scheduled
 	 */
-	schedule: (name, time, cb, station) => {
-		if (lockdown) return;
+	async schedule(name, time, cb, station) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!cb) cb = ()=>{};
+
 		time = Math.round(time);
-		logger.stationIssue(`SCHEDULE - Time: ${time}; Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}; StationId: ${station._id}; StationName: ${station.name}`);
-		lib.pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
-	},
+		this.logger.stationIssue(`SCHEDULE - Time: ${time}; Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}; StationId: ${station._id}; StationName: ${station.name}`);
+		this.pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
+	}
 
 	/**
 	 * Subscribes a callback function to be called when a notification gets called
@@ -70,37 +127,32 @@ const lib = {
 	 * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
 	 * @return {Object} - the subscription object
 	 */
-	subscribe: (name, cb, unique = false, station) => {
-		if (lockdown) return;
-		logger.stationIssue(`SUBSCRIBE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}, StationId: ${station._id}; StationName: ${station.name}; Unique: ${unique}; SubscriptionExists: ${!!subscriptions.find((subscription) => subscription.originalName == name)};`);
+	async subscribe(name, cb, unique = false, station) {
+		try { await this._validateHook(); } catch { return; }
+
+		this.logger.stationIssue(`SUBSCRIBE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}, StationId: ${station._id}; StationName: ${station.name}; Unique: ${unique}; SubscriptionExists: ${!!subscriptions.find((subscription) => subscription.originalName == name)};`);
 		if (unique && !!subscriptions.find((subscription) => subscription.originalName == name)) return;
 		let subscription = { originalName: name, name: crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), cb };
 		subscriptions.push(subscription);
 		return subscription;
-	},
+	}
 
 	/**
 	 * Remove a notification subscription
 	 *
 	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
 	 */
-	remove: (subscription) => {
-		if (lockdown) return;
+	async remove(subscription) {
+		try { await this._validateHook(); } catch { return; }
+
 		let index = subscriptions.indexOf(subscription);
 		if (index) subscriptions.splice(index, 1);
-	},
-
-	unschedule: (name) => {
-		if (lockdown) return;
-		logger.stationIssue(`UNSCHEDULE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}`);
-		lib.pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
-	},
-
-	_lockdown: () => {
-		lib.pub.quit();
-		lib.sub.quit();
-		lockdown = true;
 	}
-};
 
-module.exports = lib;
+	async unschedule(name) {
+		try { await this._validateHook(); } catch { return; }
+
+		this.logger.stationIssue(`UNSCHEDULE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}`);
+		this.pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
+	}
+}

+ 79 - 74
backend/logic/playlists.js

@@ -1,59 +1,66 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const async = require('async');
-
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
+const coreClass = require("../core");
 
-	/**
-	 * Initializes the playlists module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('playlists', next);
-			},
+const async = require('async');
 
-			(playlists, next) => {
-				if (!playlists) return next();
-				let playlistIds = Object.keys(playlists);
-				async.each(playlistIds, (playlistId, next) => {
-					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-						if (err) next(err);
-						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
-						}
-						else next();
-					});
-				}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(next) => {
-				db.models.playlist.find({}, next);
-			},
+		this.dependsOn = ["cache", "db", "utils"];
+	}
 
-			(playlists, next) => {
-				async.each(playlists, (playlist, next) => {
-					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist), next);
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db	= this.moduleManager.modules["db"];
+			this.utils	= this.moduleManager.modules["utils"];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('playlists', next);
+				},
+	
+				(playlists, next) => {
+					this.setStage(3);
+					if (!playlists) return next();
+					let playlistIds = Object.keys(playlists);
+					async.each(playlistIds, (playlistId, next) => {
+						this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+							if (err) next(err);
+							else if (!playlist) {
+								this.cache.hdel('playlists', playlistId, next);
+							}
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.playlist.find({}, next);
+				},
+	
+				(playlists, next) => {
+					this.setStage(5);
+					async.each(playlists, (playlist, next) => {
+						this.cache.hset('playlists', playlist._id, this.cache.schemas.playlist(playlist), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -61,21 +68,22 @@ module.exports = {
 	 * @param {String} playlistId - the id of the playlist we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPlaylist: (playlistId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async getPlaylist(playlistId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				cache.hgetall('playlists', next);
+				this.cache.hgetall('playlists', next);
 			},
 
 			(playlists, next) => {
 				if (!playlists) return next();
 				let playlistIds = Object.keys(playlists);
 				async.each(playlistIds, (playlistId, next) => {
-					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+					this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
 						if (err) next(err);
 						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
+							this.cache.hdel('playlists', playlistId, next);
 						}
 						else next();
 					});
@@ -83,17 +91,17 @@ module.exports = {
 			},
 
 			(next) => {
-				cache.hget('playlists', playlistId, next);
+				this.cache.hget('playlists', playlistId, next);
 			},
 
 			(playlist, next) => {
 				if (playlist) return next(true, playlist);
-				db.models.playlist.findOne({ _id: playlistId }, next);
+				this.db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 
 			(playlist, next) => {
 				if (playlist) {
-					cache.hset('playlists', playlistId, playlist, next);
+					this.cache.hset('playlists', playlistId, playlist, next);
 				} else next('Playlist not found');
 			},
 
@@ -101,7 +109,7 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			else cb(null, playlist);
 		});
-	},
+	}
 
 	/**
 	 * Gets a playlist from id from Mongo and updates the cache with it
@@ -109,27 +117,27 @@ module.exports = {
 	 * @param {String} playlistId - the id of the playlist we are trying to update
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	updatePlaylist: (playlistId, cb) => {
-		if (lockdown) return cb('Lockdown');
-		async.waterfall([
+	async updatePlaylist(playlistId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
+		async.waterfall([
 			(next) => {
-				db.models.playlist.findOne({ _id: playlistId }, next);
+				this.db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 
 			(playlist, next) => {
 				if (!playlist) {
-					cache.hdel('playlists', playlistId);
+					this.cache.hdel('playlists', playlistId);
 					return next('Playlist not found');
 				}
-				cache.hset('playlists', playlistId, playlist, next);
+				this.cache.hset('playlists', playlistId, playlist, next);
 			}
 
 		], (err, playlist) => {
 			if (err && err !== true) return cb(err);
 			cb(null, playlist);
 		});
-	},
+	}
 
 	/**
 	 * Deletes playlist from id from Mongo and cache
@@ -137,16 +145,17 @@ module.exports = {
 	 * @param {String} playlistId - the id of the playlist we are trying to delete
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	deletePlaylist: (playlistId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async deletePlaylist(playlistId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.playlist.deleteOne({ _id: playlistId }, next);
+				this.db.models.playlist.deleteOne({ _id: playlistId }, next);
 			},
 
 			(res, next) => {
-				cache.hdel('playlists', playlistId, next);
+				this.cache.hdel('playlists', playlistId, next);
 			}
 
 		], (err) => {
@@ -154,9 +163,5 @@ module.exports = {
 
 			cb(null);
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
+}

+ 87 - 79
backend/logic/punishments.js

@@ -1,73 +1,80 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
+const coreClass = require("../core");
+
 const async = require('async');
 const mongoose = require('mongoose');
 
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
-
-	/**
-	 * Initializes the punishments module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('punishments', next);
-			},
-
-			(punishments, next) => {
-				if (!punishments) return next();
-				let punishmentIds = Object.keys(punishments);
-				async.each(punishmentIds, (punishmentId, next) => {
-					db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
-						if (err) next(err);
-						else if (!punishment) cache.hdel('punishments', punishmentId, next);
-						else next();
-					});
-				}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(next) => {
-				db.models.punishment.find({}, next);
-			},
+		this.dependsOn = ["cache", "db", "utils"];
+	}
 
-			(punishments, next) => {
-				async.each(punishments, (punishment, next) => {
-					if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
-					cache.hset('punishments', punishment._id, cache.schemas.punishment(punishment, punishment._id), next);
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules['cache'];
+			this.db = this.moduleManager.modules['db'];
+			this.io = this.moduleManager.modules['io'];
+			this.utils = this.moduleManager.modules['utils'];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('punishments', next);
+				},
+	
+				(punishments, next) => {
+					this.setStage(3);
+					if (!punishments) return next();
+					let punishmentIds = Object.keys(punishments);
+					async.each(punishmentIds, (punishmentId, next) => {
+						this.db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
+							if (err) next(err);
+							else if (!punishment) this.cache.hdel('punishments', punishmentId, next);
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.punishment.find({}, next);
+				},
+	
+				(punishments, next) => {
+					this.setStage(5);
+					async.each(punishments, (punishment, next) => {
+						if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
+						this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets all punishments in the cache that are active, and removes those that have expired
 	 *
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPunishments: function(cb) {
-		if (lockdown) return cb('Lockdown');
+	async getPunishments(cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		let punishmentsToRemove = [];
 		async.waterfall([
 			(next) => {
-				cache.hgetall('punishments', next);
+				this.cache.hgetall('punishments', next);
 			},
 
 			(punishmentsObj, next) => {
@@ -88,7 +95,7 @@ module.exports = {
 				async.each(
 					punishmentsToRemove,
 					(punishment, next2) => {
-						cache.hdel('punishments', punishment.punishmentId, () => {
+						this.cache.hdel('punishments', punishment.punishmentId, () => {
 							next2();
 						});
 					},
@@ -102,7 +109,7 @@ module.exports = {
 
 			cb(null, punishments);
 		});
-	},
+	}
 
 	/**
 	 * Gets a punishment by id
@@ -110,23 +117,24 @@ module.exports = {
 	 * @param {String} id - the id of the punishment we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPunishment: function(id, cb) {
-		if (lockdown) return cb('Lockdown');
+	async getPunishment(id, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				cache.hget('punishments', id, next);
+				this.cache.hget('punishments', id, next);
 			},
 
 			(punishment, next) => {
 				if (punishment) return next(true, punishment);
-				db.models.punishment.findOne({_id: id}, next);
+				this.db.models.punishment.findOne({_id: id}, next);
 			},
 
 			(punishment, next) => {
 				if (punishment) {
-					cache.hset('punishments', id, punishment, next);
+					this.cache.hset('punishments', id, punishment, next);
 				} else next('Punishment not found.');
 			},
 
@@ -135,7 +143,7 @@ module.exports = {
 
 			cb(null, punishment);
 		});
-	},
+	}
 
 	/**
 	 * Gets all punishments from a userId
@@ -143,11 +151,12 @@ module.exports = {
 	 * @param {String} userId - the userId of the punishment(s) we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPunishmentsFromUserId: function(userId, cb) {
-		if (lockdown) return cb('Lockdown');
+	async getPunishmentsFromUserId(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				module.exports.getPunishments(next);
+				this.getPunishments(next);
 			},
 			(punishments, next) => {
 				punishments = punishments.filter((punishment) => {
@@ -160,13 +169,14 @@ module.exports = {
 
 			cb(null, punishments);
 		});
-	},
+	}
+
+	async addPunishment(type, value, reason, expiresAt, punishedBy, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	addPunishment: function(type, value, reason, expiresAt, punishedBy, cb) {
-		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 			(next) => {
-				const punishment = new db.models.punishment({
+				const punishment = new this.db.models.punishment({
 					type,
 					value,
 					reason,
@@ -182,7 +192,7 @@ module.exports = {
 			},
 
 			(punishment, next) => {
-				cache.hset('punishments', punishment._id, cache.schemas.punishment(punishment, punishment._id), (err) => {
+				this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), (err) => {
 					next(err, punishment);
 				});
 			},
@@ -194,13 +204,14 @@ module.exports = {
 		], (err, punishment) => {
 			cb(err, punishment);
 		});
-	},
+	}
+
+	async removePunishmentFromCache(punishmentId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	removePunishmentFromCache: function(punishmentId, cb) {
-		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 			(next) => {
-				const punishment = new db.models.punishment({
+				const punishment = new this.db.models.punishment({
 					type,
 					value,
 					reason,
@@ -217,7 +228,7 @@ module.exports = {
 			},
 
 			(punishment, next) => {
-				cache.hset('punishments', punishment._id, punishment, next);
+				this.cache.hset('punishments', punishment._id, punishment, next);
 			},
 
 			(punishment, next) => {
@@ -227,9 +238,6 @@ module.exports = {
 		], (err) => {
 			cb(err);
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
+}
+

+ 80 - 72
backend/logic/songs.js

@@ -1,60 +1,69 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
+const coreClass = require("../core");
+
 const async = require('async');
 const mongoose = require('mongoose');
 
-let initialized = false;
-let lockdown = false;
 
-module.exports = {
 
-	/**
-	 * Initializes the songs module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('songs', next);
-			},
 
-			(songs, next) => {
-				if (!songs) return next();
-				let songIds = Object.keys(songs);
-				async.each(songIds, (songId, next) => {
-					db.models.song.findOne({songId}, (err, song) => {
-						if (err) next(err);
-						else if (!song) cache.hdel('songs', songId, next);
-						else next();
-					});
-				}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(next) => {
-				db.models.song.find({}, next);
-			},
+		this.dependsOn = ["utils", "cache", "db"];
+	}
 
-			(songs, next) => {
-				async.each(songs, (song, next) => {
-					cache.hset('songs', song.songId, cache.schemas.song(song), next);
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db = this.moduleManager.modules["db"];
+			this.io = this.moduleManager.modules["io"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('songs', next);
+				},
+	
+				(songs, next) => {
+					this.setStage(3);
+					if (!songs) return next();
+					let songIds = Object.keys(songs);
+					async.each(songIds, (songId, next) => {
+						this.db.models.song.findOne({songId}, (err, song) => {
+							if (err) next(err);
+							else if (!song) this.cache.hdel('songs', songId, next);
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.song.find({}, next);
+				},
+	
+				(songs, next) => {
+					this.setStage(5);
+					async.each(songs, (song, next) => {
+						this.cache.hset('songs', song.songId, this.cache.schemas.song(song), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -62,23 +71,23 @@ module.exports = {
 	 * @param {String} id - the id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getSong: function(id, cb) {
-		if (lockdown) return cb('Lockdown');
-		async.waterfall([
+	async getSong(id, cb) {
+		try { await this._validateHook(); } catch { return; }
 
+		async.waterfall([
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				cache.hget('songs', id, next);
+				this.cache.hget('songs', id, next);
 			},
 
 			(song, next) => {
 				if (song) return next(true, song);
-				db.models.song.findOne({_id: id}, next);
+				this.db.models.song.findOne({_id: id}, next);
 			},
 
 			(song, next) => {
 				if (song) {
-					cache.hset('songs', id, song, next);
+					this.cache.hset('songs', id, song, next);
 				} else next('Song not found.');
 			},
 
@@ -87,7 +96,7 @@ module.exports = {
 
 			cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -95,17 +104,18 @@ module.exports = {
 	 * @param {String} songId - the mongo id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getSongFromId: function(songId, cb) {
-		if (lockdown) return cb('Lockdown');
+	async getSongFromId(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				db.models.song.findOne({ songId }, next);
+				this.db.models.song.findOne({ songId }, next);
 			}
 		], (err, song) => {
 			if (err && err !== true) return cb(err);
 			else return cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Gets a song from id from Mongo and updates the cache with it
@@ -113,21 +123,22 @@ module.exports = {
 	 * @param {String} songId - the id of the song we are trying to update
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	updateSong: (songId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async updateSong(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.song.findOne({_id: songId}, next);
+				this.db.models.song.findOne({_id: songId}, next);
 			},
 
 			(song, next) => {
 				if (!song) {
-					cache.hdel('songs', songId);
+					this.cache.hdel('songs', songId);
 					return next('Song not found.');
 				}
 
-				cache.hset('songs', songId, song, next);
+				this.cache.hset('songs', songId, song, next);
 			}
 
 		], (err, song) => {
@@ -135,7 +146,7 @@ module.exports = {
 
 			cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Deletes song from id from Mongo and cache
@@ -143,16 +154,17 @@ module.exports = {
 	 * @param {String} songId - the id of the song we are trying to delete
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	deleteSong: (songId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async deleteSong(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.song.deleteOne({ songId }, next);
+				this.db.models.song.deleteOne({ songId }, next);
 			},
 
 			(next) => {
-				cache.hdel('songs', songId, next);
+				this.cache.hdel('songs', songId, next);
 			}
 
 		], (err) => {
@@ -160,9 +172,5 @@ module.exports = {
 
 			cb(null);
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
+}

+ 61 - 50
backend/logic/spotify.js

@@ -1,19 +1,7 @@
-const config = require('config'),
-	  async  = require('async'),
-	  logger = require('./logger'),
-	  cache  = require('./cache');
-
-const client = config.get("apis.spotify.client");
-const secret = config.get("apis.spotify.secret");
+const coreClass = require("../core");
 
-const OAuth2 = require('oauth').OAuth2;
-const SpotifyOauth = new OAuth2(
-	client,
-	secret, 
-	'https://accounts.spotify.com/', 
-	null,
-	'api/token',
-	null);
+const config = require('config'),
+	async  = require('async');
 
 let apiResults = {
 	access_token: "",
@@ -23,45 +11,73 @@ let apiResults = {
 	scope: "",
 };
 
-let initialized = false;
-let lockdown = false;
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-let lib = {
-	init: (cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hget("api", "spotify", next, true);
-			},
+		this.dependsOn = ["cache"];
+	}
 
-			(data, next) => {
-				if (data) apiResults = data;
-				next();
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			const client = config.get("apis.spotify.client");
+			const secret = config.get("apis.spotify.secret");
+
+			const OAuth2 = require('oauth').OAuth2;
+			this.SpotifyOauth = new OAuth2(
+				client,
+				secret, 
+				'https://accounts.spotify.com/', 
+				null,
+				'api/token',
+				null);
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hget("api", "spotify", next, true);
+				},
+	
+				(data, next) => {
+					this.setStage(3);
+					if (data) apiResults = data;
+					next();
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
-	getToken: () => {
+	}
+
+	async getToken() {
+		try { await this._validateHook(); } catch { return; }
+
 		return new Promise((resolve, reject) => {
 			if (Date.now() > apiResults.expires_at) {
-				lib.requestToken(() => {
+				this.requestToken(() => {
 					resolve(apiResults.access_token);
 				});
 			} else resolve(apiResults.access_token);
 		});
-	},
-	requestToken: (cb) => {
+	}
+
+	async requestToken(cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				logger.info("SPOTIFY_REQUEST_TOKEN", "Requesting new Spotify token.");
-				SpotifyOauth.getOAuthAccessToken(
+				this.logger.info("SPOTIFY_REQUEST_TOKEN", "Requesting new Spotify token.");
+				this.SpotifyOauth.getOAuthAccessToken(
 					'',
 					{ 'grant_type': 'client_credentials' },
 					next
@@ -70,15 +86,10 @@ let lib = {
 			(access_token, refresh_token, results, next) => {
 				apiResults = results;
 				apiResults.expires_at = Date.now() + (results.expires_in * 1000);
-				cache.hset("api", "spotify", apiResults, next, true);
+				this.cache.hset("api", "spotify", apiResults, next, true);
 			}
 		], () => {
 			cb();
 		});
-	},
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
-
-module.exports = lib;
+}

+ 194 - 180
backend/logic/stations.js

@@ -1,118 +1,142 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
-const logger = require('./logger');
-const songs = require('./songs');
-const notifications = require('./notifications');
+const coreClass = require("../core");
+
 const async = require('async');
 
 let subscription = null;
 
-let initialized = false;
-let lockdown = false;
-
-//TEMP
-cache.sub('station.pause', (stationId) => {
-	if (lockdown) return;
-	notifications.remove(`stations.nextSong?id=${stationId}`);
-});
-
-cache.sub('station.resume', (stationId) => {
-	if (lockdown) return;
-	module.exports.initializeStation(stationId)
-});
-
-cache.sub('station.queueUpdate', (stationId) => {
-	if (lockdown) return;
-	module.exports.getStation(stationId, (err, station) => {
-		if (!station.currentSong && station.queue.length > 0) {
-			module.exports.initializeStation(stationId);
-		}
-	});
-});
-
-cache.sub('station.newOfficialPlaylist', (stationId) => {
-	if (lockdown) return;
-	cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
-		if (!err && playlistObj) {
-			utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
-		}
-	})
-});
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-module.exports = {
+		this.dependsOn = ["cache", "db", "utils"];
+	}
 
-	init: function(cb) {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('stations', next);
-			},
+	initialize() {
+		return new Promise(async (resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db = this.moduleManager.modules["db"];
+			this.utils = this.moduleManager.modules["utils"];
+			this.songs = this.moduleManager.modules["songs"];
+			this.notifications = this.moduleManager.modules["notifications"];
+
+			this.defaultSong = {
+				songId: '60ItHLz5WEA',
+				title: 'Faded - Alan Walker',
+				duration: 212,
+				skipDuration: 0,
+				likes: -1,
+				dislikes: -1
+			};
+
+			//TEMP
+			this.cache.sub('station.pause', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
+
+				this.notifications.remove(`stations.nextSong?id=${stationId}`);
+			});
 
-			(stations, next) => {
-				if (!stations) return next();
-				let stationIds = Object.keys(stations);
-				async.each(stationIds, (stationId, next) => {
-					db.models.station.findOne({_id: stationId}, (err, station) => {
-						if (err) next(err);
-						else if (!station) {
-							cache.hdel('stations', stationId, next);
-						} else next();
-					});
-				}, next);
-			},
+			this.cache.sub('station.resume', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
 
-			(next) => {
-				db.models.station.find({}, next);
-			},
+				this.initializeStation(stationId)
+			});
 
-			(stations, next) => {
-				async.each(stations, (station, next) => {
-					async.waterfall([
-						(next) => {
-							cache.hset('stations', station._id, cache.schemas.station(station), next);
-						},
+			this.cache.sub('station.queueUpdate', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
 
-						(station, next) => {
-							this.initializeStation(station._id, next);
-						}
-					], (err) => {
-						next(err);
-					});
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+				this.getStation(stationId, (err, station) => {
+					if (!station.currentSong && station.queue.length > 0) {
+						this.initializeStation(stationId);
+					}
+				});
+			});
+
+			this.cache.sub('station.newOfficialPlaylist', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
+
+				this.cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
+					if (!err && playlistObj) {
+						this.utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
+					}
+				})
+			});
+
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('stations', next);
+				},
+	
+				(stations, next) => {
+					this.setStage(3);
+					if (!stations) return next();
+					let stationIds = Object.keys(stations);
+					async.each(stationIds, (stationId, next) => {
+						this.db.models.station.findOne({_id: stationId}, (err, station) => {
+							if (err) next(err);
+							else if (!station) {
+								this.cache.hdel('stations', stationId, next);
+							} else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.station.find({}, next);
+				},
+	
+				(stations, next) => {
+					this.setStage(4);
+					async.each(stations, (station, next) => {
+						async.waterfall([
+							(next) => {
+								this.cache.hset('stations', station._id, this.cache.schemas.station(station), next);
+							},
+	
+							(station, next) => {
+								this.initializeStation(station._id, next);
+							}
+						], (err) => {
+							next(err);
+						});
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
+
+	async initializeStation(stationId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	initializeStation: function(stationId, cb) {
-		if (lockdown) return;
 		if (typeof cb !== 'function') cb = ()=>{};
-		let _this = this;
+
 		async.waterfall([
 			(next) => {
-				_this.getStation(stationId, next);
+				this.getStation(stationId, next);
 			},
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				notifications.unschedule(`stations.nextSong?id=${station._id}`);
-				subscription = notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true, station);
+				this.notifications.unschedule(`stations.nextSong?id=${station._id}`);
+				subscription = this.notifications.subscribe(`stations.nextSong?id=${station._id}`, this.skipStation(station._id), true, station);
 				if (station.paused) return next(true, station);
 				next(null, station);
 			},
 			(station, next) => {
 				if (!station.currentSong) {
-					return _this.skipStation(station._id)((err, station) => {
+					return this.skipStation(station._id)((err, station) => {
 						if (err) return next(err);
 						return next(true, station);
 					});
@@ -124,7 +148,7 @@ module.exports = {
 						next(err, station);
 					});
 				} else {
-					notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
+					this.notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
 					next(null, station);
 				}
 			}
@@ -132,17 +156,17 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
+
+	async calculateSongForStation(station, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	calculateSongForStation: function(station, cb) {
-		if (lockdown) return;
-		let _this = this;
 		let songList = [];
 		async.waterfall([
 			(next) => {
 				let genresDone = [];
 				station.genres.forEach((genre) => {
-					db.models.song.find({genres: genre}, (err, songs) => {
+					this.db.models.song.find({genres: genre}, (err, songs) => {
 						if (!err) {
 							songs.forEach((song) => {
 								if (songList.indexOf(song._id) === -1) {
@@ -171,16 +195,20 @@ module.exports = {
 					if (songList.indexOf(songId) !== -1) playlist.push(songId);
 				});
 
-				playlist = utils.shuffle(playlist);
+				this.utils.shuffle(playlist).then((playlist) => {
+					next(null, playlist);
+				});
+			},
 
-				_this.calculateOfficialPlaylistList(station._id, playlist, () => {
+			(playlist, next) => {
+				this.calculateOfficialPlaylistList(station._id, playlist, () => {
 					next(null, playlist);
 				});
 			},
 
 			(playlist, next) => {
-				db.models.station.updateOne({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
-					_this.updateStation(station._id, () => {
+				this.db.models.station.updateOne({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
+					this.updateStation(station._id, () => {
 						next(err, playlist);
 					});
 				});
@@ -189,29 +217,29 @@ module.exports = {
 		], (err, newPlaylist) => {
 			cb(err, newPlaylist);
 		});
-	},
+	}
 
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	getStation: function(stationId, cb) {
-		if (lockdown) return;
-		let _this = this;
+	async getStation(stationId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				cache.hget('stations', stationId, next);
+				this.cache.hget('stations', stationId, next);
 			},
 
 			(station, next) => {
 				if (station) return next(true, station);
-				db.models.station.findOne({ _id: stationId }, next);
+				this.db.models.station.findOne({ _id: stationId }, next);
 			},
 
 			(station, next) => {
 				if (station) {
 					if (station.type === 'official') {
-						_this.calculateOfficialPlaylistList(station._id, station.playlist, () => {});
+						this.calculateOfficialPlaylistList(station._id, station.playlist, () => {});
 					}
-					station = cache.schemas.station(station);
-					cache.hset('stations', stationId, station);
+					station = this.cache.schemas.station(station);
+					this.cache.hset('stations', stationId, station);
 					next(true, station);
 				} else next('Station not found');
 			},
@@ -220,25 +248,25 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
 
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	getStationByName: function(stationName, cb) {
-		if (lockdown) return;
-		let _this = this;
+	async getStationByName(stationName, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.station.findOne({ name: stationName }, next);
+				this.db.models.station.findOne({ name: stationName }, next);
 			},
 
 			(station, next) => {
 				if (station) {
 					if (station.type === 'official') {
-						_this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
+						this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
 					}
-					station = cache.schemas.station(station);
-					cache.hset('stations', station._id, station);
+					station = this.cache.schemas.station(station);
+					this.cache.hset('stations', station._id, station);
 					next(true, station);
 				} else next('Station not found');
 			},
@@ -247,36 +275,37 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
+
+	async updateStation(stationId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	updateStation: function(stationId, cb) {
-		if (lockdown) return;
-		let _this = this;
 		async.waterfall([
 
 			(next) => {
-				db.models.station.findOne({ _id: stationId }, next);
+				this.db.models.station.findOne({ _id: stationId }, next);
 			},
 
 			(station, next) => {
 				if (!station) {
-					cache.hdel('stations', stationId);
+					this.cache.hdel('stations', stationId);
 					return next('Station not found');
 				}
-				cache.hset('stations', stationId, station, next);
+				this.cache.hset('stations', stationId, station, next);
 			}
 
 		], (err, station) => {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
+
+	async calculateOfficialPlaylistList(stationId, songList, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	calculateOfficialPlaylistList: (stationId, songList, cb) => {
-		if (lockdown) return;
 		let lessInfoPlaylist = [];
 		async.each(songList, (song, next) => {
-			songs.getSong(song, (err, song) => {
+			this.songs.getSong(song, (err, song) => {
 				if (!err && song) {
 					let newSong = {
 						songId: song.songId,
@@ -289,36 +318,35 @@ module.exports = {
 				next();
 			});
 		}, () => {
-			cache.hset("officialPlaylists", stationId, cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
-				cache.pub("station.newOfficialPlaylist", stationId);
+			this.cache.hset("officialPlaylists", stationId, this.cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
+				this.cache.pub("station.newOfficialPlaylist", stationId);
 				cb();
 			});
 		});
-	},
-
-	skipStation: function(stationId) {
-		if (lockdown) return;
-		logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
-		let _this = this;
-		return (cb) => {
-			if (lockdown) return;
+	}
+
+	skipStation(stationId) {
+		this.logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
+		return async (cb) => {
+			try { await this._validateHook(); } catch { return; }
+
 			if (typeof cb !== 'function') cb = ()=>{};
 
 			async.waterfall([
 				(next) => {
-					_this.getStation(stationId, next);
+					this.getStation(stationId, next);
 				},
 				(station, next) => {
 					if (!station) return next('Station not found.');
 					if (station.type === 'community' && station.partyMode && station.queue.length === 0) return next(null, null, -11, station); // Community station with party mode enabled and no songs in the queue
 					if (station.type === 'community' && station.partyMode && station.queue.length > 0) { // Community station with party mode enabled and songs in the queue
-						return db.models.station.updateOne({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
+						return this.db.models.station.updateOne({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
 							if (err) return next(err);
 							next(null, station.queue[0], -12, station);
 						});
 					}
 					if (station.type === 'community' && !station.partyMode) {
-						return db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
+						return this.db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
 							if (err) return next(err);
 							if (!playlist) return next(null, null, -13, station);
 							playlist = playlist.songs;
@@ -341,18 +369,18 @@ module.exports = {
 										return next(null, currentSong, currentSongIndex, station);
 									}
 								};
-								if (playlist[currentSongIndex]._id) songs.getSong(playlist[currentSongIndex]._id, callback);
-								else songs.getSongFromId(playlist[currentSongIndex].songId, callback);
+								if (playlist[currentSongIndex]._id) this.songs.getSong(playlist[currentSongIndex]._id, callback);
+								else this.songs.getSongFromId(playlist[currentSongIndex].songId, callback);
 							} else return next(null, null, -14, station);
 						});
 					}
 					if (station.type === 'official' && station.playlist.length === 0) {
-						return _this.calculateSongForStation(station, (err, playlist) => {
+						return this.calculateSongForStation(station, (err, playlist) => {
 							if (err) return next(err);
-							if (playlist.length === 0) return next(null, _this.defaultSong, 0, station);
+							if (playlist.length === 0) return next(null, this.defaultSong, 0, station);
 							else {
-								songs.getSong(playlist[0], (err, song) => {
-									if (err || !song) return next(null, _this.defaultSong, 0, station);
+								this.songs.getSong(playlist[0], (err, song) => {
+									if (err || !song) return next(null, this.defaultSong, 0, station);
 									return next(null, song, 0, station);
 								});
 							}
@@ -361,7 +389,7 @@ module.exports = {
 					if (station.type === 'official' && station.playlist.length > 0) {
 						async.doUntil((next) => {
 							if (station.currentSongIndex < station.playlist.length - 1) {
-								songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
+								this.songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
 									if (!err) return next(null, song, station.currentSongIndex + 1);
 									else {
 										station.currentSongIndex++;
@@ -369,10 +397,10 @@ module.exports = {
 									}
 								});
 							} else {
-								_this.calculateSongForStation(station, (err, newPlaylist) => {
-									if (err) return next(null, _this.defaultSong, 0);
-									songs.getSong(newPlaylist[0], (err, song) => {
-										if (err || !song) return next(null, _this.defaultSong, 0);
+								this.calculateSongForStation(station, (err, newPlaylist) => {
+									if (err) return next(null, this.defaultSong, 0);
+									this.songs.getSong(newPlaylist[0], (err, song) => {
+										if (err || !song) return next(null, this.defaultSong, 0);
 										station.playlist = newPlaylist;
 										next(null, song, 0);
 									});
@@ -418,37 +446,37 @@ module.exports = {
 				},
 
 				($set, station, next) => {
-					db.models.station.updateOne({_id: station._id}, {$set}, (err) => {
-						_this.updateStation(station._id, (err, station) => {
+					this.db.models.station.updateOne({_id: station._id}, {$set}, (err) => {
+						this.updateStation(station._id, (err, station) => {
 							if (station.type === 'community' && station.partyMode === true)
-								cache.pub('station.queueUpdate', stationId);
+								this.cache.pub('station.queueUpdate', stationId);
 							next(null, station);
 						});
 					});
 				},
-			], (err, station) => {
+			], async (err, station) => {
 				if (!err) {
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
 						station.currentSong.skipVotes = 0;
 					}
 					//TODO Pub/Sub this
-					utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
+					this.utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
 						currentSong: station.currentSong,
 						startedAt: station.startedAt,
 						paused: station.paused,
 						timePaused: 0
 					});
 
-					if (station.privacy === 'public') utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
+					if (station.privacy === 'public') this.utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
 					else {
-						let sockets = utils.getRoomSockets('home');
+						let sockets = await this.utils.getRoomSockets('home');
 						for (let socketId in sockets) {
 							let socket = sockets[socketId];
 							let session = sockets[socketId].session;
 							if (session.sessionId) {
-								cache.hget('sessions', session.sessionId, (err, session) => {
+								this.cache.hget('sessions', session.sessionId, (err, session) => {
 									if (!err && session) {
-										db.models.user.findOne({_id: session.userId}, (err, user) => {
+										this.db.models.user.findOne({_id: session.userId}, (err, user) => {
 											if (!err && user) {
 												if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
 												else if (station.type === "community" && station.owner === session.userId) socket.emit("event:station.nextSong", station._id, station.currentSong);
@@ -460,34 +488,20 @@ module.exports = {
 						}
 					}
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
-						utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
+						this.utils.socketsJoinSongRoom(await this.utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
 						if (!station.paused) {
-							notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
+							this.notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
 						}
 					} else {
-						utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`));
+						this.utils.socketsLeaveSongRooms(await this.utils.getRoomSockets(`station.${station._id}`));
 					}
 					cb(null, station);
 				} else {
-					err = utils.getError(err);
+					err = await this.utils.getError(err);
 					logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
 					cb(err);
 				}
 			});
 		}
-	},
-
-	defaultSong: {
-		songId: '60ItHLz5WEA',
-		title: 'Faded - Alan Walker',
-		duration: 212,
-		skipDuration: 0,
-		likes: -1,
-		dislikes: -1
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-
-};
+}

+ 127 - 120
backend/logic/tasks.js

@@ -1,113 +1,32 @@
 'use strict';
 
-const cache = require("./cache");
-const logger = require("./logger");
-const Stations = require("./stations");
-const notifications = require("./notifications");
+const coreClass = require("../core");
+
 const async = require("async");
-let utils;
+
 let tasks = {};
 
-let testTask = (callback) => {
-	//Stuff
-	console.log("Starting task");
-	setTimeout(() => {
-		console.log("Callback");
-		callback();
-	}, 10000);
-};
-
-let checkStationSkipTask = (callback) => {
-	logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
-	async.waterfall([
-		(next) => {
-			cache.hgetall('stations', next);
-		},
-		(stations, next) => {
-			async.each(stations, (station, next2) => {
-				if (station.paused || !station.currentSong || !station.currentSong.title) return next2();
-				const timeElapsed = Date.now() - station.startedAt - station.timePaused;
-				if (timeElapsed <= station.currentSong.duration) return next2();
-				else {
-					logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
-					Stations.initializeStation(station._id);
-					next2();
-				}
-			}, () => {
-				next();
-			});
-		}
-	], () => {
-		callback();
-	});
-};
-
-let sessionClearingTask = (callback) => {
-	logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
-	async.waterfall([
-		(next) => {
-			cache.hgetall('sessions', next);
-		},
-		(sessions, next) => {
-			if (!sessions) return next();
-			let keys = Object.keys(sessions);
-			async.each(keys, (sessionId, next2) => {
-				let session = sessions[sessionId];
-				if (session && session.refreshDate && (Date.now() - session.refreshDate) < (60 * 60 * 24 * 30 * 1000)) return next2();
-				if (!session) {
-					logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
-					cache.hdel('sessions', sessionId, () => {
-						next2();
-					});
-				} else if (!session.refreshDate) {
-					session.refreshDate = Date.now();
-					cache.hset('sessions', sessionId, session, () => {
-						next2();
-					});
-				} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
-					utils.socketsFromSessionId(session.sessionId, (sockets) => {
-						if (sockets.length > 0) {
-							session.refreshDate = Date.now();
-							cache.hset('sessions', sessionId, session, () => {
-								next2()
-							});
-						} else {
-							logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
-							cache.hdel('sessions', session.sessionId, () => {
-								next2();
-							});
-						}
-					});
-				} else {
-					logger.error("TASK_SESSION_CLEAR", "This should never log.");
-					next2();
-				}
-			}, () => {
-				next();
-			});
-		}
-	], () => {
-		callback();
-	});
-};
-
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
-	init: function(cb) {
-		utils = require('./utils');
-		this.createTask("testTask", testTask, 5000, true);
-		this.createTask("stationSkipTask", checkStationSkipTask, 1000 * 60 * 30);
-		this.createTask("sessionClearTask", sessionClearingTask, 1000 * 60 * 60 * 6);
-
-		initialized = true;
-
-		if (lockdown) return this._lockdown();
-		cb();
-	},
-	createTask: function(name, fn, timeout, paused = false) {
-		if (lockdown) return;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+			
+			this.cache = this.moduleManager.modules["cache"];
+			this.stations = this.moduleManager.modules["stations"];
+			this.notifications = this.moduleManager.modules["notifications"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			//this.createTask("testTask", testTask, 5000, true);
+			this.createTask("stationSkipTask", this.checkStationSkipTask, 1000 * 60 * 30);
+			this.createTask("sessionClearTask", this.sessionClearingTask, 1000 * 60 * 60 * 6);
+
+			resolve();
+		});
+	}
+
+	async createTask(name, fn, timeout, paused = false) {
+		try { await this._validateHook(); } catch { return; }
+
 		tasks[name] = {
 			name,
 			fn,
@@ -116,15 +35,23 @@ module.exports = {
 			timer: null
 		};
 		if (!paused) this.handleTask(tasks[name]);
-	},
-	pauseTask: (name) => {
+	}
+
+	async pauseTask(name) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (tasks[name].timer) tasks[name].timer.pause();
-	},
-	resumeTask: (name) => {
+	}
+
+	async resumeTask(name) {
+		try { await this._validateHook(); } catch { return; }
+
 		tasks[name].timer.resume();
-	},
-	handleTask: function(task) {
-		if (lockdown) return;
+	}
+
+	async handleTask(task) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (task.timer) task.timer.pause();
 
 		task.fn(() => {
@@ -133,12 +60,92 @@ module.exports = {
 				this.handleTask(task);
 			}, task.timeout, false);
 		});
-	},
-	_lockdown: function() {
-		for (let key in tasks) {
-			this.pauseTask(key);
-		}
-		tasks = {};
-		lockdown = true;
 	}
-};
+
+	/*testTask(callback) {
+		//Stuff
+		console.log("Starting task");
+		setTimeout(() => {
+			console.log("Callback");
+			callback();
+		}, 10000);
+	}*/
+
+	async checkStationSkipTask(callback) {
+		try { await this._validateHook(); } catch { return; }
+
+		this.logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
+		async.waterfall([
+			(next) => {
+				this.cache.hgetall('stations', next);
+			},
+			(stations, next) => {
+				async.each(stations, (station, next2) => {
+					if (station.paused || !station.currentSong || !station.currentSong.title) return next2();
+					const timeElapsed = Date.now() - station.startedAt - station.timePaused;
+					if (timeElapsed <= station.currentSong.duration) return next2();
+					else {
+						this.logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
+						this.stations.initializeStation(station._id);
+						next2();
+					}
+				}, () => {
+					next();
+				});
+			}
+		], () => {
+			callback();
+		});
+	}
+
+	async sessionClearingTask(callback) {
+		try { await this._validateHook(); } catch { return; }
+	
+		this.logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
+		async.waterfall([
+			(next) => {
+				this.cache.hgetall('sessions', next);
+			},
+			(sessions, next) => {
+				if (!sessions) return next();
+				let keys = Object.keys(sessions);
+				async.each(keys, (sessionId, next2) => {
+					let session = sessions[sessionId];
+					if (session && session.refreshDate && (Date.now() - session.refreshDate) < (60 * 60 * 24 * 30 * 1000)) return next2();
+					if (!session) {
+						this.logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
+						this.cache.hdel('sessions', sessionId, () => {
+							next2();
+						});
+					} else if (!session.refreshDate) {
+						session.refreshDate = Date.now();
+						this.cache.hset('sessions', sessionId, session, () => {
+							next2();
+						});
+					} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
+						this.utils.socketsFromSessionId(session.sessionId, (sockets) => {
+							if (sockets.length > 0) {
+								session.refreshDate = Date.now();
+								this.cache.hset('sessions', sessionId, session, () => {
+									next2()
+								});
+							} else {
+								this.logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
+								this.cache.hdel('sessions', session.sessionId, () => {
+									next2();
+								});
+							}
+						});
+					} else {
+						this.logger.error("TASK_SESSION_CLEAR", "This should never log.");
+						next2();
+					}
+				}, () => {
+					next();
+				});
+			}
+		], () => {
+			callback();
+		});
+	}
+}

+ 208 - 127
backend/logic/utils.js

@@ -1,13 +1,10 @@
 'use strict';
 
-const moment  = require('moment'),
-	  io      = require('./io'),
-	  db      = require('./db'),
-	  spotify = require('./spotify'),
-	  config  = require('config'),
+const coreClass = require("../core");
+
+const config  = require('config'),
 	  async	  = require('async'),
-	  request = require('request'),
-	  cache   = require('./cache');
+	  request = require('request');
 
 class Timer {
 	constructor(callback, delay, paused) {
@@ -56,96 +53,135 @@ class Timer {
 			return Date.now() - this.timePaused;
 		}
 	}
-}
-
-function convertTime (duration) {
-	let a = duration.match(/\d+/g);
+} 
 
-	if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
-		a = [0, a[0], 0];
-	}
+let youtubeRequestCallbacks = [];
+let youtubeRequestsPending = 0;
+let youtubeRequestsActive = false;
 
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
-		a = [a[0], 0, a[1]];
-	}
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
-		a = [a[0], 0, 0];
-	}
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+			
+			this.io = this.moduleManager.modules["io"];
+			this.db = this.moduleManager.modules["db"];
+			this.spotify = this.moduleManager.modules["spotify"];
+			this.cache = this.moduleManager.modules["cache"];
 
-	duration = 0;
+			this.Timer = Timer;
 
-	if (a.length == 3) {
-		duration = duration + parseInt(a[0]) * 3600;
-		duration = duration + parseInt(a[1]) * 60;
-		duration = duration + parseInt(a[2]);
+			resolve();
+		});
 	}
 
-	if (a.length == 2) {
-		duration = duration + parseInt(a[0]) * 60;
-		duration = duration + parseInt(a[1]);
+	async parseCookies(cookieString) {
+		try { await this._validateHook(); } catch { return; }
+		let cookies = {};
+		if (cookieString) cookieString.split("; ").map((cookie) => {
+			(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
+		});
+		return cookies;
 	}
 
-	if (a.length == 1) {
-		duration = duration + parseInt(a[0]);
+	async cookiesToString(cookies) {
+		try { await this._validateHook(); } catch { return; }
+		let newCookie = [];
+		for (let prop in cookie) {
+			newCookie.push(prop + "=" + cookie[prop]);
+		}
+		return newCookie.join("; ");
 	}
 
-	let hours = Math.floor(duration / 3600);
-	let minutes = Math.floor(duration % 3600 / 60);
-	let seconds = Math.floor(duration % 3600 % 60);
-
-	return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
-}
+	async removeCookie(cookieString, cookieName) {
+		try { await this._validateHook(); } catch { return; }
+		var cookies = this.parseCookies(cookieString);
+		delete cookies[cookieName];
+		return this.toString(cookies);
+	}
 
-let youtubeRequestCallbacks = [];
-let youtubeRequestsPending = 0;
-let youtubeRequestsActive = false;
+	async htmlEntities(str) {
+		try { await this._validateHook(); } catch { return; }
+		return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+	}
 
-module.exports = {
-	htmlEntities: str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
-	generateRandomString: function(len) {
+	async generateRandomString(len) {
+		try { await this._validateHook(); } catch { return; }
 		let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
 		let result = [];
 		for (let i = 0; i < len; i++) {
-			result.push(chars[this.getRandomNumber(0, chars.length - 1)]);
+			result.push(chars[await this.getRandomNumber(0, chars.length - 1)]);
 		}
 		return result.join("");
-	},
-	getSocketFromId: function(socketId) {
+	}
+
+	async getSocketFromId(socketId) {
+		try { await this._validateHook(); } catch { return; }
 		return globals.io.sockets.sockets[socketId];
-	},
-	getRandomNumber: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min,
-	convertTime,
-	Timer,
-	guid: () => [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join(''),
-	cookies: {
-		parseCookies: cookieString => {
-			let cookies = {};
-			if (cookieString) cookieString.split("; ").map((cookie) => {
-				(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
-			});
-			return cookies;
-		},
-		toString: cookies => {
-			let newCookie = [];
-			for (let prop in cookie) {
-				newCookie.push(prop + "=" + cookie[prop]);
-			}
-			return newCookie.join("; ");
-		},
-		removeCookie: (cookieString, cookieName) => {
-			var cookies = this.parseCookies(cookieString);
-			delete cookies[cookieName];
-			return this.toString(cookies);
+	}
+
+	async getRandomNumber(min, max) {
+		try { await this._validateHook(); } catch { return; }
+		return Math.floor(Math.random() * (max - min + 1)) + min
+	}
+
+	async convertTime(duration) {
+		try { await this._validateHook(); } catch { return; }
+		let a = duration.match(/\d+/g);
+	
+		if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
+			a = [0, a[0], 0];
+		}
+	
+		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
+			a = [a[0], 0, a[1]];
+		}
+		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
+			a = [a[0], 0, 0];
 		}
-	},
-	socketFromSession: function(socketId) {
-		let ns = io.io.of("/");
+	
+		duration = 0;
+	
+		if (a.length == 3) {
+			duration = duration + parseInt(a[0]) * 3600;
+			duration = duration + parseInt(a[1]) * 60;
+			duration = duration + parseInt(a[2]);
+		}
+	
+		if (a.length == 2) {
+			duration = duration + parseInt(a[0]) * 60;
+			duration = duration + parseInt(a[1]);
+		}
+	
+		if (a.length == 1) {
+			duration = duration + parseInt(a[0]);
+		}
+	
+		let hours = Math.floor(duration / 3600);
+		let minutes = Math.floor(duration % 3600 / 60);
+		let seconds = Math.floor(duration % 3600 % 60);
+	
+		return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
+	}
+
+	async guid () {
+		try { await this._validateHook(); } catch { return; }
+		return [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join('');
+	}
+
+	async socketFromSession(socketId) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		if (ns) {
 			return ns.connected[socketId];
 		}
-	},
-	socketsFromSessionId: function(sessionId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromSessionId(sessionId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -156,14 +192,17 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketsFromUser: function(userId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromUser(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
-				cache.hget('sessions', session.sessionId, (err, session) => {
+				this.cache.hget('sessions', session.sessionId, (err, session) => {
 					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
 					next();
 				});
@@ -171,14 +210,17 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketsFromIP: function(ip, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromIP(ip, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
-				cache.hget('sessions', session.sessionId, (err, session) => {
+				this.cache.hget('sessions', session.sessionId, (err, session) => {
 					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
 					next();
 				});
@@ -186,9 +228,12 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketsFromUserWithoutCache: function(userId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromUserWithoutCache(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -199,31 +244,43 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketLeaveRooms: function(socketid) {
-		let socket = this.socketFromSession(socketid);
+	}
+
+	async socketLeaveRooms(socketid) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketid);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			socket.leave(room);
 		}
-	},
-	socketJoinRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
+	}
+
+	async socketJoinRoom(socketId, room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			socket.leave(room);
 		}
 		socket.join(room);
-	},
-	socketJoinSongRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
+	}
+
+	async socketJoinSongRoom(socketId, room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			if (room.indexOf('song.') !== -1) socket.leave(rooms);
 		}
 		socket.join(room);
-	},
-	socketsJoinSongRoom: function(sockets, room) {
+	}
+
+	async socketsJoinSongRoom(sockets, room) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let rooms = socket.rooms;
@@ -232,8 +289,11 @@ module.exports = {
 			}
 			socket.join(room);
 		}
-	},
-	socketsLeaveSongRooms: function(sockets) {
+	}
+
+	async socketsLeaveSongRooms(sockets) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let rooms = socket.rooms;
@@ -241,30 +301,34 @@ module.exports = {
 				if (room.indexOf('song.') !== -1) socket.leave(room);
 			}
 		}
-	},
-	emitToRoom: function(room) {
-		let sockets = io.io.sockets.sockets;
+	}
+
+	async emitToRoom(room, ...args) {
+		try { await this._validateHook(); } catch { return; }
+
+		let sockets = this.io.io.sockets.sockets;
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) {
-				let args = [];
-				for (let i = 1; i < Object.keys(arguments).length; i++) {
-					args.push(arguments[i]);
-				}
 				socket.emit.apply(socket, args);
 			}
 		}
-	},
-	getRoomSockets: function(room) {
-		let sockets = io.io.sockets.sockets;
+	}
+
+	async getRoomSockets(room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let sockets = this.io.io.sockets.sockets;
 		let roomSockets = [];
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) roomSockets.push(socket);
 		}
 		return roomSockets;
-	},
-	getSongFromYouTube: (songId, cb) => {
+	}
+
+	async getSongFromYouTube(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 		youtubeRequestCallbacks.push({cb: (test) => {
 			youtubeRequestsActive = true;
@@ -320,8 +384,10 @@ module.exports = {
 		if (!youtubeRequestsActive) {
 			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
 		}
-	},
-	getPlaylistFromYouTube: (url, cb) => {
+	}
+
+	async getPlaylistFromYouTube(url, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
@@ -353,8 +419,11 @@ module.exports = {
 			});
 		}
 		getPage(null, []);
-	},
-	getSongFromSpotify: async (song, cb) => {
+	}
+
+	async getSongFromSpotify(song, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!config.get("apis.spotify.enabled")) return cb("Spotify is not enabled", null);
 
 		const spotifyParams = [
@@ -362,7 +431,7 @@ module.exports = {
 			`type=track`
 		].join('&');
 
-		const token = await spotify.getToken();
+		const token = await this.spotify.getToken();
 		const options = {
 			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
 			headers: {
@@ -400,8 +469,11 @@ module.exports = {
 
 			cb(null, song);
 		});
-	},
-	getSongsFromSpotify: async (title, artist, cb) => {
+	}
+
+	async getSongsFromSpotify(title, artist, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!config.get("apis.spotify.enabled")) return cb([]);
 
 		const spotifyParams = [
@@ -409,7 +481,7 @@ module.exports = {
 			`type=track`
 		].join('&');
 		
-		const token = await spotify.getToken();
+		const token = await this.spotify.getToken();
 		const options = {
 			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
 			headers: {
@@ -449,8 +521,11 @@ module.exports = {
 
 			cb(songs);
 		});
-	},
-	shuffle: (array) => {
+	}
+
+	async shuffle(array) {
+		try { await this._validateHook(); } catch { return; }
+
 		let currentIndex = array.length, temporaryValue, randomIndex;
 
 		// While there remain elements to shuffle...
@@ -467,8 +542,11 @@ module.exports = {
 		}
 
 		return array;
-	},
-	getError: (err) => {
+	}
+
+	async getError(err) {
+		try { await this._validateHook(); } catch { return; }
+
 		let error = 'An error occurred.';
 		if (typeof err === "string") error = err;
 		else if (err.message) {
@@ -476,8 +554,11 @@ module.exports = {
 			else error = err.errors[Object.keys(err.errors)].message;
 		}
 		return error;
-	},
-	canUserBeInStation: (station, userId, cb) => {
+	}
+
+	async canUserBeInStation(station, userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
 				if (station.privacy !== 'private') return next(true);
@@ -486,7 +567,7 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				this.db.models.user.findOne({_id: userId}, next);
 			},
 
 			(user, next) => {
@@ -501,4 +582,4 @@ module.exports = {
 			return cb(false);
 		});
 	}
-};
+}

+ 4 - 2
backend/package.json

@@ -1,12 +1,14 @@
 {
   "name": "musare-backend",
-  "version": "0.0.1",
+  "private": true,
+  "version": "2.1.0",
   "description": "A modern, open-source, collaborative music app https://musare.com",
-  "main": "app.js",
+  "main": "index.js",
   "author": "Musare Team",
   "license": "GPL-3.0",
   "repository": "https://github.com/Musare/MusareNode",
   "scripts": {
+    "dev": "nodemon",
     "docker:dev": "nodemon -L /opt/app",
     "docker:prod": "node /opt/app"
   },

+ 0 - 2017
backend/yarn.lock

@@ -1,2017 +0,0 @@
-# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-# yarn lockfile v1
-
-
-"@types/node@^8.0.7":
-  version "8.10.51"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.51.tgz#80600857c0a47a8e8bafc2dae6daed6db58e3627"
-  integrity sha512-cArrlJp3Yv6IyFT/DYe+rlO8o3SIHraALbBW/+CcCYW/a9QucpLI+n2p4sRxAvl2O35TiecpX2heSZtJjvEO+Q==
-
-abbrev@1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
-  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
-
-accepts@~1.3.4, accepts@~1.3.7:
-  version "1.3.7"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
-  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
-  dependencies:
-    mime-types "~2.1.24"
-    negotiator "0.6.2"
-
-after@0.8.2:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
-  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
-
-agent-base@4, agent-base@^4.2.0, agent-base@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
-  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
-  dependencies:
-    es6-promisify "^5.0.0"
-
-agent-base@~4.2.1:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
-  integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==
-  dependencies:
-    es6-promisify "^5.0.0"
-
-ajv@^6.5.5:
-  version "6.10.2"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
-  integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==
-  dependencies:
-    fast-deep-equal "^2.0.1"
-    fast-json-stable-stringify "^2.0.0"
-    json-schema-traverse "^0.4.1"
-    uri-js "^4.2.2"
-
-ansi-regex@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
-
-ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
-
-aproba@^1.0.3:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
-  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
-
-are-we-there-yet@~1.1.2:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
-  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
-  dependencies:
-    delegates "^1.0.0"
-    readable-stream "^2.0.6"
-
-array-flatten@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
-  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
-
-arraybuffer.slice@~0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
-  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
-
-asn1@~0.2.3:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
-  dependencies:
-    safer-buffer "~2.1.0"
-
-assert-plus@1.0.0, assert-plus@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
-
-ast-types@0.x.x:
-  version "0.13.2"
-  resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
-  integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA==
-
-async-limiter@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
-  integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
-
-async@2.6.2:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"
-  integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==
-  dependencies:
-    lodash "^4.17.11"
-
-async@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/async/-/async-3.1.0.tgz#42b3b12ae1b74927b5217d8c0016baaf62463772"
-  integrity sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==
-
-async@^2.6.1:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
-  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
-  dependencies:
-    lodash "^4.17.14"
-
-asynckit@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
-
-aws-sign2@~0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
-
-aws4@^1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
-  integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
-
-backo2@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
-  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
-
-balanced-match@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
-  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
-
-base64-arraybuffer@0.1.5:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
-  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
-
-base64id@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
-  integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=
-
-bcrypt-pbkdf@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
-  dependencies:
-    tweetnacl "^0.14.3"
-
-bcrypt@^3.0.6:
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-3.0.6.tgz#f607846df62d27e60d5e795612c4f67d70206eb2"
-  integrity sha512-taA5bCTfXe7FUjKroKky9EXpdhkVvhE5owfxfLYodbrAR1Ul3juLmIQmIQBK4L9a5BuUcE6cqmwT+Da20lF9tg==
-  dependencies:
-    nan "2.13.2"
-    node-pre-gyp "0.12.0"
-
-better-assert@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
-  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
-  dependencies:
-    callsite "1.0.0"
-
-blob@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
-  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
-
-bluebird@3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
-  integrity sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==
-
-bluebird@^3.5.5:
-  version "3.5.5"
-  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
-  integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
-
-body-parser@1.19.0, body-parser@^1.19.0:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
-  dependencies:
-    bytes "3.1.0"
-    content-type "~1.0.4"
-    debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
-
-brace-expansion@^1.1.7:
-  version "1.1.11"
-  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
-  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
-  dependencies:
-    balanced-match "^1.0.0"
-    concat-map "0.0.1"
-
-bson@^1.1.1, bson@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.1.tgz#4330f5e99104c4e751e7351859e2d408279f2f13"
-  integrity sha512-jCGVYLoYMHDkOsbwJZBCqwMHyH4c+wzgI9hG7Z6SZJRXWr+x58pdIbm2i9a/jFGCkRJqRUr8eoI7lDWa0hTkxg==
-
-bytes@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
-  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
-
-callsite@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
-  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
-
-caseless@~0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
-
-chownr@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6"
-  integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==
-
-co@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
-  integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
-
-code-point-at@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
-
-combined-stream@^1.0.6, combined-stream@~1.0.6:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
-  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
-  dependencies:
-    delayed-stream "~1.0.0"
-
-component-bind@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
-  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
-
-component-emitter@1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
-  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
-
-component-inherit@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
-  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
-
-concat-map@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
-  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
-
-config@^3.2.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/config/-/config-3.2.1.tgz#c687cdd0ba22433422ff4f6e3edffbe3930aa141"
-  integrity sha512-EMA/IU0gBI3OZHi41B2JaosXEc6tJMN8RT3Pm5cHuRfbtfAQbNmYB6bFq0JK8tRu8F2WZ8s+5tnUX7acEy37xw==
-  dependencies:
-    json5 "^1.0.1"
-
-console-control-strings@^1.0.0, console-control-strings@~1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
-  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
-
-content-disposition@0.5.3:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
-  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
-  dependencies:
-    safe-buffer "5.1.2"
-
-content-type@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
-  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
-
-convert-hex@~0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/convert-hex/-/convert-hex-0.1.0.tgz#08c04568922c27776b8a2e81a95d393362ea0b65"
-  integrity sha1-CMBFaJIsJ3drii6BqV05M2LqC2U=
-
-convert-string@~0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/convert-string/-/convert-string-0.1.0.tgz#79ce41a9bb0d03bcf72cdc6a8f3c56fbbc64410a"
-  integrity sha1-ec5BqbsNA7z3LNxqjzxW+7xkQQo=
-
-cookie-parser@^1.4.4:
-  version "1.4.4"
-  resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.4.tgz#e6363de4ea98c3def9697b93421c09f30cf5d188"
-  integrity sha512-lo13tqF3JEtFO7FyA49CqbhaFkskRJ0u/UAiINgrIXeRCY41c88/zxtrECl8AKH3B0hj9q10+h3Kt8I7KlW4tw==
-  dependencies:
-    cookie "0.3.1"
-    cookie-signature "1.0.6"
-
-cookie-signature@1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
-  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
-
-cookie@0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
-  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
-
-cookie@0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
-  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
-
-core-util-is@1.0.2, core-util-is@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
-
-cors@^2.8.5:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
-  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
-  dependencies:
-    object-assign "^4"
-    vary "^1"
-
-dashdash@^1.12.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
-  dependencies:
-    assert-plus "^1.0.0"
-
-data-uri-to-buffer@2:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-2.0.1.tgz#ca8f56fe38b1fd329473e9d1b4a9afcd8ce1c045"
-  integrity sha512-OkVVLrerfAKZlW2ZZ3Ve2y65jgiWqBKsTfUIAFbn8nVbPcCZg6l6gikKlEYv0kXcmzqGm6mFq/Jf2vriuEkv8A==
-  dependencies:
-    "@types/node" "^8.0.7"
-
-debug@2.6.9:
-  version "2.6.9"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
-  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
-  dependencies:
-    ms "2.0.0"
-
-debug@3.1.0, debug@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
-  dependencies:
-    ms "2.0.0"
-
-debug@4, debug@^4.1.0, debug@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
-  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
-  dependencies:
-    ms "^2.1.1"
-
-debug@^3.1.0, debug@^3.2.6:
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
-  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
-  dependencies:
-    ms "^2.1.1"
-
-deep-extend@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
-  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
-
-deep-is@~0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
-  integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
-
-degenerator@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
-  integrity sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=
-  dependencies:
-    ast-types "0.x.x"
-    escodegen "1.x.x"
-    esprima "3.x.x"
-
-delayed-stream@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
-
-delegates@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
-
-depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
-
-destroy@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
-  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
-
-detect-libc@^1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
-  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
-
-discord.js@^11.5.1:
-  version "11.5.1"
-  resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-11.5.1.tgz#910fb9f6410328581093e044cafb661783a4d9e8"
-  integrity sha512-tGhV5xaZXE3Z+4uXJb3hYM6gQ1NmnSxp9PClcsSAYFVRzH6AJH74040mO3afPDMWEAlj8XsoPXXTJHTxesqcGw==
-  dependencies:
-    long "^4.0.0"
-    prism-media "^0.0.3"
-    snekfetch "^3.6.4"
-    tweetnacl "^1.0.0"
-    ws "^6.0.0"
-
-double-ended-queue@^2.1.0-0:
-  version "2.1.0-0"
-  resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c"
-  integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=
-
-ecc-jsbn@~0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
-  dependencies:
-    jsbn "~0.1.0"
-    safer-buffer "^2.1.0"
-
-ee-first@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
-  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
-
-encodeurl@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
-  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
-
-engine.io-client@~3.3.1:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.2.tgz#04e068798d75beda14375a264bb3d742d7bc33aa"
-  integrity sha512-y0CPINnhMvPuwtqXfsGuWE8BB66+B6wTtCofQDRecMQPYX3MYUZXFNKDhdrSe3EVjgOu4V3rxdeqN/Tr91IgbQ==
-  dependencies:
-    component-emitter "1.2.1"
-    component-inherit "0.0.3"
-    debug "~3.1.0"
-    engine.io-parser "~2.1.1"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    parseqs "0.0.5"
-    parseuri "0.0.5"
-    ws "~6.1.0"
-    xmlhttprequest-ssl "~1.5.4"
-    yeast "0.1.2"
-
-engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
-  integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
-  dependencies:
-    after "0.8.2"
-    arraybuffer.slice "~0.0.7"
-    base64-arraybuffer "0.1.5"
-    blob "0.0.5"
-    has-binary2 "~1.0.2"
-
-engine.io@~3.3.1:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
-  integrity sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==
-  dependencies:
-    accepts "~1.3.4"
-    base64id "1.0.0"
-    cookie "0.3.1"
-    debug "~3.1.0"
-    engine.io-parser "~2.1.0"
-    ws "~6.1.0"
-
-es6-promise@^4.0.3:
-  version "4.2.8"
-  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
-  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
-
-es6-promisify@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
-  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
-  dependencies:
-    es6-promise "^4.0.3"
-
-escape-html@~1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
-  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
-
-escodegen@1.x.x:
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.1.tgz#c485ff8d6b4cdb89e27f4a856e91f118401ca510"
-  integrity sha512-JwiqFD9KdGVVpeuRa68yU3zZnBEOcPs0nKW7wZzXky8Z7tffdYUHbe11bPCV5jYlK6DVdKLWLm0f5I/QlL0Kmw==
-  dependencies:
-    esprima "^3.1.3"
-    estraverse "^4.2.0"
-    esutils "^2.0.2"
-    optionator "^0.8.1"
-  optionalDependencies:
-    source-map "~0.6.1"
-
-esprima@3.x.x, esprima@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
-  integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
-
-estraverse@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
-  integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=
-
-esutils@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
-  integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=
-
-etag@~1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
-
-express@^4.17.1:
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
-  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
-  dependencies:
-    accepts "~1.3.7"
-    array-flatten "1.1.1"
-    body-parser "1.19.0"
-    content-disposition "0.5.3"
-    content-type "~1.0.4"
-    cookie "0.4.0"
-    cookie-signature "1.0.6"
-    debug "2.6.9"
-    depd "~1.1.2"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    finalhandler "~1.1.2"
-    fresh "0.5.2"
-    merge-descriptors "1.0.1"
-    methods "~1.1.2"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    path-to-regexp "0.1.7"
-    proxy-addr "~2.0.5"
-    qs "6.7.0"
-    range-parser "~1.2.1"
-    safe-buffer "5.1.2"
-    send "0.17.1"
-    serve-static "1.14.1"
-    setprototypeof "1.1.1"
-    statuses "~1.5.0"
-    type-is "~1.6.18"
-    utils-merge "1.0.1"
-    vary "~1.1.2"
-
-extend@~3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
-  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
-
-extsprintf@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
-
-extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
-
-fast-deep-equal@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
-  integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
-
-fast-json-stable-stringify@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
-  integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
-
-fast-levenshtein@~2.0.4:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
-  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
-
-file-uri-to-path@1:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
-  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
-
-finalhandler@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
-  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
-  dependencies:
-    debug "2.6.9"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    statuses "~1.5.0"
-    unpipe "~1.0.0"
-
-forever-agent@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
-
-form-data@^2.3.3:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.0.tgz#094ec359dc4b55e7d62e0db4acd76e89fe874d37"
-  integrity sha512-WXieX3G/8side6VIqx44ablyULoGruSde5PNTxoUyo5CeyAMX6nVWUd0rgist/EuX655cjhUhTo1Fo3tRYqbcA==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.6"
-    mime-types "^2.1.12"
-
-form-data@~2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
-  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.6"
-    mime-types "^2.1.12"
-
-forwarded@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
-  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
-
-fresh@0.5.2:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
-  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
-
-fs-minipass@^1.2.5:
-  version "1.2.6"
-  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07"
-  integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==
-  dependencies:
-    minipass "^2.2.1"
-
-fs.realpath@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
-  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
-
-ftp@~0.3.10:
-  version "0.3.10"
-  resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d"
-  integrity sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=
-  dependencies:
-    readable-stream "1.1.x"
-    xregexp "2.0.0"
-
-gauge@~2.7.3:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
-  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
-  dependencies:
-    aproba "^1.0.3"
-    console-control-strings "^1.0.0"
-    has-unicode "^2.0.0"
-    object-assign "^4.1.0"
-    signal-exit "^3.0.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.1"
-    wide-align "^1.1.0"
-
-get-uri@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.3.tgz#fa13352269781d75162c6fc813c9e905323fbab5"
-  integrity sha512-x5j6Ks7FOgLD/GlvjKwgu7wdmMR55iuRHhn8hj/+gA+eSbxQvZ+AEomq+3MgVEZj1vpi738QahGbCCSIDtXtkw==
-  dependencies:
-    data-uri-to-buffer "2"
-    debug "4"
-    extend "~3.0.2"
-    file-uri-to-path "1"
-    ftp "~0.3.10"
-    readable-stream "3"
-
-getpass@^0.1.1:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
-  dependencies:
-    assert-plus "^1.0.0"
-
-glob@^7.1.3:
-  version "7.1.4"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
-  integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
-  dependencies:
-    fs.realpath "^1.0.0"
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "^3.0.4"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-har-schema@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
-
-har-validator@~5.1.0:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
-  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
-  dependencies:
-    ajv "^6.5.5"
-    har-schema "^2.0.0"
-
-has-binary2@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
-  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
-  dependencies:
-    isarray "2.0.1"
-
-has-cors@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
-  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
-
-has-unicode@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
-  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
-
-http-errors@1.7.2:
-  version "1.7.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
-  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-errors@1.7.3, http-errors@~1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
-  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.4"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-proxy-agent@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
-  integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
-  dependencies:
-    agent-base "4"
-    debug "3.1.0"
-
-http-signature@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
-  dependencies:
-    assert-plus "^1.0.0"
-    jsprim "^1.2.2"
-    sshpk "^1.7.0"
-
-https-proxy-agent@^2.2.1:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793"
-  integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg==
-  dependencies:
-    agent-base "^4.3.0"
-    debug "^3.1.0"
-
-iconv-lite@0.4.24, iconv-lite@^0.4.4:
-  version "0.4.24"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
-  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
-  dependencies:
-    safer-buffer ">= 2.1.2 < 3"
-
-ignore-walk@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
-  integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==
-  dependencies:
-    minimatch "^3.0.4"
-
-indexof@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
-
-inflection@~1.12.0:
-  version "1.12.0"
-  resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
-  integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
-
-inflection@~1.3.0:
-  version "1.3.8"
-  resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e"
-  integrity sha1-y9Fg2p91sUw8xjV41POWeEvzAU4=
-
-inflight@^1.0.4:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
-  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
-  dependencies:
-    once "^1.3.0"
-    wrappy "1"
-
-inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
-  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
-
-inherits@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
-
-ini@~1.3.0:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
-  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
-
-ip@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
-  integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
-
-ipaddr.js@1.9.0:
-  version "1.9.0"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
-  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
-
-is-fullwidth-code-point@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
-  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
-  dependencies:
-    number-is-nan "^1.0.0"
-
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-
-is-stream@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
-
-is-typedarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
-  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
-
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
-isarray@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
-  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
-
-isarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
-  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
-
-isstream@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
-
-jsbn@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
-
-json-schema-traverse@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
-  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
-
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
-
-json-stringify-safe@~5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
-  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
-
-json5@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
-  integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
-  dependencies:
-    minimist "^1.2.0"
-
-jsprim@^1.2.2:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
-  dependencies:
-    assert-plus "1.0.0"
-    extsprintf "1.3.0"
-    json-schema "0.2.3"
-    verror "1.10.0"
-
-kareem@2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.0.tgz#ef33c42e9024dce511eeaf440cd684f3af1fc769"
-  integrity sha512-6hHxsp9e6zQU8nXsP+02HGWXwTkOEw6IROhF2ZA28cYbUk4eJ6QbtZvdqZOdD9YPKghG3apk5eOCvs+tLl3lRg==
-
-levn@~0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
-  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
-  dependencies:
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
-
-lodash@^4.17.11, lodash@^4.17.14:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
-
-long@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
-  integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
-
-lru-cache@^4.1.2:
-  version "4.1.5"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
-  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
-  dependencies:
-    pseudomap "^1.0.2"
-    yallist "^2.1.2"
-
-mailgun-js@^0.22.0:
-  version "0.22.0"
-  resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.22.0.tgz#128942b5e47a364a470791608852bf68c96b3a05"
-  integrity sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==
-  dependencies:
-    async "^2.6.1"
-    debug "^4.1.0"
-    form-data "^2.3.3"
-    inflection "~1.12.0"
-    is-stream "^1.1.0"
-    path-proxy "~1.0.0"
-    promisify-call "^2.0.2"
-    proxy-agent "^3.0.3"
-    tsscmp "^1.0.6"
-
-media-typer@0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
-  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
-
-memory-pager@^1.0.2:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
-  integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==
-
-merge-descriptors@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
-  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
-
-methods@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
-  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
-
-mime-db@1.40.0:
-  version "1.40.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
-  integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
-
-mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
-  version "2.1.24"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
-  integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
-  dependencies:
-    mime-db "1.40.0"
-
-mime@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
-  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
-
-minimatch@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
-  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
-  dependencies:
-    brace-expansion "^1.1.7"
-
-minimist@0.0.8:
-  version "0.0.8"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
-
-minimist@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
-  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
-
-minipass@^2.2.1, minipass@^2.3.5:
-  version "2.3.5"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848"
-  integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==
-  dependencies:
-    safe-buffer "^5.1.2"
-    yallist "^3.0.0"
-
-minizlib@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614"
-  integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==
-  dependencies:
-    minipass "^2.2.1"
-
-mkdirp@^0.5.0, mkdirp@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
-  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
-  dependencies:
-    minimist "0.0.8"
-
-moment@^2.24.0:
-  version "2.24.0"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
-  integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
-
-mongodb-core@3.2.7:
-  version "3.2.7"
-  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.2.7.tgz#a8ef1fe764a192c979252dacbc600dc88d77e28f"
-  integrity sha512-WypKdLxFNPOH/Jy6i9z47IjG2wIldA54iDZBmHMINcgKOUcWJh8og+Wix76oGd7EyYkHJKssQ2FAOw5Su/n4XQ==
-  dependencies:
-    bson "^1.1.1"
-    require_optional "^1.0.1"
-    safe-buffer "^5.1.2"
-  optionalDependencies:
-    saslprep "^1.0.0"
-
-mongodb@3.2.7:
-  version "3.2.7"
-  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.2.7.tgz#8ba149e4be708257cad0dea72aebb2bbb311a7ac"
-  integrity sha512-2YdWrdf1PJgxcCrT1tWoL6nHuk6hCxhddAAaEh8QJL231ci4+P9FLyqopbTm2Z2sAU6mhCri+wd9r1hOcHdoMw==
-  dependencies:
-    mongodb-core "3.2.7"
-    safe-buffer "^5.1.2"
-
-mongoose-legacy-pluralize@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
-  integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==
-
-mongoose@^5.6.4:
-  version "5.6.5"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.6.5.tgz#fdddf9a3c588a670b81b23bec203440f5ddf2876"
-  integrity sha512-c8bIo8mxbf1ybwo9jgPKcJRICQBlIMKwDWt2A+M7h0AutroQ5EqzRAYOK1vrHwwwq00EcJyVwjVBW2wv8E9Wfw==
-  dependencies:
-    async "2.6.2"
-    bson "~1.1.1"
-    kareem "2.3.0"
-    mongodb "3.2.7"
-    mongodb-core "3.2.7"
-    mongoose-legacy-pluralize "1.0.2"
-    mpath "0.6.0"
-    mquery "3.2.1"
-    ms "2.1.2"
-    regexp-clone "1.0.0"
-    safe-buffer "5.1.2"
-    sift "7.0.1"
-    sliced "1.0.1"
-
-mpath@0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.6.0.tgz#aa922029fca4f0f641f360e74c5c1b6a4c47078e"
-  integrity sha512-i75qh79MJ5Xo/sbhxrDrPSEG0H/mr1kcZXJ8dH6URU5jD/knFxCVqVC/gVSW7GIXL/9hHWlT9haLbCXWOll3qw==
-
-mquery@3.2.1:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.1.tgz#8b059a49cdae0a8a9e804284ef64c2f58d3ac05d"
-  integrity sha512-kY/K8QToZWTTocm0U+r8rqcJCp5PRl6e8tPmoDs5OeSO3DInZE2rAL6AYH+V406JTo8305LdASOQcxRDqHojyw==
-  dependencies:
-    bluebird "3.5.1"
-    debug "3.1.0"
-    regexp-clone "^1.0.0"
-    safe-buffer "5.1.2"
-    sliced "1.0.1"
-
-ms@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
-
-ms@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
-  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
-
-ms@2.1.2, ms@^2.1.1:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
-  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
-
-nan@2.13.2:
-  version "2.13.2"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
-  integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
-
-needle@^2.2.1:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c"
-  integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==
-  dependencies:
-    debug "^3.2.6"
-    iconv-lite "^0.4.4"
-    sax "^1.2.4"
-
-negotiator@0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
-  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
-
-netmask@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
-  integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=
-
-node-pre-gyp@0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149"
-  integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==
-  dependencies:
-    detect-libc "^1.0.2"
-    mkdirp "^0.5.1"
-    needle "^2.2.1"
-    nopt "^4.0.1"
-    npm-packlist "^1.1.6"
-    npmlog "^4.0.2"
-    rc "^1.2.7"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar "^4"
-
-nopt@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
-  integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=
-  dependencies:
-    abbrev "1"
-    osenv "^0.1.4"
-
-npm-bundled@^1.0.1:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
-  integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==
-
-npm-packlist@^1.1.6:
-  version "1.4.4"
-  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.4.tgz#866224233850ac534b63d1a6e76050092b5d2f44"
-  integrity sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw==
-  dependencies:
-    ignore-walk "^3.0.1"
-    npm-bundled "^1.0.1"
-
-npmlog@^4.0.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
-  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
-  dependencies:
-    are-we-there-yet "~1.1.2"
-    console-control-strings "~1.1.0"
-    gauge "~2.7.3"
-    set-blocking "~2.0.0"
-
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
-oauth-sign@~0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
-  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-
-oauth@^0.9.15:
-  version "0.9.15"
-  resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
-  integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
-
-object-assign@^4, object-assign@^4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
-  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
-
-object-component@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
-  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
-
-on-finished@~2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
-  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
-  dependencies:
-    ee-first "1.1.1"
-
-once@^1.3.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
-  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
-  dependencies:
-    wrappy "1"
-
-optionator@^0.8.1:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
-  integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=
-  dependencies:
-    deep-is "~0.1.3"
-    fast-levenshtein "~2.0.4"
-    levn "~0.3.0"
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
-    wordwrap "~1.0.0"
-
-os-homedir@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
-os-tmpdir@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
-
-osenv@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
-pac-proxy-agent@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-3.0.0.tgz#11d578b72a164ad74bf9d5bac9ff462a38282432"
-  integrity sha512-AOUX9jES/EkQX2zRz0AW7lSx9jD//hQS8wFXBvcnd/J2Py9KaMJMqV/LPqJssj1tgGufotb2mmopGPR15ODv1Q==
-  dependencies:
-    agent-base "^4.2.0"
-    debug "^3.1.0"
-    get-uri "^2.0.0"
-    http-proxy-agent "^2.1.0"
-    https-proxy-agent "^2.2.1"
-    pac-resolver "^3.0.0"
-    raw-body "^2.2.0"
-    socks-proxy-agent "^4.0.1"
-
-pac-resolver@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-3.0.0.tgz#6aea30787db0a891704deb7800a722a7615a6f26"
-  integrity sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==
-  dependencies:
-    co "^4.6.0"
-    degenerator "^1.0.4"
-    ip "^1.1.5"
-    netmask "^1.0.6"
-    thunkify "^2.1.2"
-
-parseqs@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
-  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
-  dependencies:
-    better-assert "~1.0.0"
-
-parseuri@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
-  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
-  dependencies:
-    better-assert "~1.0.0"
-
-parseurl@~1.3.3:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
-  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
-
-path-is-absolute@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
-  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
-
-path-proxy@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e"
-  integrity sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=
-  dependencies:
-    inflection "~1.3.0"
-
-path-to-regexp@0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
-  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
-
-performance-now@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
-  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
-
-prelude-ls@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
-  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
-
-prism-media@^0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-0.0.3.tgz#8842d4fae804f099d3b48a9a38e3c2bab6f4855b"
-  integrity sha512-c9KkNifSMU/iXT8FFTaBwBMr+rdVcN+H/uNv1o+CuFeTThNZNTOrQ+RgXA1yL/DeLk098duAeRPP3QNPNbhxYQ==
-
-process-nextick-args@~2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
-  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-
-promisify-call@^2.0.2:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/promisify-call/-/promisify-call-2.0.4.tgz#d48c2d45652ccccd52801ddecbd533a6d4bd5fba"
-  integrity sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=
-  dependencies:
-    with-callback "^1.0.2"
-
-proxy-addr@~2.0.5:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
-  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
-  dependencies:
-    forwarded "~0.1.2"
-    ipaddr.js "1.9.0"
-
-proxy-agent@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-3.1.0.tgz#3cf86ee911c94874de4359f37efd9de25157c113"
-  integrity sha512-IkbZL4ClW3wwBL/ABFD2zJ8iP84CY0uKMvBPk/OceQe/cEjrxzN1pMHsLwhbzUoRhG9QbSxYC+Z7LBkTiBNvrA==
-  dependencies:
-    agent-base "^4.2.0"
-    debug "^3.1.0"
-    http-proxy-agent "^2.1.0"
-    https-proxy-agent "^2.2.1"
-    lru-cache "^4.1.2"
-    pac-proxy-agent "^3.0.0"
-    proxy-from-env "^1.0.0"
-    socks-proxy-agent "^4.0.1"
-
-proxy-from-env@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
-  integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=
-
-pseudomap@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
-
-psl@^1.1.24:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.2.0.tgz#df12b5b1b3a30f51c329eacbdef98f3a6e136dc6"
-  integrity sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==
-
-punycode@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
-  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
-
-punycode@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
-  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
-
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
-
-qs@~6.5.2:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
-  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
-
-range-parser@~1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
-  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
-
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
-  dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    unpipe "1.0.0"
-
-raw-body@^2.2.0:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
-  integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==
-  dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.3"
-    iconv-lite "0.4.24"
-    unpipe "1.0.0"
-
-rc@^1.2.7:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
-  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
-  dependencies:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
-readable-stream@1.1.x:
-  version "1.1.14"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
-  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-readable-stream@3:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
-  integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
-  dependencies:
-    inherits "^2.0.3"
-    string_decoder "^1.1.1"
-    util-deprecate "^1.0.1"
-
-readable-stream@^2.0.6:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
-  integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.3"
-    isarray "~1.0.0"
-    process-nextick-args "~2.0.0"
-    safe-buffer "~5.1.1"
-    string_decoder "~1.1.1"
-    util-deprecate "~1.0.1"
-
-redis-commands@^1.2.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785"
-  integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==
-
-redis-parser@^2.6.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b"
-  integrity sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=
-
-redis@^2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
-  integrity sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==
-  dependencies:
-    double-ended-queue "^2.1.0-0"
-    redis-commands "^1.2.0"
-    redis-parser "^2.6.0"
-
-regexp-clone@1.0.0, regexp-clone@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-1.0.0.tgz#222db967623277056260b992626354a04ce9bf63"
-  integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==
-
-request@^2.88.0:
-  version "2.88.0"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
-  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.0"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
-
-require_optional@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.1.tgz#4cf35a4247f64ca3df8c2ef208cc494b1ca8fc2e"
-  integrity sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==
-  dependencies:
-    resolve-from "^2.0.0"
-    semver "^5.1.0"
-
-resolve-from@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57"
-  integrity sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=
-
-rimraf@^2.6.1:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
-  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
-  dependencies:
-    glob "^7.1.3"
-
-safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
-  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-
-safe-buffer@^5.0.1, safe-buffer@^5.1.2:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
-  integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
-
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
-  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
-
-saslprep@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226"
-  integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==
-  dependencies:
-    sparse-bitfield "^3.0.3"
-
-sax@^1.2.4:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
-  integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
-
-semver@^5.1.0, semver@^5.3.0:
-  version "5.7.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b"
-  integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==
-
-send@0.17.1:
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
-  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.7.2"
-    mime "1.6.0"
-    ms "2.1.1"
-    on-finished "~2.3.0"
-    range-parser "~1.2.1"
-    statuses "~1.5.0"
-
-serve-static@1.14.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
-  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
-  dependencies:
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    parseurl "~1.3.3"
-    send "0.17.1"
-
-set-blocking@~2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
-  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
-
-setprototypeof@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
-  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
-
-sha256@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/sha256/-/sha256-0.2.0.tgz#73a0b418daab7035bff86e8491e363412fc2ab05"
-  integrity sha1-c6C0GNqrcDW/+G6EkeNjQS/CqwU=
-  dependencies:
-    convert-hex "~0.1.0"
-    convert-string "~0.1.0"
-
-sift@7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/sift/-/sift-7.0.1.tgz#47d62c50b159d316f1372f8b53f9c10cd21a4b08"
-  integrity sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g==
-
-signal-exit@^3.0.0:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
-  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
-
-sliced@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41"
-  integrity sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=
-
-smart-buffer@4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.2.tgz#5207858c3815cc69110703c6b94e46c15634395d"
-  integrity sha512-JDhEpTKzXusOqXZ0BUIdH+CjFdO/CR3tLlf5CN34IypI+xMmXW1uB16OOY8z3cICbJlDAVJzNbwBhNO0wt9OAw==
-
-snekfetch@^3.6.4:
-  version "3.6.4"
-  resolved "https://registry.yarnpkg.com/snekfetch/-/snekfetch-3.6.4.tgz#d13e80a616d892f3d38daae4289f4d258a645120"
-  integrity sha512-NjxjITIj04Ffqid5lqr7XdgwM7X61c/Dns073Ly170bPQHLm6jkmelye/eglS++1nfTWktpP6Y2bFXjdPlQqdw==
-
-socket.io-adapter@~1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
-  integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=
-
-socket.io-client@2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
-  integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
-  dependencies:
-    backo2 "1.0.2"
-    base64-arraybuffer "0.1.5"
-    component-bind "1.0.0"
-    component-emitter "1.2.1"
-    debug "~3.1.0"
-    engine.io-client "~3.3.1"
-    has-binary2 "~1.0.2"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    object-component "0.0.3"
-    parseqs "0.0.5"
-    parseuri "0.0.5"
-    socket.io-parser "~3.3.0"
-    to-array "0.1.4"
-
-socket.io-parser@~3.3.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
-  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
-  dependencies:
-    component-emitter "1.2.1"
-    debug "~3.1.0"
-    isarray "2.0.1"
-
-socket.io@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
-  integrity sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==
-  dependencies:
-    debug "~4.1.0"
-    engine.io "~3.3.1"
-    has-binary2 "~1.0.2"
-    socket.io-adapter "~1.1.0"
-    socket.io-client "2.2.0"
-    socket.io-parser "~3.3.0"
-
-socks-proxy-agent@^4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386"
-  integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==
-  dependencies:
-    agent-base "~4.2.1"
-    socks "~2.3.2"
-
-socks@~2.3.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.2.tgz#ade388e9e6d87fdb11649c15746c578922a5883e"
-  integrity sha512-pCpjxQgOByDHLlNqlnh/mNSAxIUkyBBuwwhTcV+enZGbDaClPvHdvm6uvOwZfFJkam7cGhBNbb4JxiP8UZkRvQ==
-  dependencies:
-    ip "^1.1.5"
-    smart-buffer "4.0.2"
-
-source-map@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
-  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
-
-sparse-bitfield@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11"
-  integrity sha1-/0rm5oZWBWuks+eSqzM004JzyhE=
-  dependencies:
-    memory-pager "^1.0.2"
-
-sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
-  dependencies:
-    asn1 "~0.2.3"
-    assert-plus "^1.0.0"
-    bcrypt-pbkdf "^1.0.0"
-    dashdash "^1.12.0"
-    ecc-jsbn "~0.1.1"
-    getpass "^0.1.1"
-    jsbn "~0.1.0"
-    safer-buffer "^2.0.2"
-    tweetnacl "~0.14.0"
-
-"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
-  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
-
-string-width@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
-  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
-  dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    strip-ansi "^3.0.0"
-
-"string-width@^1.0.2 || 2":
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
-string_decoder@^1.1.1:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d"
-  integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==
-  dependencies:
-    safe-buffer "~5.1.0"
-
-string_decoder@~0.10.x:
-  version "0.10.31"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
-  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
-
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
-  dependencies:
-    safe-buffer "~5.1.0"
-
-strip-ansi@^3.0.0, strip-ansi@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
-  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
-  dependencies:
-    ansi-regex "^2.0.0"
-
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
-  dependencies:
-    ansi-regex "^3.0.0"
-
-strip-json-comments@~2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
-  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
-
-tar@^4:
-  version "4.4.10"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1"
-  integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==
-  dependencies:
-    chownr "^1.1.1"
-    fs-minipass "^1.2.5"
-    minipass "^2.3.5"
-    minizlib "^1.2.1"
-    mkdirp "^0.5.0"
-    safe-buffer "^5.1.2"
-    yallist "^3.0.3"
-
-thunkify@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
-  integrity sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=
-
-to-array@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
-  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
-
-toidentifier@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
-  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
-
-tough-cookie@~2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
-  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
-  dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
-
-tsscmp@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
-  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
-
-tunnel-agent@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
-  dependencies:
-    safe-buffer "^5.0.1"
-
-tweetnacl@^0.14.3, tweetnacl@~0.14.0:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
-
-tweetnacl@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.1.tgz#2594d42da73cd036bd0d2a54683dd35a6b55ca17"
-  integrity sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==
-
-type-check@~0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
-  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
-  dependencies:
-    prelude-ls "~1.1.2"
-
-type-is@~1.6.17, type-is@~1.6.18:
-  version "1.6.18"
-  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
-  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
-  dependencies:
-    media-typer "0.3.0"
-    mime-types "~2.1.24"
-
-underscore@^1.9.1:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961"
-  integrity sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==
-
-unpipe@1.0.0, unpipe@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
-  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
-
-uri-js@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
-  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
-  dependencies:
-    punycode "^2.1.0"
-
-util-deprecate@^1.0.1, util-deprecate@~1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
-  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
-
-utils-merge@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
-
-uuid@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
-  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
-
-vary@^1, vary@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
-  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
-
-verror@1.10.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
-  dependencies:
-    assert-plus "^1.0.0"
-    core-util-is "1.0.2"
-    extsprintf "^1.2.0"
-
-wide-align@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
-  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
-  dependencies:
-    string-width "^1.0.2 || 2"
-
-with-callback@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/with-callback/-/with-callback-1.0.2.tgz#a09629b9a920028d721404fb435bdcff5c91bc21"
-  integrity sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE=
-
-wordwrap@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
-  integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
-
-wrappy@1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
-  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
-
-ws@^6.0.0:
-  version "6.2.1"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb"
-  integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==
-  dependencies:
-    async-limiter "~1.0.0"
-
-ws@~6.1.0:
-  version "6.1.4"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
-  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
-  dependencies:
-    async-limiter "~1.0.0"
-
-xmlhttprequest-ssl@~1.5.4:
-  version "1.5.5"
-  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
-  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
-
-xregexp@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
-  integrity sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=
-
-yallist@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
-
-yallist@^3.0.0, yallist@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
-  integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
-
-yeast@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
-  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=

+ 1 - 1
docker-compose.yml

@@ -35,7 +35,7 @@ services:
       - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
     volumes:
-      - ./tools/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
+      - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
   mongoclient:
     image: mongoclient/mongoclient
     ports:

+ 1 - 3
frontend/.eslintrc

@@ -20,9 +20,7 @@
 	],
 	"globals": {
 		"lofig": "writable",
-		"grecaptcha": "readonly",
-		"ga": "readonly",
-		"moment": "readonly"
+		"grecaptcha": "readonly"
 	},
 	"rules": {
 		"no-console": 0,

+ 70 - 50
frontend/App.vue

@@ -26,38 +26,26 @@ import WhatIsNew from "./components/Modals/WhatIsNew.vue";
 import MobileAlert from "./components/Modals/MobileAlert.vue";
 import LoginModal from "./components/Modals/Login.vue";
 import RegisterModal from "./components/Modals/Register.vue";
-import auth from "./auth";
 import io from "./io";
 
 export default {
 	replace: false,
 	data() {
 		return {
-			banned: false,
-			ban: {},
-			loggedIn: false,
-			role: "",
-			username: "",
-			userId: "",
 			serverDomain: "",
 			socketConnected: true
 		};
 	},
 	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		role: state => state.user.auth.role,
+		username: state => state.user.auth.username,
+		userId: state => state.user.auth.userId,
+		banned: state => state.user.auth.banned,
 		modals: state => state.modals.modals,
 		currentlyActive: state => state.modals.currentlyActive
 	}),
 	methods: {
-		logout() {
-			const _this = this;
-			_this.socket.emit("users.logout", result => {
-				if (result.status === "success") {
-					document.cookie =
-						"SID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;";
-					window.location.reload();
-				} else Toast.methods.addToast(result.message, 4000);
-			});
-		},
 		submitOnEnter: (cb, event) => {
 			if (event.which === 13) cb();
 		},
@@ -73,49 +61,37 @@ export default {
 				this.closeCurrentModal();
 		};
 
-		const _this = this;
 		if (localStorage.getItem("github_redirect")) {
 			this.$router.go(localStorage.getItem("github_redirect"));
 			localStorage.removeItem("github_redirect");
 		}
-		auth.isBanned((banned, ban) => {
-			_this.ban = ban;
-			_this.banned = banned;
-		});
-		auth.getStatus((authenticated, role, username, userId) => {
-			_this.socket = window.socket;
-			_this.loggedIn = authenticated;
-			_this.role = role;
-			_this.username = username;
-			_this.userId = userId;
-		});
 		io.onConnect(true, () => {
-			_this.socketConnected = true;
+			this.socketConnected = true;
 		});
 		io.onConnectError(true, () => {
-			_this.socketConnected = false;
+			this.socketConnected = false;
 		});
 		io.onDisconnect(true, () => {
-			_this.socketConnected = false;
+			this.socketConnected = false;
 		});
 		lofig.get("serverDomain", res => {
-			_this.serverDomain = res;
+			this.serverDomain = res;
 		});
-		_this.$router.onReady(() => {
-			if (_this.$route.query.err) {
-				let { err } = _this.$route.query;
+		this.$router.onReady(() => {
+			if (this.$route.query.err) {
+				let { err } = this.$route.query;
 				err = err
 					.replace(new RegExp("<", "g"), "&lt;")
 					.replace(new RegExp(">", "g"), "&gt;");
-				_this.$router.push({ query: {} });
+				this.$router.push({ query: {} });
 				Toast.methods.addToast(err, 20000);
 			}
-			if (_this.$route.query.msg) {
-				let { msg } = _this.$route.query;
+			if (this.$route.query.msg) {
+				let { msg } = this.$route.query;
 				msg = msg
 					.replace(new RegExp("<", "g"), "&lt;")
 					.replace(new RegExp(">", "g"), "&gt;");
-				_this.$router.push({ query: {} });
+				this.$router.push({ query: {} });
 				Toast.methods.addToast(msg, 20000);
 			}
 		});
@@ -137,9 +113,7 @@ export default {
 </script>
 
 <style lang="scss">
-.center {
-	text-align: center;
-}
+@import "styles/global.scss";
 
 #toast-container {
 	z-index: 10000 !important;
@@ -149,6 +123,17 @@ html {
 	overflow: auto !important;
 }
 
+body {
+	background-color: $light-grey;
+	color: $dark-grey;
+	font-family: "Roboto", Helvetica, Arial, sans-serif;
+}
+
+a {
+	color: $primary-color;
+	text-decoration: none;
+}
+
 .modal-card {
 	margin: 0 !important;
 }
@@ -163,8 +148,8 @@ html {
 
 .alert {
 	padding: 20px;
-	color: white;
-	background-color: red;
+	color: $white;
+	background-color: $red;
 	position: fixed;
 	top: 50px;
 	right: 50px;
@@ -183,9 +168,9 @@ html {
 		text-align: center;
 		padding: 7.5px 6px;
 		border-radius: 2px;
-		background-color: #323232;
+		background-color: $dark-grey;
 		font-size: 0.9em;
-		color: #fff;
+		color: $white;
 		content: attr(data-tooltip);
 		opacity: 0;
 		transition: all 0.2s ease-in-out 0.1s;
@@ -256,7 +241,7 @@ html {
 }
 .input:focus,
 .input:active {
-	border-color: #03a9f4 !important;
+	border-color: $primary-color !important;
 }
 button.delete:focus {
 	background-color: rgba(10, 10, 10, 0.3);
@@ -266,7 +251,42 @@ button.delete:focus {
 	padding-right: 6px !important;
 }
 
-.button.is-success {
-	background-color: #00b16a !important;
+.button {
+	&.is-success {
+		background-color: $green !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($green, 5%) !important;
+		}
+	}
+	&.is-primary {
+		background-color: $primary-color !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($primary-color, 5%) !important;
+		}
+	}
+	&.is-danger {
+		background-color: $red !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($red, 5%) !important;
+		}
+	}
+	&.is-info {
+		background-color: $blue !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($blue, 5%) !important;
+		}
+	}
+}
+
+.center {
+	text-align: center;
 }
 </style>

+ 18 - 1
frontend/api/auth.js

@@ -1,3 +1,4 @@
+import { Toast } from "vue-roaster";
 import io from "../io";
 
 // when Vuex needs to interact with socket.io
@@ -61,7 +62,7 @@ export default {
 							let domain = "";
 							if (cookie.domain !== "localhost")
 								domain = ` domain=${cookie.domain};`;
-							document.cookie = `SID=${
+							document.cookie = `${cookie.SIDname}=${
 								res.SID
 							}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
 							return resolve({ status: "success" });
@@ -72,5 +73,21 @@ export default {
 				});
 			});
 		});
+	},
+	logout() {
+		return new Promise((resolve, reject) => {
+			io.getSocket(socket => {
+				socket.emit("users.logout", result => {
+					if (result.status === "success") {
+						return lofig.get("cookie", cookie => {
+							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
+							return window.location.reload();
+						});
+					}
+					Toast.methods.addToast(result.message, 4000);
+					return reject(new Error(result.message));
+				});
+			});
+		});
 	}
 };

+ 3 - 4
frontend/auth.js

@@ -17,11 +17,10 @@ export default {
 	},
 
 	setBanned(ban) {
-		const _this = this;
-		_this.banned = true;
-		_this.ban = ban;
+		this.banned = true;
+		this.ban = ban;
 		bannedCallbacks.forEach(callback => {
-			callback(true, _this.ban);
+			callback(true, this.ban);
 		});
 	},
 

+ 4 - 0
frontend/components/404.vue

@@ -1,5 +1,7 @@
 <template>
 	<div class="wrapper">
+		<metadata title="404" />
+
 		<h3><strong>404</strong>&nbsp;Not Found</h3>
 		<router-link class="button is-black" to="/">
 			Back to Home
@@ -8,6 +10,8 @@
 </template>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 * {
 	margin: 0;
 	padding: 0;

+ 46 - 43
frontend/components/Admin/EditStation.vue

@@ -150,9 +150,9 @@
 </template>
 
 <script>
-import { mapState } from "vuex";
-
+import { mapState, mapActions } from "vuex";
 import { Toast } from "vue-roaster";
+
 import Modal from "../Modals/Modal.vue";
 import io from "../../io";
 import validation from "../../validation";
@@ -163,9 +163,8 @@ export default {
 		editing: state => state.editing
 	}),
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 			return socket;
 		});
 	},
@@ -264,8 +263,6 @@ export default {
 			);
 		},
 		updateDescription() {
-			const _this = this;
-
 			const { description } = this.editing;
 			if (!validation.isLength(description, 2, 200))
 				return Toast.methods.addToast(
@@ -288,13 +285,13 @@ export default {
 				description,
 				res => {
 					if (res.status === "success") {
-						if (_this.station) {
-							_this.station.description = description;
+						if (this.station) {
+							this.station.description = description;
 							return description;
 						}
-						_this.$parent.stations.forEach((station, index) => {
-							if (station._id === _this.editing._id) {
-								_this.$parent.stations[
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
 									index
 								].description = description;
 								return description;
@@ -311,21 +308,21 @@ export default {
 			);
 		},
 		updatePrivacy() {
-			const _this = this;
 			this.socket.emit(
 				"stations.updatePrivacy",
 				this.editing._id,
 				this.editing.privacy,
 				res => {
 					if (res.status === "success") {
-						if (_this.station)
-							_this.station.privacy = _this.editing.privacy;
+						if (this.station)
+							this.station.privacy = this.editing.privacy;
 						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === _this.editing._id) {
-									_this.$parent.stations[index].privacy =
-										_this.editing.privacy;
-									return _this.editing.privacy;
+							this.$parent.stations.forEach((station, index) => {
+								if (station._id === this.editing._id) {
+									this.$parent.stations[
+										index
+									].privacy = this.editing.privacy;
+									return this.editing.privacy;
 								}
 
 								return false;
@@ -339,7 +336,6 @@ export default {
 			);
 		},
 		updateGenres() {
-			const _this = this;
 			this.socket.emit(
 				"stations.updateGenres",
 				this.editing._id,
@@ -347,12 +343,12 @@ export default {
 				res => {
 					if (res.status === "success") {
 						const genres = JSON.parse(
-							JSON.stringify(_this.editing.genres)
+							JSON.stringify(this.editing.genres)
 						);
-						if (_this.station) _this.station.genres = genres;
-						_this.$parent.stations.forEach((station, index) => {
-							if (station._id === _this.editing._id) {
-								_this.$parent.stations[index].genres = genres;
+						if (this.station) this.station.genres = genres;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[index].genres = genres;
 								return genres;
 							}
 
@@ -366,7 +362,6 @@ export default {
 			);
 		},
 		updateBlacklistedGenres() {
-			const _this = this;
 			this.socket.emit(
 				"stations.updateBlacklistedGenres",
 				this.editing._id,
@@ -374,13 +369,13 @@ export default {
 				res => {
 					if (res.status === "success") {
 						const blacklistedGenres = JSON.parse(
-							JSON.stringify(_this.editing.blacklistedGenres)
+							JSON.stringify(this.editing.blacklistedGenres)
 						);
-						if (_this.station)
-							_this.station.blacklistedGenres = blacklistedGenres;
-						_this.$parent.stations.forEach((station, index) => {
-							if (station._id === _this.editing._id) {
-								_this.$parent.stations[
+						if (this.station)
+							this.station.blacklistedGenres = blacklistedGenres;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
 									index
 								].blacklistedGenres = blacklistedGenres;
 								return blacklistedGenres;
@@ -396,20 +391,20 @@ export default {
 			);
 		},
 		updatePartyMode() {
-			const _this = this;
 			this.socket.emit(
 				"stations.updatePartyMode",
 				this.editing._id,
 				this.editing.partyMode,
 				res => {
 					if (res.status === "success") {
-						if (_this.station)
-							_this.station.partyMode = _this.editing.partyMode;
-						_this.$parent.stations.forEach((station, index) => {
-							if (station._id === _this.editing._id) {
-								_this.$parent.stations[index].partyMode =
-									_this.editing.partyMode;
-								return _this.editing.partyMode;
+						if (this.station)
+							this.station.partyMode = this.editing.partyMode;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].partyMode = this.editing.partyMode;
+								return this.editing.partyMode;
 							}
 
 							return false;
@@ -460,15 +455,23 @@ export default {
 		},
 		deleteStation() {
 			this.socket.emit("stations.remove", this.editing._id, res => {
-				Toast.methods.addToast(res.message, 8000);
+				if (res.status === "success")
+					this.closeModal({
+						sector: "station",
+						modal: "editStation"
+					});
+				return Toast.methods.addToast(res.message, 8000);
 			});
-		}
+		},
+		...mapActions("modals", ["closeModal"])
 	},
 	components: { Modal }
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .controls {
 	display: flex;
 
@@ -493,6 +496,6 @@ h5 {
 }
 
 .select:after {
-	border-color: #029ce3;
+	border-color: $primary-color;
 }
 </style>

+ 25 - 40
frontend/components/Admin/News.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | News" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>
@@ -26,7 +27,7 @@
 						<td>
 							<button
 								class="button is-primary"
-								@click="editNews(news)"
+								@click="editNewsClick(news)"
 							>
 								Edit
 							</button>
@@ -231,37 +232,36 @@ export default {
 				features: [],
 				improvements: [],
 				upcoming: []
-			},
-			editing: {}
+			}
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("news.index", res => {
-				_this.news = res.data;
+			this.socket = socket;
+			this.socket.emit("news.index", res => {
+				this.news = res.data;
 				return res.data;
 			});
-			_this.socket.on("event:admin.news.created", news => {
-				_this.news.unshift(news);
+			this.socket.on("event:admin.news.created", news => {
+				this.news.unshift(news);
 			});
-			_this.socket.on("event:admin.news.removed", news => {
-				_this.news = _this.news.filter(item => item._id !== news._id);
+			this.socket.on("event:admin.news.removed", news => {
+				this.news = this.news.filter(item => item._id !== news._id);
 			});
-			if (_this.socket.connected) _this.init();
-			io.onConnect(() => _this.init());
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
 		});
 	},
 	computed: {
 		...mapState("modals", {
 			modals: state => state.modals.admin
+		}),
+		...mapState("admin/news", {
+			editing: state => state.editing
 		})
 	},
 	methods: {
 		createNews() {
-			const _this = this;
-
 			const {
 				creating: { bugs, features, improvements, upcoming }
 			} = this;
@@ -287,10 +287,10 @@ export default {
 					3000
 				);
 
-			return _this.socket.emit("news.create", _this.creating, result => {
+			return this.socket.emit("news.create", this.creating, result => {
 				Toast.methods.addToast(result.message, 4000);
 				if (result.status === "success")
-					_this.creating = {
+					this.creating = {
 						title: "",
 						description: "",
 						bugs: [],
@@ -305,28 +305,10 @@ export default {
 				Toast.methods.addToast(res.message, 8000)
 			);
 		},
-		editNews(news) {
-			this.editing = news;
+		editNewsClick(news) {
+			this.editNews(news);
 			this.openModal({ sector: "admin", modal: "editNews" });
 		},
-		updateNews(close) {
-			const _this = this;
-			this.socket.emit(
-				"news.update",
-				_this.editing._id,
-				_this.editing,
-				res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === "success") {
-						if (close)
-							_this.closeModal({
-								sector: "admin",
-								modal: "editNews"
-							});
-					}
-				}
-			);
-		},
 		addChange(type) {
 			const change = document.getElementById(`new-${type}`).value.trim();
 
@@ -346,12 +328,15 @@ export default {
 		init() {
 			this.socket.emit("apis.joinAdminRoom", "news", () => {});
 		},
-		...mapActions("modals", ["openModal", "closeModal"])
+		...mapActions("modals", ["openModal", "closeModal"]),
+		...mapActions("admin/news", ["editNews"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .tag:not(:last-child) {
 	margin-right: 5px;
 }
@@ -361,10 +346,10 @@ td {
 }
 
 .is-info:focus {
-	background-color: #0398db;
+	background-color: $primary-color;
 }
 
 .card-footer-item {
-	color: #03a9f4;
+	color: $primary-color;
 }
 </style>

+ 16 - 13
frontend/components/Admin/Punishments.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | Punishments" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>
@@ -142,39 +143,41 @@ export default {
 			this.openModal({ sector: "admin", modal: "viewPunishment" });
 		},
 		banIP() {
-			const _this = this;
-			_this.socket.emit(
+			this.socket.emit(
 				"punishments.banIP",
-				_this.ipBan.ip,
-				_this.ipBan.reason,
-				_this.ipBan.expiresAt,
+				this.ipBan.ip,
+				this.ipBan.reason,
+				this.ipBan.expiresAt,
 				res => {
 					Toast.methods.addToast(res.message, 6000);
 				}
 			);
 		},
 		init() {
-			const _this = this;
-			_this.socket.emit("punishments.index", res => {
-				if (res.status === "success") _this.punishments = res.data;
+			this.socket.emit("punishments.index", res => {
+				if (res.status === "success") this.punishments = res.data;
 			});
-			// _this.socket.emit('apis.joinAdminRoom', 'punishments', () => {});
+			this.socket.emit("apis.joinAdminRoom", "punishments", () => {});
 		},
 		...mapActions("modals", ["openModal"]),
 		...mapActions("admin/punishments", ["viewPunishment"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) _this.init();
-			io.onConnect(() => _this.init());
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+			socket.on("event:admin.punishment.added", punishment => {
+				this.punishments.push(punishment);
+			});
 		});
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 body {
 	font-family: "Roboto", sans-serif;
 }

+ 40 - 37
frontend/components/Admin/QueueSongs.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | Queue songs" />
 		<div class="container">
 			<input
 				v-model="searchQuery"
@@ -14,10 +15,9 @@
 					<tr>
 						<td>Thumbnail</td>
 						<td>Title</td>
-						<td>ID</td>
-						<td>YouTube ID</td>
 						<td>Artists</td>
 						<td>Genres</td>
+						<td>ID / YouTube ID</td>
 						<td>Requested By</td>
 						<td>Options</td>
 					</tr>
@@ -34,8 +34,11 @@
 						<td>
 							<strong>{{ song.title }}</strong>
 						</td>
-						<td>{{ song._id }}</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
 						<td>
+							{{ song._id }}
+							<br />
 							<a
 								:href="
 									'https://www.youtube.com/watch?v=' +
@@ -46,8 +49,6 @@
 								{{ song.songId }}</a
 							>
 						</td>
-						<td>{{ song.artists.join(", ") }}</td>
-						<td>{{ song.genres.join(", ") }}</td>
 						<td>
 							<user-id-to-username
 								:userId="song.requestedBy"
@@ -122,8 +123,12 @@ export default {
 	},
 	computed: {
 		filteredSongs() {
-			return this.songs;
-			// return this.songs.filter(song => song.indexOf(song.searchQuery) !== -1);
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
 		},
 		...mapState("modals", {
 			modals: state => state.modals.admin
@@ -137,9 +142,8 @@ export default {
 	// },
 	methods: {
 		getSet(position) {
-			const _this = this;
 			this.socket.emit("queueSongs.getSet", position, data => {
-				_this.songs = data;
+				this.songs = data;
 				this.position = position;
 			});
 		},
@@ -167,44 +171,41 @@ export default {
 			});
 		},
 		init() {
-			const _this = this;
-			_this.socket.emit("queueSongs.index", data => {
-				_this.songs = data.songs;
-				_this.maxPosition = Math.round(data.maxLength / 50);
+			this.socket.emit("queueSongs.index", data => {
+				this.songs = data.songs;
+				this.maxPosition = Math.round(data.maxLength / 50);
 			});
-			_this.socket.emit("apis.joinAdminRoom", "queue", () => {});
+			this.socket.emit("apis.joinAdminRoom", "queue", () => {});
 		},
 		...mapActions("admin/songs", ["stopVideo", "editSong"]),
 		...mapActions("modals", ["openModal"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) {
-				_this.init();
-				_this.socket.on("event:admin.queueSong.added", queueSong => {
-					_this.songs.push(queueSong);
-				});
-				_this.socket.on("event:admin.queueSong.removed", songId => {
-					_this.songs = _this.songs.filter(song => {
-						return song._id !== songId;
-					});
+			this.socket = socket;
+
+			this.socket.on("event:admin.queueSong.added", queueSong => {
+				this.songs.push(queueSong);
+			});
+			this.socket.on("event:admin.queueSong.removed", songId => {
+				this.songs = this.songs.filter(song => {
+					return song._id !== songId;
 				});
-				_this.socket.on(
-					"event:admin.queueSong.updated",
-					updatedSong => {
-						for (let i = 0; i < _this.songs.length; i += 1) {
-							const song = _this.songs[i];
-							if (song._id === updatedSong._id) {
-								_this.songs.$set(i, updatedSong);
-							}
-						}
+			});
+			this.socket.on("event:admin.queueSong.updated", updatedSong => {
+				for (let i = 0; i < this.songs.length; i += 1) {
+					const song = this.songs[i];
+					if (song._id === updatedSong._id) {
+						this.songs.$set(i, updatedSong);
 					}
-				);
+				}
+			});
+
+			if (this.socket.connected) {
+				this.init();
 			}
 			io.onConnect(() => {
-				_this.init();
+				this.init();
 			});
 		});
 	}
@@ -212,6 +213,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .optionsColumn {
 	width: 140px;
 	button {
@@ -230,6 +233,6 @@ td {
 }
 
 .is-primary:focus {
-	background-color: #029ce3 !important;
+	background-color: $primary-color !important;
 }
 </style>

+ 19 - 14
frontend/components/Admin/Reports.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | Reports" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>
@@ -14,7 +15,11 @@
 				<tbody>
 					<tr v-for="(report, index) in reports" :key="index">
 						<td>
-							<span>{{ report.songId }}</span>
+							<span>
+								{{ report.song.songId }}
+								<br />
+								{{ report.song._id }}
+							</span>
 						</td>
 						<td>
 							<user-id-to-username
@@ -68,29 +73,28 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) _this.init();
-			_this.socket.emit("reports.index", res => {
-				_this.reports = res.data;
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			this.socket.emit("reports.index", res => {
+				this.reports = res.data;
 			});
-			_this.socket.on("event:admin.report.resolved", reportId => {
-				_this.reports = _this.reports.filter(report => {
+			this.socket.on("event:admin.report.resolved", reportId => {
+				this.reports = this.reports.filter(report => {
 					return report._id !== reportId;
 				});
 			});
-			_this.socket.on("event:admin.report.created", report => {
-				_this.reports.push(report);
+			this.socket.on("event:admin.report.created", report => {
+				this.reports.push(report);
 			});
 			io.onConnect(() => {
-				_this.init();
+				this.init();
 			});
 		});
 
 		if (this.$route.query.id) {
 			this.socket.emit("reports.findOne", this.$route.query.id, res => {
-				if (res.status === "success") _this.view(res.data);
+				if (res.status === "success") this.view(res.data);
 				else
 					Toast.methods.addToast(
 						"Report with that ID not found",
@@ -113,11 +117,10 @@ export default {
 			this.openModal({ sector: "admin", modal: "viewReport" });
 		},
 		resolve(reportId) {
-			const _this = this;
 			this.socket.emit("reports.resolve", reportId, res => {
 				Toast.methods.addToast(res.message, 3000);
 				if (res.status === "success" && this.modals.viewReport)
-					_this.closeModal({
+					this.closeModal({
 						sector: "admin",
 						modal: "viewReport"
 					});
@@ -130,6 +133,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .tag:not(:last-child) {
 	margin-right: 5px;
 }

+ 71 - 42
frontend/components/Admin/Songs.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | Songs" />
 		<div class="container">
 			<input
 				v-model="searchQuery"
@@ -14,10 +15,17 @@
 					<tr>
 						<td>Thumbnail</td>
 						<td>Title</td>
-						<td>ID</td>
-						<td>YouTube ID</td>
 						<td>Artists</td>
 						<td>Genres</td>
+						<td class="likesColumn">
+							<i class="material-icons thumbLike">thumb_up</i>
+						</td>
+						<td class="dislikesColumn">
+							<i class="material-icons thumbDislike"
+								>thumb_down</i
+							>
+						</td>
+						<td>ID / Youtube ID</td>
 						<td>Requested By</td>
 						<td>Options</td>
 					</tr>
@@ -34,8 +42,13 @@
 						<td>
 							<strong>{{ song.title }}</strong>
 						</td>
-						<td>{{ song._id }}</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>{{ song.likes }}</td>
+						<td>{{ song.dislikes }}</td>
 						<td>
+							{{ song._id }}
+							<br />
 							<a
 								:href="
 									'https://www.youtube.com/watch?v=' +
@@ -46,8 +59,6 @@
 								{{ song.songId }}</a
 							>
 						</td>
-						<td>{{ song.artists.join(", ") }}</td>
-						<td>{{ song.genres.join(", ") }}</td>
 						<td>
 							<user-id-to-username
 								:userId="song.requestedBy"
@@ -92,7 +103,6 @@ export default {
 		return {
 			position: 1,
 			maxPosition: 1,
-			songs: [],
 			searchQuery: "",
 			editing: {
 				index: 0,
@@ -102,11 +112,18 @@ export default {
 	},
 	computed: {
 		filteredSongs() {
-			return this.songs;
-			// return this.songs.filter(song => song.indexOf(song.searchQuery) !== -1);
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
 		},
 		...mapState("modals", {
 			modals: state => state.modals.admin
+		}),
+		...mapState("admin/songs", {
+			songs: state => state.songs
 		})
 	},
 	watch: {
@@ -127,52 +144,48 @@ export default {
 			});
 		},
 		getSet() {
-			const _this = this;
-			_this.socket.emit("songs.getSet", _this.position, data => {
+			this.socket.emit("songs.getSet", this.position, data => {
 				data.forEach(song => {
-					_this.songs.push(song);
+					this.addSong(song);
 				});
-				_this.position += 1;
-				if (_this.maxPosition > _this.position - 1) _this.getSet();
+				this.position += 1;
+				if (this.maxPosition > this.position - 1) this.getSet();
 			});
 		},
 		init() {
-			const _this = this;
-			_this.songs = [];
-			_this.socket.emit("songs.length", length => {
-				_this.maxPosition = Math.ceil(length / 15);
-				_this.getSet();
+			this.socket.emit("songs.length", length => {
+				this.maxPosition = Math.ceil(length / 15);
+				this.getSet();
 			});
-			_this.socket.emit("apis.joinAdminRoom", "songs", () => {});
+			this.socket.emit("apis.joinAdminRoom", "songs", () => {});
 		},
-		...mapActions("admin/songs", ["stopVideo", "editSong"]),
+		...mapActions("admin/songs", [
+			"stopVideo",
+			"editSong",
+			"addSong",
+			"removeSong",
+			"updateSong"
+		]),
 		...mapActions("modals", ["openModal", "closeModal"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) {
-				_this.init();
-				_this.socket.on("event:admin.song.added", song => {
-					_this.songs.push(song);
-				});
-				_this.socket.on("event:admin.song.removed", songId => {
-					_this.songs = _this.songs.filter(song => {
-						return song._id !== songId;
-					});
-				});
-				_this.socket.on("event:admin.song.updated", updatedSong => {
-					for (let i = 0; i < _this.songs.length; i += 1) {
-						const song = _this.songs[i];
-						if (song._id === updatedSong._id) {
-							_this.songs.$set(i, updatedSong);
-						}
-					}
-				});
+			this.socket = socket;
+			this.socket.on("event:admin.song.added", song => {
+				this.addSong(song);
+			});
+			this.socket.on("event:admin.song.removed", songId => {
+				this.removeSong(songId);
+			});
+			this.socket.on("event:admin.song.updated", updatedSong => {
+				this.updateSong(updatedSong);
+			});
+
+			if (this.socket.connected) {
+				this.init();
 			}
 			io.onConnect(() => {
-				_this.init();
+				this.init();
 			});
 		});
 
@@ -190,6 +203,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 body {
 	font-family: "Roboto", sans-serif;
 }
@@ -201,6 +216,20 @@ body {
 	}
 }
 
+.likesColumn,
+.dislikesColumn {
+	width: 40px;
+	i {
+		font-size: 20px;
+	}
+	.thumbLike {
+		color: $green !important;
+	}
+	.thumbDislike {
+		color: $red !important;
+	}
+}
+
 .song-thumbnail {
 	display: block;
 	max-width: 50px;
@@ -212,6 +241,6 @@ td {
 }
 
 .is-primary:focus {
-	background-color: #029ce3 !important;
+	background-color: $primary-color !important;
 }
 </style>

+ 32 - 17
frontend/components/Admin/Stations.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | Stations" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>
@@ -19,7 +20,16 @@
 							<span>{{ station._id }}</span>
 						</td>
 						<td>
-							<span>{{ station.name }}</span>
+							<span>
+								<router-link
+									:to="{
+										name: 'station',
+										params: { id: station.name }
+									}"
+								>
+									{{ station.name }}
+								</router-link>
+							</span>
 						</td>
 						<td>
 							<span>{{ station.type }}</span>
@@ -31,7 +41,13 @@
 							<span>{{ station.description }}</span>
 						</td>
 						<td>
+							<span
+								v-if="station.type === 'official'"
+								title="Musare"
+								>Musare</span
+							>
 							<user-id-to-username
+								v-else
 								:userId="station.owner"
 								:link="true"
 							/>
@@ -192,7 +208,6 @@ export default {
 	},
 	methods: {
 		createStation() {
-			const _this = this;
 			const {
 				newStation: {
 					name,
@@ -219,7 +234,7 @@ export default {
 					3000
 				);
 
-			return _this.socket.emit(
+			return this.socket.emit(
 				"stations.create",
 				{
 					name,
@@ -301,30 +316,28 @@ export default {
 			this.newStation.blacklistedGenres.splice(index, 1);
 		},
 		init() {
-			const _this = this;
-			_this.socket.emit("stations.index", data => {
-				_this.stations = data.stations;
+			this.socket.emit("stations.index", data => {
+				this.stations = data.stations;
 			});
-			_this.socket.emit("apis.joinAdminRoom", "stations", () => {});
+			this.socket.emit("apis.joinAdminRoom", "stations", () => {});
 		},
 		...mapActions("modals", ["openModal"]),
 		...mapActions("admin/stations", ["editStation"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) _this.init();
-			_this.socket.on("event:admin.station.added", station => {
-				_this.stations.push(station);
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			this.socket.on("event:admin.station.added", station => {
+				this.stations.push(station);
 			});
-			_this.socket.on("event:admin.station.removed", stationId => {
-				_this.stations = _this.stations.filter(station => {
+			this.socket.on("event:admin.station.removed", stationId => {
+				this.stations = this.stations.filter(station => {
 					return station._id !== stationId;
 				});
 			});
 			io.onConnect(() => {
-				_this.init();
+				this.init();
 			});
 		});
 	}
@@ -332,6 +345,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .tag {
 	margin-top: 5px;
 	&:not(:last-child) {
@@ -346,7 +361,7 @@ td {
 }
 
 .is-info:focus {
-	background-color: #0398db;
+	background-color: $primary-color;
 }
 
 .genre-wrapper {
@@ -355,6 +370,6 @@ td {
 }
 
 .card-footer-item {
-	color: #029ce3;
+	color: $primary-color;
 }
 </style>

+ 11 - 10
frontend/components/Admin/Statistics.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="container">
+		<metadata title="Admin | Statistics" />
 		<div class="columns">
 			<div
 				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
@@ -103,7 +104,8 @@
 </template>
 
 <script>
-import Chart from "chart.js";
+import { Line } from "chart.js";
+import "chartjs-adapter-date-fns";
 
 import io from "../../io";
 
@@ -144,12 +146,10 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
 		const minuteCtx = document.getElementById("minuteChart");
 		const hourCtx = document.getElementById("hourChart");
 
-		_this.minuteChart = new Chart(minuteCtx, {
-			type: "line",
+		this.minuteChart = new Line(minuteCtx, {
 			data: {
 				labels: [
 					"-10",
@@ -207,8 +207,7 @@ export default {
 			}
 		});
 
-		_this.hourChart = new Chart(hourCtx, {
-			type: "line",
+		this.hourChart = new Line(hourCtx, {
 			data: {
 				labels: [
 					"-10",
@@ -267,9 +266,9 @@ export default {
 		});
 
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) _this.init();
-			io.onConnect(() => _this.init());
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
 		});
 	},
 	methods: {
@@ -329,6 +328,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 body {
 	font-family: "Roboto", sans-serif;
 }
@@ -344,6 +345,6 @@ td {
 }
 
 .is-primary:focus {
-	background-color: #029ce3 !important;
+	background-color: $primary-color !important;
 }
 </style>

+ 10 - 12
frontend/components/Admin/Users.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Admin | Users" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>
@@ -86,30 +87,27 @@ export default {
 			this.openModal({ sector: "admin", modal: "editUser" });
 		},
 		init() {
-			const _this = this;
-			_this.socket.emit("users.index", result => {
-				if (result.status === "success") _this.users = result.data;
-			});
-			_this.socket.emit("apis.joinAdminRoom", "users", () => {});
-			_this.socket.on("event:user.username.changed", username => {
-				_this.$parent.$parent.username = username;
+			this.socket.emit("users.index", result => {
+				if (result.status === "success") this.users = result.data;
 			});
+			this.socket.emit("apis.joinAdminRoom", "users", () => {});
 		},
 		...mapActions("admin/users", ["editUser"]),
 		...mapActions("modals", ["openModal"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			if (_this.socket.connected) _this.init();
-			io.onConnect(() => _this.init());
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
 		});
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 body {
 	font-family: "Roboto", sans-serif;
 }
@@ -125,6 +123,6 @@ td {
 }
 
 .is-primary:focus {
-	background-color: #029ce3 !important;
+	background-color: $primary-color !important;
 }
 </style>

+ 7 - 5
frontend/components/MainFooter.vue

@@ -36,7 +36,7 @@
 						<img src="/assets/social/discord.svg" />
 					</a>
 				</p>
-				<a href="https://musare.com" target="_blank"
+				<a href="/"
 					><img
 						class="musareFooterLogo"
 						src="/assets/wordmark.png"
@@ -82,6 +82,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .content a:not(.button) {
 	border: 0;
 }
@@ -102,7 +104,7 @@ export default {
 	border-radius: 33% 33% 0% 0% / 7% 7% 0% 0%;
 	box-shadow: 0 4px 8px 0 rgba(3, 169, 244, 0.65),
 		0 6px 20px 0 rgba(3, 169, 244, 0.4);
-	background-color: #ffffff;
+	background-color: $white;
 	width: 100%;
 
 	.musareFooterLogo {
@@ -123,15 +125,15 @@ export default {
 
 	.footerLinks {
 		:not(:last-child) {
-			border-right: solid 1px #03a9f4;
+			border-right: solid 1px $primary-color;
 		}
 		a {
 			padding: 0 5px;
 			font-size: 18px;
-			color: #03a9f4;
+			color: $primary-color;
 		}
 		a:hover {
-			color: #03a9f4;
+			color: $primary-color;
 			text-decoration: underline;
 		}
 	}

+ 23 - 21
frontend/components/MainHeader.vue

@@ -21,18 +21,18 @@
 
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
 			<router-link
-				v-if="$parent.$parent.role === 'admin'"
+				v-if="role === 'admin'"
 				class="nav-item is-tab admin"
 				to="/admin"
 			>
 				<strong>Admin</strong>
 			</router-link>
-			<span v-if="$parent.$parent.loggedIn" class="grouped">
+			<span v-if="loggedIn" class="grouped">
 				<router-link
 					class="nav-item is-tab"
 					:to="{
 						name: 'profile',
-						params: { username: $parent.$parent.username }
+						params: { username }
 					}"
 				>
 					Profile
@@ -40,12 +40,7 @@
 				<router-link class="nav-item is-tab" to="/settings"
 					>Settings</router-link
 				>
-				<a
-					class="nav-item is-tab"
-					href="#"
-					@click="$parent.$parent.logout()"
-					>Logout</a
-				>
+				<a class="nav-item is-tab" href="#" @click="logout()">Logout</a>
 			</span>
 			<span v-else class="grouped">
 				<a
@@ -99,49 +94,56 @@ export default {
 			return res;
 		});
 	},
-	computed: mapState("modals", {
-		modals: state => state.modals.header
+	computed: mapState({
+		modals: state => state.modals.modals.header,
+		role: state => state.user.auth.role,
+		loggedIn: state => state.user.auth.loggedIn,
+		username: state => state.user.auth.username
 	}),
 	methods: {
-		...mapActions("modals", ["openModal"])
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/auth", ["logout"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .nav {
-	background-color: #03a9f4;
+	background-color: $primary-color;
 	height: 64px;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 
 	.nav-menu.is-active {
 		.nav-item {
-			color: #333;
+			color: $dark-grey-2;
 
 			&:hover {
-				color: #333;
+				color: $dark-grey-2;
 			}
 		}
 	}
 
 	a.nav-item.is-tab:hover {
 		border-bottom: none;
-		border-top: solid 1px #ffffff;
+		border-top: solid 1px $white;
+		padding-top: 9px;
 	}
 
 	.nav-toggle {
 		height: 64px;
 
 		&.is-active span {
-			background-color: #333;
+			background-color: $dark-grey-2;
 		}
 	}
 
 	.is-brand {
 		font-size: 2.1rem !important;
-		line-height: 64px !important;
+		line-height: 38px !important;
 		padding: 0 20px;
-		color: #ffffff;
+		color: $white;
 		font-family: Pacifico, cursive;
 		filter: brightness(0) invert(1);
 
@@ -152,10 +154,10 @@ export default {
 
 	.nav-item {
 		font-size: 17px;
-		color: #ffffff;
+		color: $white;
 
 		&:hover {
-			color: #ffffff;
+			color: $white;
 		}
 	}
 	.admin strong {

+ 35 - 34
frontend/components/Modals/AddSongToPlaylist.vue

@@ -2,10 +2,10 @@
 	<modal title="Add Song To Playlist">
 		<template v-slot:body>
 			<h4 class="songTitle">
-				{{ $parent.currentSong.title }}
+				{{ currentSong.title }}
 			</h4>
 			<h5 class="songArtist">
-				{{ $parent.currentSong.artists }}
+				{{ currentSong.artists }}
 			</h5>
 			<aside class="menu">
 				<p class="menu-label">
@@ -38,6 +38,8 @@
 </template>
 
 <script>
+import { mapState } from "vuex";
+
 import { Toast } from "vue-roaster";
 import Modal from "./Modal.vue";
 import io from "../../io";
@@ -53,84 +55,83 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
-		this.songId = this.$parent.currentSong.songId;
-		this.song = this.$parent.currentSong;
+		this.songId = this.currentSong.songId;
+		this.song = this.currentSong;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("playlists.indexForUser", res => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
 				if (res.status === "success") {
 					res.data.forEach(playlist => {
-						_this.playlists[playlist._id] = playlist;
+						this.playlists[playlist._id] = playlist;
 					});
-					_this.recalculatePlaylists();
+					this.recalculatePlaylists();
 				}
 			});
 		});
 	},
+	computed: {
+		...mapState("station", {
+			currentSong: state => state.currentSong
+		})
+	},
 	methods: {
 		addSongToPlaylist(playlistId) {
-			const _this = this;
 			this.socket.emit(
 				"playlists.addSongToPlaylist",
-				this.$parent.currentSong.songId,
+				this.currentSong.songId,
 				playlistId,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
 					if (res.status === "success") {
-						_this.playlists[playlistId].songs.push(_this.song);
+						this.playlists[playlistId].songs.push(this.song);
 					}
-					_this.recalculatePlaylists();
-					// this.$parent.modals.addSongToPlaylist = false;
+					this.recalculatePlaylists();
 				}
 			);
 		},
 		removeSongFromPlaylist(playlistId) {
-			const _this = this;
 			this.socket.emit(
 				"playlists.removeSongFromPlaylist",
-				_this.songId,
+				this.songId,
 				playlistId,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
 					if (res.status === "success") {
-						_this.playlists[playlistId].songs.forEach(
+						this.playlists[playlistId].songs.forEach(
 							(song, index) => {
-								if (song.songId === _this.songId)
-									_this.playlists[playlistId].songs.splice(
+								if (song.songId === this.songId)
+									this.playlists[playlistId].songs.splice(
 										index,
 										1
 									);
 							}
 						);
 					}
-					_this.recalculatePlaylists();
-					// this.$parent.modals.addSongToPlaylist = false;
+					this.recalculatePlaylists();
 				}
 			);
 		},
 		recalculatePlaylists() {
-			const _this = this;
-			_this.playlistsArr = Object.values(_this.playlists).map(
-				playlist => {
-					let hasSong = false;
-					for (let i = 0; i < playlist.songs.length; i += 1) {
-						if (playlist.songs[i].songId === _this.songId) {
-							hasSong = true;
-						}
+			this.playlistsArr = Object.values(this.playlists).map(playlist => {
+				let hasSong = false;
+				for (let i = 0; i < playlist.songs.length; i += 1) {
+					if (playlist.songs[i].songId === this.songId) {
+						hasSong = true;
 					}
-
-					playlist.hasSong = hasSong; // eslint-disable-line no-param-reassign
-					_this.playlists[playlist._id] = playlist;
-					return playlist;
 				}
-			);
+
+				playlist.hasSong = hasSong; // eslint-disable-line no-param-reassign
+				this.playlists[playlist._id] = playlist;
+				return playlist;
+			});
 		}
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .icon.is-small {
 	margin-right: 10px !important;
 }

+ 34 - 34
frontend/components/Modals/AddSongToQueue.vue

@@ -1,16 +1,13 @@
 <template>
 	<modal title="Add Song To Queue">
 		<div slot="body">
-			<aside
-				class="menu"
-				v-if="$parent.$parent.loggedIn && $parent.type === 'community'"
-			>
+			<aside class="menu" v-if="loggedIn && station.type === 'community'">
 				<ul class="menu-list">
 					<li v-for="(playlist, index) in playlists" :key="index">
 						<a
 							href="#"
 							target="_blank"
-							v-on:click="$parent.editPlaylist(playlist._id)"
+							v-on:click="editPlaylist(playlist._id)"
 							>{{ playlist.displayName }}</a
 						>
 						<div class="controls">
@@ -53,7 +50,7 @@
 					>
 				</p>
 			</div>
-			<div class="control is-grouped" v-if="$parent.type === 'official'">
+			<div class="control is-grouped" v-if="station.type === 'official'">
 				<p class="control is-expanded">
 					<input
 						class="input"
@@ -95,6 +92,8 @@
 </template>
 
 <script>
+import { mapState, mapActions } from "vuex";
+
 import { Toast } from "vue-roaster";
 import Modal from "./Modal.vue";
 import io from "../../io";
@@ -109,31 +108,33 @@ export default {
 			importQuery: ""
 		};
 	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		station: state => state.station.station
+	}),
 	methods: {
 		isPlaylistSelected(playlistId) {
 			return this.privatePlaylistQueueSelected === playlistId;
 		},
 		selectPlaylist(playlistId) {
-			const _this = this;
-			if (_this.$parent.type === "community") {
-				_this.privatePlaylistQueueSelected = playlistId;
-				_this.$parent.privatePlaylistQueueSelected = playlistId;
-				_this.$parent.addFirstPrivatePlaylistSongToQueue();
+			if (this.station.type === "community") {
+				this.privatePlaylistQueueSelected = playlistId;
+				this.$parent.privatePlaylistQueueSelected = playlistId;
+				this.$parent.addFirstPrivatePlaylistSongToQueue();
 			}
 		},
 		unSelectPlaylist() {
-			const _this = this;
-			if (_this.$parent.type === "community") {
-				_this.privatePlaylistQueueSelected = null;
-				_this.$parent.privatePlaylistQueueSelected = null;
+			if (this.station.type === "community") {
+				this.privatePlaylistQueueSelected = null;
+				this.$parent.privatePlaylistQueueSelected = null;
 			}
 		},
 		addSongToQueue(songId) {
-			const _this = this;
-			if (_this.$parent.type === "community") {
-				_this.socket.emit(
+			console.log(this.station.type);
+			if (this.station.type === "community") {
+				this.socket.emit(
 					"stations.addToQueue",
-					_this.$parent.station._id,
+					this.station._id,
 					songId,
 					data => {
 						if (data.status !== "success")
@@ -145,7 +146,7 @@ export default {
 					}
 				);
 			} else {
-				_this.socket.emit("queueSongs.add", songId, data => {
+				this.socket.emit("queueSongs.add", songId, data => {
 					if (data.status !== "success")
 						Toast.methods.addToast(`Error: ${data.message}`, 8000);
 					else Toast.methods.addToast(`${data.message}`, 4000);
@@ -153,22 +154,20 @@ export default {
 			}
 		},
 		importPlaylist() {
-			const _this = this;
 			Toast.methods.addToast(
 				"Starting to import your playlist. This can take some time to do.",
 				4000
 			);
 			this.socket.emit(
 				"queueSongs.addSetToQueue",
-				_this.importQuery,
+				this.importQuery,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
 				}
 			);
 		},
 		submitQuery() {
-			const _this = this;
-			let query = _this.querySearch;
+			let query = this.querySearch;
 			if (query.indexOf("&index=") !== -1) {
 				query = query.split("&index=");
 				query.pop();
@@ -179,12 +178,12 @@ export default {
 				query.pop();
 				query = query.join("");
 			}
-			_this.socket.emit("apis.searchYoutube", query, res => {
+			this.socket.emit("apis.searchYoutube", query, res => {
 				// check for error
 				const { data } = res;
-				_this.queryResults = [];
+				this.queryResults = [];
 				for (let i = 0; i < data.items.length; i += 1) {
-					_this.queryResults.push({
+					this.queryResults.push({
 						id: data.items[i].id.videoId,
 						url: `https://www.youtube.com/watch?v=${this.id}`,
 						title: data.items[i].snippet.title,
@@ -192,17 +191,16 @@ export default {
 					});
 				}
 			});
-		}
+		},
+		...mapActions("user/playlists", ["editPlaylist"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("playlists.indexForUser", res => {
-				if (res.status === "success") _this.playlists = res.data;
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") this.playlists = res.data;
 			});
-			_this.privatePlaylistQueueSelected =
-				_this.$parent.privatePlaylistQueueSelected;
+			this.privatePlaylistQueueSelected = this.$parent.privatePlaylistQueueSelected;
 		});
 	},
 	components: { Modal }
@@ -210,6 +208,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 tr td {
 	vertical-align: middle;
 

+ 2 - 5
frontend/components/Modals/CreateCommunityStation.vue

@@ -58,9 +58,8 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 		});
 	},
 	methods: {
@@ -114,8 +113,6 @@ export default {
 					8000
 				);
 
-			const _this = this;
-
 			return this.socket.emit(
 				"stations.create",
 				{
@@ -130,7 +127,7 @@ export default {
 							`You have added the station successfully`,
 							4000
 						);
-						_this.closeModal({
+						this.closeModal({
 							sector: "home",
 							modal: "createCommunityStation"
 						});

+ 65 - 38
frontend/components/Modals/EditNews.vue

@@ -4,7 +4,7 @@
 			<label class="label">Title</label>
 			<p class="control">
 				<input
-					v-model="$parent.editing.title"
+					v-model="editing.title"
 					class="input"
 					type="text"
 					placeholder="News Title"
@@ -14,7 +14,7 @@
 			<label class="label">Description</label>
 			<p class="control">
 				<input
-					v-model="$parent.editing.description"
+					v-model="editing.description"
 					class="input"
 					type="text"
 					placeholder="News Description"
@@ -29,24 +29,24 @@
 							class="input"
 							type="text"
 							placeholder="Bug"
-							@keyup.enter="addChange('bugs')"
+							@keyup.enter="addChangeClick('bugs')"
 						/>
 						<a
 							class="button is-info"
 							href="#"
-							@click="addChange('bugs')"
+							@click="addChangeClick('bugs')"
 							>Add</a
 						>
 					</p>
 					<span
-						v-for="(bug, index) in $parent.editing.bugs"
+						v-for="(bug, index) in editing.bugs"
 						class="tag is-info"
 						:key="index"
 					>
 						{{ bug }}
 						<button
 							class="delete is-info"
-							@click="removeChange('bugs', index)"
+							@click="removeChangeClick('bugs', index)"
 						/>
 					</span>
 				</div>
@@ -58,24 +58,24 @@
 							class="input"
 							type="text"
 							placeholder="Feature"
-							@keyup.enter="addChange('features')"
+							@keyup.enter="addChangeClick('features')"
 						/>
 						<a
 							class="button is-info"
 							href="#"
-							@click="addChange('features')"
+							@click="addChangeClick('features')"
 							>Add</a
 						>
 					</p>
 					<span
-						v-for="(feature, index) in $parent.editing.features"
+						v-for="(feature, index) in editing.features"
 						class="tag is-info"
 						:key="index"
 					>
 						{{ feature }}
 						<button
 							class="delete is-info"
-							@click="removeChange('features', index)"
+							@click="removeChangeClick('features', index)"
 						/>
 					</span>
 				</div>
@@ -90,25 +90,24 @@
 							class="input"
 							type="text"
 							placeholder="Improvement"
-							@keyup.enter="addChange('improvements')"
+							@keyup.enter="addChangeClick('improvements')"
 						/>
 						<a
 							class="button is-info"
 							href="#"
-							@click="addChange('improvements')"
+							@click="addChangeClick('improvements')"
 							>Add</a
 						>
 					</p>
 					<span
-						v-for="(improvement, index) in $parent.editing
-							.improvements"
+						v-for="(improvement, index) in editing.improvements"
 						class="tag is-info"
 						:key="index"
 					>
 						{{ improvement }}
 						<button
 							class="delete is-info"
-							@click="removeChange('improvements', index)"
+							@click="removeChangeClick('improvements', index)"
 						/>
 					</span>
 				</div>
@@ -120,38 +119,35 @@
 							class="input"
 							type="text"
 							placeholder="Upcoming"
-							@keyup.enter="addChange('upcoming')"
+							@keyup.enter="addChangeClick('upcoming')"
 						/>
 						<a
 							class="button is-info"
 							href="#"
-							@click="addChange('upcoming')"
+							@click="addChangeClick('upcoming')"
 							>Add</a
 						>
 					</p>
 					<span
-						v-for="(upcoming, index) in $parent.editing.upcoming"
+						v-for="(upcoming, index) in editing.upcoming"
 						class="tag is-info"
 						:key="index"
 					>
 						{{ upcoming }}
 						<button
 							class="delete is-info"
-							@click="removeChange('upcoming', index)"
+							@click="removeChangeClick('upcoming', index)"
 						/>
 					</span>
 				</div>
 			</div>
 		</div>
 		<div slot="footer">
-			<button
-				class="button is-success"
-				@click="$parent.updateNews(false)"
-			>
+			<button class="button is-success" @click="updateNews(false)">
 				<i class="material-icons save-changes">done</i>
 				<span>&nbsp;Save</span>
 			</button>
-			<button class="button is-success" @click="$parent.updateNews(true)">
+			<button class="button is-success" @click="updateNews(true)">
 				<i class="material-icons save-changes">done</i>
 				<span>&nbsp;Save and close</span>
 			</button>
@@ -171,36 +167,67 @@
 </template>
 
 <script>
-import { mapActions } from "vuex";
+import { mapActions, mapState } from "vuex";
 
 import { Toast } from "vue-roaster";
+import io from "../../io";
 
 import Modal from "./Modal.vue";
 
 export default {
 	components: { Modal },
+	computed: {
+		...mapState("admin/news", {
+			editing: state => state.editing
+		})
+	},
 	methods: {
 		addChange(type) {
 			const change = document.getElementById(`edit-${type}`).value.trim();
 
-			if (this.$parent.editing[type].indexOf(change) !== -1)
+			if (this.editing[type].indexOf(change) !== -1)
 				return Toast.methods.addToast(`Tag already exists`, 3000);
 
-			if (change) this.$parent.editing[type].push(change);
+			if (change) this.addChange({ type, change });
 			else Toast.methods.addToast(`${type} cannot be empty`, 3000);
 
 			document.getElementById(`edit-${type}`).value = "";
 			return true;
 		},
 		removeChange(type, index) {
-			this.$parent.editing[type].splice(index, 1);
+			this.removeChange({ type, index });
 		},
-		...mapActions("modals", ["closeModal"])
+		updateNews(close) {
+			this.socket.emit(
+				"news.update",
+				this.editing._id,
+				this.editing,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === "success") {
+						if (close)
+							this.closeModal({
+								sector: "admin",
+								modal: "editNews"
+							});
+					}
+				}
+			);
+		},
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("admin/news", ["addChange", "removeChange"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 input[type="range"] {
 	-webkit-appearance: none;
 	width: 100%;
@@ -216,7 +243,7 @@ input[type="range"]::-webkit-slider-runnable-track {
 	height: 5.2px;
 	cursor: pointer;
 	box-shadow: 0;
-	background: #c2c0c2;
+	background: $light-grey-2;
 	border-radius: 0;
 	border: 0;
 }
@@ -227,7 +254,7 @@ input[type="range"]::-webkit-slider-thumb {
 	height: 19px;
 	width: 19px;
 	border-radius: 15px;
-	background: #03a9f4;
+	background: $primary-color;
 	cursor: pointer;
 	-webkit-appearance: none;
 	margin-top: -6.5px;
@@ -238,7 +265,7 @@ input[type="range"]::-moz-range-track {
 	height: 5.2px;
 	cursor: pointer;
 	box-shadow: 0;
-	background: #c2c0c2;
+	background: $light-grey-2;
 	border-radius: 0;
 	border: 0;
 }
@@ -249,7 +276,7 @@ input[type="range"]::-moz-range-thumb {
 	height: 19px;
 	width: 19px;
 	border-radius: 15px;
-	background: #03a9f4;
+	background: $primary-color;
 	cursor: pointer;
 	-webkit-appearance: none;
 	margin-top: -6.5px;
@@ -260,19 +287,19 @@ input[type="range"]::-ms-track {
 	height: 5.2px;
 	cursor: pointer;
 	box-shadow: 0;
-	background: #c2c0c2;
+	background: $light-grey-2;
 	border-radius: 1.3px;
 }
 
 input[type="range"]::-ms-fill-lower {
-	background: #c2c0c2;
+	background: $light-grey-2;
 	border: 0;
 	border-radius: 0;
 	box-shadow: 0;
 }
 
 input[type="range"]::-ms-fill-upper {
-	background: #c2c0c2;
+	background: $light-grey-2;
 	border: 0;
 	border-radius: 0;
 	box-shadow: 0;
@@ -284,7 +311,7 @@ input[type="range"]::-ms-thumb {
 	height: 15px;
 	width: 15px;
 	border-radius: 15px;
-	background: #03a9f4;
+	background: $primary-color;
 	cursor: pointer;
 	-webkit-appearance: none;
 	margin-top: 1.5px;
@@ -339,7 +366,7 @@ h5 {
 }
 
 .save-changes {
-	color: #fff;
+	color: $white;
 }
 
 .tag:not(:last-child) {

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 744 - 267
frontend/components/Modals/EditSong.vue


+ 26 - 27
frontend/components/Modals/EditStation.vue

@@ -105,9 +105,8 @@ export default {
 		editing: state => state.editing
 	}),
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 			return socket;
 		});
 	},
@@ -197,8 +196,6 @@ export default {
 			);
 		},
 		updateDescription() {
-			const _this = this;
-
 			const { description } = this.editing;
 			if (!validation.isLength(description, 2, 200))
 				return Toast.methods.addToast(
@@ -223,14 +220,14 @@ export default {
 				description,
 				res => {
 					if (res.status === "success") {
-						if (_this.station) {
-							_this.station.description = description;
+						if (this.station) {
+							this.station.description = description;
 							return description;
 						}
 
-						_this.$parent.stations.forEach((station, index) => {
+						this.$parent.stations.forEach((station, index) => {
 							if (station._id === this.editing._id) {
-								_this.$parent.stations[
+								this.$parent.stations[
 									index
 								].description = description;
 								return description;
@@ -247,23 +244,23 @@ export default {
 			);
 		},
 		updatePrivacy() {
-			const _this = this;
 			return this.socket.emit(
 				"stations.updatePrivacy",
 				this.editing._id,
 				this.editing.privacy,
 				res => {
 					if (res.status === "success") {
-						if (_this.station) {
-							_this.station.privacy = _this.editing.privacy;
-							return _this.editing.privacy;
+						if (this.station) {
+							this.station.privacy = this.editing.privacy;
+							return this.editing.privacy;
 						}
 
-						_this.$parent.stations.forEach((station, index) => {
-							if (station._id === _this.editing._id) {
-								_this.$parent.stations[index].privacy =
-									_this.editing.privacy;
-								return _this.editing.privacy;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].privacy = this.editing.privacy;
+								return this.editing.privacy;
 							}
 
 							return false;
@@ -277,23 +274,23 @@ export default {
 			);
 		},
 		updatePartyMode() {
-			const _this = this;
 			return this.socket.emit(
 				"stations.updatePartyMode",
 				this.editing._id,
 				this.editing.partyMode,
 				res => {
 					if (res.status === "success") {
-						if (_this.station) {
-							_this.station.partyMode = _this.editing.partyMode;
-							return _this.editing.partyMode;
+						if (this.station) {
+							this.station.partyMode = this.editing.partyMode;
+							return this.editing.partyMode;
 						}
 
-						_this.$parent.stations.forEach((station, index) => {
-							if (station._id === _this.editing._id) {
-								_this.$parent.stations[index].partyMode =
-									_this.editing.partyMode;
-								return _this.editing.partyMode;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].partyMode = this.editing.partyMode;
+								return this.editing.partyMode;
 							}
 
 							return false;
@@ -317,6 +314,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .controls {
 	display: flex;
 
@@ -341,6 +340,6 @@ h5 {
 }
 
 .select:after {
-	border-color: #029ce3;
+	border-color: $primary-color;
 }
 </style>

+ 9 - 5
frontend/components/Modals/EditUser.vue

@@ -109,6 +109,9 @@ export default {
 	computed: {
 		...mapState("admin/users", {
 			editing: state => state.editing
+		}),
+		...mapState({
+			userId: state => state.user.auth.userId
 		})
 	},
 	methods: {
@@ -166,7 +169,7 @@ export default {
 					if (
 						res.status === "success" &&
 						this.editing.role === "default" &&
-						this.editing._id === this.$parent.$parent.$parent.userId
+						this.editing._id === this.userId
 					)
 						window.location.reload();
 				}
@@ -203,9 +206,8 @@ export default {
 		...mapActions("modals", ["closeModal"])
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 			return socket;
 		});
 	}
@@ -213,8 +215,10 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .save-changes {
-	color: #fff;
+	color: $white;
 }
 
 .tag:not(:last-child) {
@@ -222,6 +226,6 @@ export default {
 }
 
 .select:after {
-	border-color: #029ce3;
+	border-color: $primary-color;
 }
 </style>

+ 2 - 0
frontend/components/Modals/IssuesModal.vue

@@ -96,6 +96,8 @@ export default {
 </script>
 
 <style lang="scss">
+@import "styles/global.scss";
+
 .back-to-song {
 	display: flex;
 	margin-bottom: 20px;

+ 15 - 7
frontend/components/Modals/Login.vue

@@ -61,7 +61,7 @@
 				>
 				<a
 					class="button is-github"
-					:href="$parent.serverDomain + '/auth/github/authorize'"
+					:href="serverDomain + '/auth/github/authorize'"
 					@click="githubRedirect()"
 				>
 					<div class="icon">
@@ -86,7 +86,8 @@ export default {
 	data() {
 		return {
 			email: "",
-			password: ""
+			password: "",
+			serverDomain: ""
 		};
 	},
 	methods: {
@@ -109,21 +110,28 @@ export default {
 		},
 		...mapActions("modals", ["closeModal"]),
 		...mapActions("user/auth", ["login"])
+	},
+	mounted() {
+		lofig.get("serverDomain", res => {
+			this.serverDomain = res;
+		});
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .button.is-github {
-	background-color: #333;
-	color: #fff !important;
+	background-color: $dark-grey-2;
+	color: $white !important;
 }
 
 .is-github:focus {
-	background-color: #1a1a1a;
+	background-color: $dark-grey-3;
 }
 .is-primary:focus {
-	background-color: #029ce3 !important;
+	background-color: $primary-color !important;
 }
 
 .invert {
@@ -131,6 +139,6 @@ export default {
 }
 
 a {
-	color: #029ce3;
+	color: $primary-color;
 }
 </style>

+ 4 - 3
frontend/components/Modals/MobileAlert.vue

@@ -30,9 +30,8 @@ export default {
 	},
 	methods: {
 		toggleModal() {
-			const _this = this;
-			_this.isModalActive = !_this.isModalActive;
-			if (_this.isModalActive) {
+			this.isModalActive = !this.isModalActive;
+			if (this.isModalActive) {
 				setTimeout(() => {
 					this.isModalActive = false;
 				}, 4000);
@@ -48,6 +47,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 @media (min-width: 735px) {
 	.modal {
 		display: none;

+ 5 - 6
frontend/components/Modals/Playlists/Create.vue

@@ -34,16 +34,13 @@ export default {
 		return {
 			playlist: {
 				displayName: null,
-				songs: [],
-				createdBy: this.$parent.$parent.username,
-				createdAt: Date.now()
+				songs: []
 			}
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 		});
 	},
 	methods: {
@@ -83,6 +80,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .menu {
 	padding: 0 20px;
 }
@@ -93,7 +92,7 @@ export default {
 }
 
 .menu-list a:hover {
-	color: #000 !important;
+	color: $black !important;
 }
 
 li a {

+ 68 - 76
frontend/components/Modals/Playlists/Edit.vue

@@ -135,7 +135,7 @@
 </template>
 
 <script>
-import { mapState } from "vuex";
+import { mapState, mapActions } from "vuex";
 
 import { Toast } from "vue-roaster";
 import Modal from "../Modal.vue";
@@ -156,91 +156,88 @@ export default {
 		editing: state => state.editing
 	}),
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("playlists.getPlaylist", _this.editing, res => {
-				if (res.status === "success") _this.playlist = res.data;
-				_this.playlist.oldId = res.data._id;
+			this.socket = socket;
+			this.socket.emit("playlists.getPlaylist", this.editing, res => {
+				if (res.status === "success") this.playlist = res.data;
+				this.playlist.oldId = res.data._id;
 			});
-			_this.socket.on("event:playlist.addSong", data => {
-				if (_this.playlist._id === data.playlistId)
-					_this.playlist.songs.push(data.song);
+			this.socket.on("event:playlist.addSong", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.songs.push(data.song);
 			});
-			_this.socket.on("event:playlist.removeSong", data => {
-				if (_this.playlist._id === data.playlistId) {
-					_this.playlist.songs.forEach((song, index) => {
+			this.socket.on("event:playlist.removeSong", data => {
+				if (this.playlist._id === data.playlistId) {
+					this.playlist.songs.forEach((song, index) => {
 						if (song.songId === data.songId)
-							_this.playlist.songs.splice(index, 1);
+							this.playlist.songs.splice(index, 1);
 					});
 				}
 			});
-			_this.socket.on("event:playlist.updateDisplayName", data => {
-				if (_this.playlist._id === data.playlistId)
-					_this.playlist.displayName = data.displayName;
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.displayName = data.displayName;
 			});
-			_this.socket.on("event:playlist.moveSongToBottom", data => {
-				if (_this.playlist._id === data.playlistId) {
+			this.socket.on("event:playlist.moveSongToBottom", data => {
+				if (this.playlist._id === data.playlistId) {
 					let songIndex;
-					_this.playlist.songs.forEach((song, index) => {
+					this.playlist.songs.forEach((song, index) => {
 						if (song.songId === data.songId) songIndex = index;
 					});
-					const song = _this.playlist.songs.splice(songIndex, 1)[0];
-					_this.playlist.songs.push(song);
+					const song = this.playlist.songs.splice(songIndex, 1)[0];
+					this.playlist.songs.push(song);
 				}
 			});
-			_this.socket.on("event:playlist.moveSongToTop", data => {
-				if (_this.playlist._id === data.playlistId) {
+			this.socket.on("event:playlist.moveSongToTop", data => {
+				if (this.playlist._id === data.playlistId) {
 					let songIndex;
-					_this.playlist.songs.forEach((song, index) => {
+					this.playlist.songs.forEach((song, index) => {
 						if (song.songId === data.songId) songIndex = index;
 					});
-					const song = _this.playlist.songs.splice(songIndex, 1)[0];
-					_this.playlist.songs.unshift(song);
+					const song = this.playlist.songs.splice(songIndex, 1)[0];
+					this.playlist.songs.unshift(song);
 				}
 			});
 		});
 	},
 	methods: {
-		formatTime(length) {
-			const duration = moment.duration(length, "seconds");
-			const getHours = () => {
-				return Math.floor(duration.asHours());
-			};
-
-			if (length <= 0) return "0 seconds";
+		formatTime(duration) {
+			if (duration <= 0) return "0 seconds";
 
+			const hours = Math.floor(duration / (60 * 60));
 			const formatHours = () => {
-				if (getHours() > 0) {
-					if (getHours() > 1) {
-						if (getHours() < 10) return `0${getHours()} hours `;
-						return `${getHours()} hours `;
+				if (hours > 0) {
+					if (hours > 1) {
+						if (hours < 10) return `0${hours} hours `;
+						return `${hours} hours `;
 					}
-					return `0${getHours()} hour `;
+					return `0${hours} hour `;
 				}
 				return "";
 			};
 
+			const minutes = Math.floor((duration - hours) / 60);
 			const formatMinutes = () => {
-				if (duration.minutes() > 0) {
-					if (duration.minutes() > 1) {
-						if (duration.minutes() < 10)
-							return `0${duration.minutes()} minutes `;
-						return `${duration.minutes()} minutes `;
+				if (minutes > 0) {
+					if (minutes > 1) {
+						if (minutes < 10) return `0${minutes} minutes `;
+						return `${minutes} minutes `;
 					}
-					return `0${duration.minutes()} minute `;
+					return `0${minutes} minute `;
 				}
 				return "";
 			};
 
+			const seconds = Math.floor(
+				duration - hours * 60 * 60 - minutes * 60
+			);
 			const formatSeconds = () => {
-				if (duration.seconds() > 0) {
-					if (duration.seconds() > 1) {
-						if (duration.seconds() < 10)
-							return `0${duration.seconds()} seconds `;
-						return `${duration.seconds()} seconds `;
+				if (seconds > 0) {
+					if (seconds > 1) {
+						if (seconds < 10) return `0${seconds} seconds `;
+						return `${seconds} seconds `;
 					}
-					return `0${duration.seconds()} second `;
+					return `0${seconds} second `;
 				}
 				return "";
 			};
@@ -255,8 +252,7 @@ export default {
 			return this.formatTime(length);
 		},
 		searchForSongs() {
-			const _this = this;
-			let query = _this.songQuery;
+			let query = this.songQuery;
 			if (query.indexOf("&index=") !== -1) {
 				query = query.split("&index=");
 				query.pop();
@@ -267,11 +263,11 @@ export default {
 				query.pop();
 				query = query.join("");
 			}
-			_this.socket.emit("apis.searchYoutube", query, res => {
+			this.socket.emit("apis.searchYoutube", query, res => {
 				if (res.status === "success") {
-					_this.songQueryResults = [];
+					this.songQueryResults = [];
 					for (let i = 0; i < res.data.items.length; i += 1) {
-						_this.songQueryResults.push({
+						this.songQueryResults.push({
 							id: res.data.items[i].id.videoId,
 							url: `https://www.youtube.com/watch?v=${this.id}`,
 							title: res.data.items[i].snippet.title,
@@ -284,39 +280,36 @@ export default {
 			});
 		},
 		addSongToPlaylist(id) {
-			const _this = this;
-			_this.socket.emit(
+			this.socket.emit(
 				"playlists.addSongToPlaylist",
 				id,
-				_this.playlist._id,
+				this.playlist._id,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
 				}
 			);
 		},
 		importPlaylist() {
-			const _this = this;
 			Toast.methods.addToast(
 				"Starting to import your playlist. This can take some time to do.",
 				4000
 			);
 			this.socket.emit(
 				"playlists.addSetToPlaylist",
-				_this.importQuery,
-				_this.playlist._id,
+				this.importQuery,
+				this.playlist._id,
 				res => {
 					if (res.status === "success")
-						_this.playlist.songs = res.data;
+						this.playlist.songs = res.data;
 					Toast.methods.addToast(res.message, 4000);
 				}
 			);
 		},
 		removeSongFromPlaylist(id) {
-			const _this = this;
 			this.socket.emit(
 				"playlists.removeSongFromPlaylist",
 				id,
-				_this.playlist._id,
+				this.playlist._id,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
 				}
@@ -345,20 +338,17 @@ export default {
 			);
 		},
 		removePlaylist() {
-			const _this = this;
-			_this.socket.emit("playlists.remove", _this.playlist._id, res => {
+			this.socket.emit("playlists.remove", this.playlist._id, res => {
 				Toast.methods.addToast(res.message, 3000);
 				if (res.status === "success") {
-					_this.$parent.modals.editPlaylist = !_this.$parent.modals
-						.editPlaylist;
+					this.closeModal();
 				}
 			});
 		},
 		promoteSong(songId) {
-			const _this = this;
-			_this.socket.emit(
+			this.socket.emit(
 				"playlists.moveSongToTop",
-				_this.playlist._id,
+				this.playlist._id,
 				songId,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
@@ -366,21 +356,23 @@ export default {
 			);
 		},
 		demoteSong(songId) {
-			const _this = this;
-			_this.socket.emit(
+			this.socket.emit(
 				"playlists.moveSongToBottom",
-				_this.playlist._id,
+				this.playlist._id,
 				songId,
 				res => {
 					Toast.methods.addToast(res.message, 4000);
 				}
 			);
-		}
+		},
+		...mapActions("modals", ["closeModal"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .menu {
 	padding: 0 20px;
 }
@@ -391,7 +383,7 @@ export default {
 }
 
 .menu-list a:hover {
-	color: #000 !important;
+	color: $black !important;
 }
 
 li a {

+ 23 - 9
frontend/components/Modals/Register.vue

@@ -68,7 +68,7 @@
 				>
 				<a
 					class="button is-github"
-					:href="$parent.serverDomain + '/auth/github/authorize'"
+					:href="serverDomain + '/auth/github/authorize'"
 					@click="githubRedirect()"
 				>
 					<div class="icon">
@@ -95,13 +95,17 @@ export default {
 			recaptcha: {
 				key: "",
 				token: ""
-			}
+			},
+			serverDomain: ""
 		};
 	},
 	mounted() {
-		const _this = this;
+		lofig.get("serverDomain", res => {
+			this.serverDomain = res;
+		});
+
 		lofig.get("recaptcha", obj => {
-			_this.recaptcha.key = obj.key;
+			this.recaptcha.key = obj.key;
 
 			const recaptchaScript = document.createElement("script");
 			recaptchaScript.onload = () => {
@@ -109,7 +113,7 @@ export default {
 					grecaptcha
 						.execute(this.recaptcha.key, { action: "login" })
 						.then(token => {
-							_this.recaptcha.token = token;
+							this.recaptcha.token = token;
 						});
 				});
 			};
@@ -146,13 +150,15 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .button.is-github {
-	background-color: #333;
-	color: #fff !important;
+	background-color: $dark-grey-2;
+	color: $white !important;
 }
 
 .is-github:focus {
-	background-color: #1a1a1a;
+	background-color: $dark-grey-3;
 }
 .is-primary:focus {
 	background-color: #028bca !important;
@@ -167,6 +173,14 @@ export default {
 }
 
 a {
-	color: #029ce3;
+	color: $primary-color;
+}
+</style>
+
+<style lang="scss">
+@import "styles/global.scss";
+
+.grecaptcha-badge {
+	z-index: 2000;
 }
 </style>

+ 24 - 27
frontend/components/Modals/Report.vue

@@ -2,10 +2,7 @@
 	<modal title="Report">
 		<div slot="body">
 			<div class="columns song-types">
-				<div
-					v-if="$parent.previousSong !== null"
-					class="column song-type"
-				>
+				<div v-if="previousSong !== null" class="column song-type">
 					<div
 						class="card is-fullwidth"
 						:class="{ 'is-highlight-active': isPreviousSongActive }"
@@ -21,9 +18,7 @@
 								<figure class="media-left">
 									<p class="image is-64x64">
 										<img
-											:src="
-												$parent.previousSong.thumbnail
-											"
+											:src="previousSong.thumbnail"
 											onerror='this.src="/assets/notes-transparent.png"'
 										/>
 									</p>
@@ -32,13 +27,11 @@
 									<div class="content">
 										<p>
 											<strong>{{
-												$parent.previousSong.title
+												previousSong.title
 											}}</strong>
 											<br />
 											<small>{{
-												$parent.previousSong.artists.split(
-													" ,"
-												)
+												previousSong.artists.split(" ,")
 											}}</small>
 										</p>
 									</div>
@@ -52,7 +45,7 @@
 						/>
 					</div>
 				</div>
-				<div v-if="$parent.currentSong !== {}" class="column song-type">
+				<div v-if="currentSong !== {}" class="column song-type">
 					<div
 						class="card is-fullwidth"
 						:class="{ 'is-highlight-active': isCurrentSongActive }"
@@ -68,7 +61,7 @@
 								<figure class="media-left">
 									<p class="image is-64x64">
 										<img
-											:src="$parent.currentSong.thumbnail"
+											:src="currentSong.thumbnail"
 											onerror='this.src="/assets/notes-transparent.png"'
 										/>
 									</p>
@@ -77,13 +70,11 @@
 									<div class="content">
 										<p>
 											<strong>{{
-												$parent.currentSong.title
+												currentSong.title
 											}}</strong>
 											<br />
 											<small>{{
-												$parent.currentSong.artists.split(
-													" ,"
-												)
+												currentSong.artists.split(" ,")
 											}}</small>
 										</p>
 									</div>
@@ -158,7 +149,7 @@
 </template>
 
 <script>
-import { mapActions } from "vuex";
+import { mapState, mapActions } from "vuex";
 
 import { Toast } from "vue-roaster";
 import Modal from "./Modal.vue";
@@ -173,7 +164,7 @@ export default {
 			isCurrentSongActive: true,
 			report: {
 				resolved: false,
-				songId: this.$parent.currentSong.songId,
+				songId: "",
 				description: "",
 				issues: [
 					{ name: "Video", reasons: [] },
@@ -216,20 +207,24 @@ export default {
 			]
 		};
 	},
+	computed: mapState({
+		currentSong: state => state.station.currentSong,
+		previousSong: state => state.station.previousSong
+	}),
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 		});
+
+		this.report.songId = this.currentSong.songId;
 	},
 	methods: {
 		create() {
-			const _this = this;
 			console.log(this.report);
-			_this.socket.emit("reports.create", _this.report, res => {
+			this.socket.emit("reports.create", this.report, res => {
 				Toast.methods.addToast(res.message, 4000);
 				if (res.status === "success")
-					_this.closeModal({
+					this.closeModal({
 						sector: "station",
 						modal: "report"
 					});
@@ -241,11 +236,11 @@ export default {
 		},
 		highlight(type) {
 			if (type === "currentSong") {
-				this.report.songId = this.$parent.currentSong.songId;
+				this.report.songId = this.currentSong.songId;
 				this.isPreviousSongActive = false;
 				this.isCurrentSongActive = true;
 			} else if (type === "previousSong") {
-				this.report.songId = this.$parent.previousSong.songId;
+				this.report.songId = this.previousSong.songId;
 				this.isCurrentSongActive = false;
 				this.isPreviousSongActive = true;
 			}
@@ -268,6 +263,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 h6 {
 	margin-bottom: 15px;
 }
@@ -305,6 +302,6 @@ h6 {
 }
 
 .is-highlight-active {
-	border: 3px #03a9f4 solid;
+	border: 3px $primary-color solid;
 }
 </style>

+ 33 - 13
frontend/components/Modals/ViewPunishment.vue

@@ -18,22 +18,39 @@
 						<br />
 						<strong>Expires at:</strong>
 						{{
-							moment(punishment.expiresAt).format(
-								"MMMM Do YYYY, h:mm:ss a"
+							format(
+								parseISO(punishment.expiresAt),
+								"MMMM do yyyy, h:mm:ss a"
 							)
 						}}
-						({{ moment(punishment.expiresAt).fromNow() }})
+						({{
+							formatDistance(
+								parseISO(punishment.expiresAt),
+								new Date(),
+								{ addSuffix: true }
+							)
+						}})
 						<br />
 						<strong>Punished at:</strong>
 						{{
-							moment(punishment.punishedAt).format(
-								"MMMM Do YYYY, h:mm:ss a"
+							format(
+								parseISO(punishment.punishedAt),
+								"MMMM do yyyy, h:mm:ss a"
 							)
 						}}
-						({{ moment(punishment.punishedAt).fromNow() }})
+						({{
+							formatDistance(
+								parseISO(punishment.punishedAt),
+								new Date(),
+								{ addSuffix: true }
+							)
+						}})
 						<br />
 						<strong>Punished by:</strong>
-						{{ punishment.punishedBy }}
+						<user-id-to-username
+							:userId="punishment.punishedBy"
+							:alt="punishment.punishedBy"
+						/>
 						<br />
 					</div>
 				</article>
@@ -57,16 +74,17 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
+import { format, formatDistance, parseISO } from "date-fns"; // eslint-disable-line no-unused-vars
 
 import io from "../../io";
 import Modal from "./Modal.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
 
 export default {
-	components: { Modal },
+	components: { Modal, UserIdToUsername },
 	data() {
 		return {
-			ban: {},
-			moment
+			ban: {}
 		};
 	},
 	computed: {
@@ -75,14 +93,16 @@ export default {
 		})
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 			return socket;
 		});
 	},
 	methods: {
-		...mapActions("modals", ["closeModal"])
+		...mapActions("modals", ["closeModal"]),
+		format,
+		formatDistance,
+		parseISO
 	}
 };
 </script>

+ 10 - 7
frontend/components/Modals/WhatIsNew.vue

@@ -72,6 +72,8 @@
 </template>
 
 <script>
+import { format } from "date-fns";
+
 import io from "../../io";
 
 export default {
@@ -82,12 +84,11 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(true, socket => {
-			_this.socket = socket;
-			_this.socket.emit("news.newest", res => {
-				_this.news = res.data;
-				if (_this.news && localStorage.getItem("firstVisited")) {
+			this.socket = socket;
+			this.socket.emit("news.newest", res => {
+				this.news = res.data;
+				if (this.news && localStorage.getItem("firstVisited")) {
 					if (localStorage.getItem("whatIsNew")) {
 						if (
 							parseInt(localStorage.getItem("whatIsNew")) <
@@ -118,7 +119,7 @@ export default {
 			this.isModalActive = !this.isModalActive;
 		},
 		formatDate: unix => {
-			return moment(unix).format("DD-MM-YYYY");
+			return format(unix, "dd-MM-yyyy");
 		}
 	},
 	events: {
@@ -130,6 +131,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .modal-card-head {
 	border-bottom: none;
 	background-color: ghostwhite;
@@ -158,7 +161,7 @@ export default {
 		padding: 12px;
 		text-transform: uppercase;
 		font-weight: bold;
-		color: #fff;
+		color: $white;
 	}
 
 	.sect-head-features {

+ 37 - 32
frontend/components/Sidebars/Playlist.vue

@@ -14,7 +14,7 @@
 							<a
 								v-if="
 									isNotSelected(playlist._id) &&
-										!$parent.station.partyMode
+										!station.partyMode
 								"
 								href="#"
 								@click="selectPlaylist(playlist._id)"
@@ -46,7 +46,7 @@
 </template>
 
 <script>
-import { mapActions } from "vuex";
+import { mapState, mapActions } from "vuex";
 
 import { Toast } from "vue-roaster";
 import io from "../../io";
@@ -57,6 +57,14 @@ export default {
 			playlists: []
 		};
 	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		}),
+		...mapState({
+			station: state => state.station.station
+		})
+	},
 	methods: {
 		edit(id) {
 			this.editPlaylist(id);
@@ -65,7 +73,7 @@ export default {
 		selectPlaylist(id) {
 			this.socket.emit(
 				"stations.selectPrivatePlaylist",
-				this.$parent.station._id,
+				this.station._id,
 				id,
 				res => {
 					if (res.status === "failure")
@@ -75,12 +83,8 @@ export default {
 			);
 		},
 		isNotSelected(id) {
-			const _this = this;
 			// TODO Also change this once it changes for a station
-			if (
-				_this.$parent.station &&
-				_this.$parent.station.privatePlaylist === id
-			)
+			if (this.station && this.station.privatePlaylist === id)
 				return false;
 			return true;
 		},
@@ -89,44 +93,43 @@ export default {
 	},
 	mounted() {
 		// TODO: Update when playlist is removed/created
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("playlists.indexForUser", res => {
-				if (res.status === "success") _this.playlists = res.data;
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") this.playlists = res.data;
 			});
-			_this.socket.on("event:playlist.create", playlist => {
-				_this.playlists.push(playlist);
+			this.socket.on("event:playlist.create", playlist => {
+				this.playlists.push(playlist);
 			});
-			_this.socket.on("event:playlist.delete", playlistId => {
-				_this.playlists.forEach((playlist, index) => {
+			this.socket.on("event:playlist.delete", playlistId => {
+				this.playlists.forEach((playlist, index) => {
 					if (playlist._id === playlistId) {
-						_this.playlists.splice(index, 1);
+						this.playlists.splice(index, 1);
 					}
 				});
 			});
-			_this.socket.on("event:playlist.addSong", data => {
-				_this.playlists.forEach((playlist, index) => {
+			this.socket.on("event:playlist.addSong", data => {
+				this.playlists.forEach((playlist, index) => {
 					if (playlist._id === data.playlistId) {
-						_this.playlists[index].songs.push(data.song);
+						this.playlists[index].songs.push(data.song);
 					}
 				});
 			});
-			_this.socket.on("event:playlist.removeSong", data => {
-				_this.playlists.forEach((playlist, index) => {
+			this.socket.on("event:playlist.removeSong", data => {
+				this.playlists.forEach((playlist, index) => {
 					if (playlist._id === data.playlistId) {
-						_this.playlists[index].songs.forEach((song, index2) => {
+						this.playlists[index].songs.forEach((song, index2) => {
 							if (song._id === data.songId) {
-								_this.playlists[index].songs.splice(index2, 1);
+								this.playlists[index].songs.splice(index2, 1);
 							}
 						});
 					}
 				});
 			});
-			_this.socket.on("event:playlist.updateDisplayName", data => {
-				_this.playlists.forEach((playlist, index) => {
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				this.playlists.forEach((playlist, index) => {
 					if (playlist._id === data.playlistId) {
-						_this.playlists[index].displayName = data.displayName;
+						this.playlists[index].displayName = data.displayName;
 					}
 				});
 			});
@@ -136,6 +139,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .sidebar {
 	position: fixed;
 	z-index: 1;
@@ -143,7 +148,7 @@ export default {
 	right: 0;
 	width: 300px;
 	height: 100vh;
-	background-color: #fff;
+	background-color: $white;
 	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
 		0 2px 10px 0 rgba(0, 0, 0, 0.12);
 }
@@ -158,7 +163,7 @@ export default {
 }
 
 .inner-wrapper {
-	top: 64px;
+	top: 60px;
 	position: relative;
 }
 
@@ -176,7 +181,7 @@ export default {
 	background-color: rgb(3, 169, 244);
 	text-align: center;
 	padding: 10px;
-	color: white;
+	color: $white;
 	font-weight: 600;
 }
 
@@ -186,7 +191,7 @@ export default {
 	height: 40px;
 	border-radius: 0;
 	background: rgba(3, 169, 244, 1);
-	color: #fff !important;
+	color: $white !important;
 	border: 0;
 
 	&:active,
@@ -196,7 +201,7 @@ export default {
 }
 
 .create-playlist:focus {
-	background: #029ce3;
+	background: $primary-color;
 }
 
 .none-found {

+ 42 - 40
frontend/components/Sidebars/SongsList.vue

@@ -1,18 +1,18 @@
 <template>
 	<div class="sidebar" transition="slide">
 		<div class="inner-wrapper">
-			<div v-if="$parent.type === 'community'" class="title">
+			<div v-if="station.type === 'community'" class="title">
 				Queue
 			</div>
 			<div v-else class="title">
 				Playlist
 			</div>
 
-			<article v-if="!$parent.noSong" class="media">
-				<figure v-if="$parent.currentSong.thumbnail" class="media-left">
+			<article v-if="!noSong" class="media">
+				<figure v-if="currentSong.thumbnail" class="media-left">
 					<p class="image is-64x64">
 						<img
-							:src="$parent.currentSong.thumbnail"
+							:src="currentSong.thumbnail"
 							onerror="this.src='/assets/notes-transparent.png'"
 						/>
 					</p>
@@ -21,22 +21,23 @@
 					<div class="content">
 						<p>
 							Current Song:
-							<strong>{{ $parent.currentSong.title }}</strong>
+							<strong>{{ currentSong.title }}</strong>
 							<br />
-							<small>{{ $parent.currentSong.artists }}</small>
+							<small>{{ currentSong.artists }}</small>
 						</p>
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.formatTime($parent.currentSong.duration) }}
+					{{ $parent.formatTime(currentSong.duration) }}
 				</div>
 			</article>
-			<p v-if="$parent.noSong" class="center">
+			<p v-if="noSong" class="center">
 				There is currently no song playing.
 			</p>
 
 			<article
-				v-for="(song, index) in $parent.songsList"
+				v-else
+				v-for="(song, index) in songsList"
 				:key="index"
 				class="media"
 			>
@@ -49,8 +50,8 @@
 						<small>{{ song.artists.join(", ") }}</small>
 						<div
 							v-if="
-								$parent.type === 'community' &&
-									$parent.station.partyMode === true
+								station.type === 'community' &&
+									station.partyMode === true
 							"
 						>
 							<small>
@@ -78,16 +79,16 @@
 			</article>
 			<div
 				v-if="
-					$parent.type === 'community' &&
-						$parent.$parent.loggedIn &&
-						$parent.station.partyMode === true
+					station.type === 'community' &&
+						loggedIn &&
+						station.partyMode === true
 				"
 			>
 				<button
 					v-if="
-						($parent.station.locked && isOwnerOnly()) ||
-							!$parent.station.locked ||
-							($parent.station.locked &&
+						(station.locked && isOwnerOnly()) ||
+							!station.locked ||
+							(station.locked &&
 								isAdminOnly() &&
 								dismissedWarning)
 					"
@@ -103,7 +104,7 @@
 				</button>
 				<button
 					v-if="
-						$parent.station.locked &&
+						station.locked &&
 							isAdminOnly() &&
 							!isOwnerOnly() &&
 							!dismissedWarning
@@ -114,11 +115,7 @@
 					THIS STATION'S QUEUE IS LOCKED.
 				</button>
 				<button
-					v-if="
-						$parent.station.locked &&
-							!isAdminOnly() &&
-							!isOwnerOnly()
-					"
+					v-if="station.locked && !isAdminOnly() && !isOwnerOnly()"
 					class="button add-to-queue add-to-queue-disabled"
 				>
 					THIS STATION'S QUEUE IS LOCKED.
@@ -129,7 +126,7 @@
 </template>
 
 <script>
-import { mapActions } from "vuex";
+import { mapState, mapActions } from "vuex";
 
 import { Toast } from "vue-roaster";
 
@@ -141,23 +138,26 @@ export default {
 			dismissedWarning: false
 		};
 	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		userId: state => state.user.auth.userId,
+		role: state => state.user.auth.role,
+		station: state => state.station.station,
+		currentSong: state => state.station.currentSong,
+		songsList: state => state.station.songsList,
+		noSong: state => state.station.noSong
+	}),
 	methods: {
 		isOwnerOnly() {
-			return (
-				this.$parent.$parent.loggedIn &&
-				this.$parent.$parent.userId === this.$parent.station.owner
-			);
+			return this.loggedIn && this.userId === this.station.owner;
 		},
 		isAdminOnly() {
-			return (
-				this.$parent.$parent.loggedIn &&
-				this.$parent.$parent.role === "admin"
-			);
+			return this.loggedIn && this.role === "admin";
 		},
 		removeFromQueue(songId) {
 			window.socket.emit(
 				"stations.removeFromQueue",
-				this.$parent.station._id,
+				this.station._id,
 				songId,
 				res => {
 					if (res.status === "success") {
@@ -172,9 +172,9 @@ export default {
 		...mapActions("modals", ["openModal"])
 	},
 	mounted() {
-		/* let _this = this;
+		/*
 			io.getSocket((socket) => {
-				_this.socket = socket;
+				this.socket = socket;
 
 			}); */
 	},
@@ -183,6 +183,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .sidebar {
 	position: fixed;
 	z-index: 1;
@@ -190,13 +192,13 @@ export default {
 	right: 0;
 	width: 300px;
 	height: 100vh;
-	background-color: #fff;
+	background-color: $white;
 	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
 		0 2px 10px 0 rgba(0, 0, 0, 0.12);
 }
 
 .inner-wrapper {
-	top: 64px;
+	top: 60px;
 	position: relative;
 	overflow: auto;
 	height: 100%;
@@ -216,7 +218,7 @@ export default {
 	background-color: rgb(3, 169, 244);
 	text-align: center;
 	padding: 10px;
-	color: white;
+	color: $white;
 	font-weight: 600;
 }
 
@@ -244,7 +246,7 @@ export default {
 	height: 40px;
 	border-radius: 0;
 	background: rgb(3, 169, 244);
-	color: #fff !important;
+	color: $white !important;
 	border: 0;
 	&:active,
 	&:focus {
@@ -264,7 +266,7 @@ export default {
 }
 
 .add-to-queue:focus {
-	background: #029ce3;
+	background: $primary-color;
 }
 
 .media-right {

+ 18 - 5
frontend/components/Sidebars/UsersList.vue

@@ -4,10 +4,10 @@
 			<div class="title">
 				Users
 			</div>
-			<h5 class="center">Total users: {{ $parent.userCount }}</h5>
+			<h5 class="center">Total users: {{ userCount }}</h5>
 			<aside class="menu">
 				<ul class="menu-list">
-					<li v-for="(username, index) in $parent.users" :key="index">
+					<li v-for="(username, index) in users" :key="index">
 						<router-link
 							:to="{ name: 'profile', params: { username } }"
 							target="_blank"
@@ -21,7 +21,20 @@
 	</div>
 </template>
 
+<script>
+import { mapState } from "vuex";
+
+export default {
+	computed: mapState({
+		users: state => state.station.users,
+		userCount: state => state.station.userCount
+	})
+};
+</script>
+
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .sidebar {
 	position: fixed;
 	z-index: 1;
@@ -29,13 +42,13 @@
 	right: 0;
 	width: 300px;
 	height: 100vh;
-	background-color: #fff;
+	background-color: $white;
 	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
 		0 2px 10px 0 rgba(0, 0, 0, 0.12);
 }
 
 .inner-wrapper {
-	top: 64px;
+	top: 60px;
 	position: relative;
 }
 
@@ -53,7 +66,7 @@
 	background-color: rgb(3, 169, 244);
 	text-align: center;
 	padding: 10px;
-	color: white;
+	color: $white;
 	font-weight: 600;
 }
 

+ 0 - 461
frontend/components/Station/CommunityHeader.vue

@@ -1,461 +0,0 @@
-<template>
-	<div>
-		<nav class="nav">
-			<div class="nav-left">
-				<router-link
-					class="nav-item is-brand"
-					href="#"
-					:to="{ path: '/' }"
-				>
-					<img
-						:src="`${this.siteSettings.logo}`"
-						:alt="`${this.siteSettings.siteName}` || `Musare`"
-					/>
-				</router-link>
-			</div>
-
-			<div class="nav-center stationDisplayName">
-				{{ $parent.station.displayName }}
-			</div>
-
-			<span class="nav-toggle" v-on:click="controlBar = !controlBar">
-				<span />
-				<span />
-				<span />
-			</span>
-
-			<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
-				<router-link
-					v-if="$parent.$parent.role === 'admin'"
-					class="nav-item is-tab admin"
-					href="#"
-					:to="{ path: '/admin' }"
-				>
-					<strong>Admin</strong>
-				</router-link>
-				<span v-if="$parent.$parent.loggedIn" class="grouped">
-					<router-link
-						class="nav-item is-tab"
-						:to="{ path: '/u/' + $parent.$parent.username }"
-						>Profile</router-link
-					>
-					<router-link class="nav-item is-tab" to="/settings"
-						>Settings</router-link
-					>
-					<a
-						class="nav-item is-tab"
-						href="#"
-						@click="$parent.$parent.logout()"
-						>Logout</a
-					>
-				</span>
-				<span v-else class="grouped">
-					<a
-						class="nav-item"
-						href="#"
-						@click="openModal({ sector: 'header', modal: 'login' })"
-						>Login</a
-					>
-					<a
-						class="nav-item"
-						href="#"
-						@click="
-							openModal({ sector: 'header', modal: 'register' })
-						"
-						>Register</a
-					>
-				</span>
-			</div>
-		</nav>
-		<div class="control-sidebar" :class="{ 'show-controlBar': controlBar }">
-			<div class="inner-wrapper">
-				<div v-if="isOwner()">
-					<a
-						v-if="isOwner()"
-						class="sidebar-item"
-						href="#"
-						@click="settings()"
-					>
-						<span class="icon">
-							<i class="material-icons">settings</i>
-						</span>
-						<span class="icon-purpose">Station settings</span>
-					</a>
-					<a
-						v-if="isOwner()"
-						class="sidebar-item"
-						href="#"
-						@click="$parent.skipStation()"
-					>
-						<span class="icon">
-							<i class="material-icons">skip_next</i>
-						</span>
-						<span class="icon-purpose">Skip current song</span>
-					</a>
-					<a
-						v-if="isOwner() && $parent.paused"
-						class="sidebar-item"
-						href="#"
-						@click="$parent.resumeStation()"
-					>
-						<span class="icon">
-							<i class="material-icons">play_arrow</i>
-						</span>
-						<span class="icon-purpose">Resume station</span>
-					</a>
-					<a
-						v-if="isOwner() && !$parent.paused"
-						class="sidebar-item"
-						href="#"
-						@click="$parent.pauseStation()"
-					>
-						<span class="icon">
-							<i class="material-icons">pause</i>
-						</span>
-						<span class="icon-purpose">Pause station</span>
-					</a>
-					<hr />
-				</div>
-				<div v-if="$parent.$parent.loggedIn && !$parent.noSong">
-					<a
-						v-if="
-							!isOwner() &&
-								$parent.$parent.loggedIn &&
-								!$parent.noSong
-						"
-						class="sidebar-item"
-						href="#"
-						@click="$parent.voteSkipStation()"
-					>
-						<span class="icon">
-							<i class="material-icons">skip_next</i>
-						</span>
-						<span class="skip-votes">{{
-							$parent.currentSong.skipVotes
-						}}</span>
-						<span class="icon-purpose">Skip current song</span>
-					</a>
-					<a
-						v-if="$parent.$parent.loggedIn && !$parent.noSong"
-						class="sidebar-item"
-						href="#"
-						@click="
-							openModal({
-								sector: 'station',
-								modal: 'addSongToPlaylist'
-							})
-						"
-					>
-						<span class="icon">
-							<i class="material-icons">playlist_add</i>
-						</span>
-						<span class="icon-purpose"
-							>Add current song to playlist</span
-						>
-					</a>
-					<hr />
-				</div>
-				<a
-					v-if="$parent.station.partyMode === true"
-					class="sidebar-item"
-					href="#"
-					@click="$parent.toggleSidebar('songslist')"
-				>
-					<span class="icon">
-						<i class="material-icons">queue_music</i>
-					</span>
-					<span class="icon-purpose">Show the station queue</span>
-				</a>
-				<a
-					class="sidebar-item"
-					href="#"
-					@click="$parent.toggleSidebar('users')"
-				>
-					<span class="icon">
-						<i class="material-icons">people</i>
-					</span>
-					<span class="icon-purpose"
-						>Display users in the station</span
-					>
-				</a>
-				<a
-					v-if="$parent.$parent.loggedIn"
-					class="sidebar-item"
-					href="#"
-					@click="$parent.toggleSidebar('playlist')"
-				>
-					<span class="icon">
-						<i class="material-icons">library_music</i>
-					</span>
-					<span class="icon-purpose">Show your playlists</span>
-				</a>
-			</div>
-		</div>
-	</div>
-</template>
-
-<script>
-import { mapActions } from "vuex";
-
-export default {
-	data() {
-		return {
-			title: this.$route.params.id,
-			isMobile: false,
-			controlBar: true,
-			frontendDomain: "",
-			siteSettings: {
-				logo: "",
-				siteName: ""
-			}
-		};
-	},
-	mounted() {
-		lofig.get("frontendDomain", res => {
-			this.frontendDomain = res;
-			return res;
-		});
-		lofig.get("siteSettings", res => {
-			this.siteSettings = res;
-			return res;
-		});
-	},
-	methods: {
-		isOwner() {
-			return (
-				this.$parent.$parent.loggedIn &&
-				(this.$parent.$parent.role === "admin" ||
-					this.$parent.$parent.userId === this.$parent.station.owner)
-			);
-		},
-		settings() {
-			this.editStation({
-				_id: this.$parent.station._id,
-				name: this.$parent.station.name,
-				type: this.$parent.type,
-				partyMode: this.$parent.station.partyMode,
-				description: this.$parent.station.description,
-				privacy: this.$parent.station.privacy,
-				displayName: this.$parent.station.displayName
-			});
-			this.openModal({
-				sector: "station",
-				modal: "editStation"
-			});
-		},
-		...mapActions("modals", ["openModal"]),
-		...mapActions("station", ["editStation"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.nav {
-	background-color: #03a9f4;
-	line-height: 64px;
-	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
-
-	.is-brand {
-		font-size: 2.1rem !important;
-		line-height: 64px !important;
-		padding: 0 20px;
-		color: #ffffff;
-		font-family: Pacifico, cursive;
-		filter: brightness(0) invert(1);
-
-		img {
-			max-height: 38px;
-		}
-	}
-}
-
-a.nav-item {
-	color: #ffffff;
-	font-size: 17px;
-
-	&:hover {
-		color: #ffffff;
-	}
-
-	padding: 0 12px;
-	.icon {
-		height: 64px;
-		i {
-			font-size: 2rem;
-			line-height: 64px;
-			height: 64px;
-			width: 34px;
-		}
-	}
-}
-
-a.nav-item.is-tab:hover {
-	border-bottom: none;
-	border-top: solid 1px #ffffff;
-}
-
-.admin strong {
-	color: #9d42b1;
-}
-
-.grouped {
-	margin: 0;
-	display: flex;
-	text-decoration: none;
-}
-
-.skip-votes {
-	position: relative;
-	left: 11px;
-}
-
-.nav-toggle {
-	height: 64px;
-}
-
-@media screen and (max-width: 998px) {
-	.nav-menu {
-		background-color: white;
-		box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
-		left: 0;
-		display: none;
-		right: 0;
-		top: 100%;
-		position: absolute;
-	}
-	.nav-toggle {
-		display: block;
-	}
-}
-
-.logo {
-	font-size: 2.1rem;
-	line-height: 64px;
-	padding-left: 20px !important;
-	padding-right: 20px !important;
-}
-
-.nav-center {
-	display: flex;
-	align-items: center;
-	color: #03a9f4;
-	font-size: 22px;
-	position: absolute;
-	margin: auto;
-	top: 50%;
-	left: 50%;
-	transform: translate(-50%, -50%);
-}
-
-.nav-right.is-active .nav-item {
-	background: #03a9f4;
-	border: 0;
-}
-
-.control-sidebar {
-	position: fixed;
-	z-index: 1;
-	top: 0;
-	left: 0;
-	width: 64px;
-	height: 100vh;
-	background-color: #03a9f4;
-	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
-		0 2px 10px 0 rgba(0, 0, 0, 0.12);
-
-	@media (max-width: 998px) {
-		display: none;
-	}
-	.inner-wrapper {
-		@media (min-width: 999px) {
-			.mobile-only {
-				display: none;
-			}
-			.desktop-only {
-				display: flex;
-			}
-		}
-		@media (max-width: 998px) {
-			.mobile-only {
-				display: flex;
-			}
-			.desktop-only {
-				display: none;
-				visibility: hidden;
-			}
-		}
-	}
-}
-
-.show-controlBar {
-	display: block;
-}
-
-.inner-wrapper {
-	top: 64px;
-	position: relative;
-}
-
-.control-sidebar .material-icons {
-	width: 100%;
-	font-size: 2rem;
-}
-.control-sidebar .sidebar-item {
-	font-size: 2rem;
-	height: 50px;
-	color: white;
-	-webkit-box-align: center;
-	-ms-flex-align: center;
-	align-items: center;
-	display: -webkit-box;
-	display: -ms-flexbox;
-	display: flex;
-	-webkit-box-flex: 0;
-	-ms-flex-positive: 0;
-	flex-grow: 0;
-	-ms-flex-negative: 0;
-	flex-shrink: 0;
-	-webkit-box-pack: center;
-	-ms-flex-pack: center;
-	justify-content: center;
-	width: 100%;
-	position: relative;
-}
-.control-sidebar .sidebar-top-hr {
-	margin: 0 0 20px 0;
-}
-
-.sidebar-item .icon-purpose {
-	visibility: hidden;
-	width: 160px;
-	font-size: 12px;
-	background-color: rgba(3, 169, 244, 0.8);
-	color: #fff;
-	text-align: center;
-	border-radius: 6px;
-	padding: 5px;
-	position: absolute;
-	z-index: 1;
-	left: 115%;
-	opacity: 0;
-	transition: opacity 0.5s;
-	display: none;
-}
-
-.sidebar-item .icon-purpose::after {
-	content: "";
-	position: absolute;
-	top: 50%;
-	right: 100%;
-	margin-top: -5px;
-	border-width: 5px;
-	border-style: solid;
-	border-color: transparent rgba(3, 169, 244, 0.8) transparent transparent;
-}
-
-.sidebar-item:hover .icon-purpose {
-	visibility: visible;
-	opacity: 1;
-	display: block;
-}
-</style>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 279 - 306
frontend/components/Station/Station.vue


+ 74 - 67
frontend/components/Station/OfficialHeader.vue → frontend/components/Station/StationHeader.vue

@@ -2,7 +2,7 @@
 	<div>
 		<nav class="nav">
 			<div class="nav-left">
-				<router-link class="nav-item is-brand" to="/">
+				<router-link class="nav-item is-brand" :to="{ path: '/' }">
 					<img
 						:src="`${this.siteSettings.logo}`"
 						:alt="`${this.siteSettings.siteName}` || `Musare`"
@@ -11,7 +11,7 @@
 			</div>
 
 			<div class="nav-center stationDisplayName">
-				{{ $parent.station.displayName }}
+				{{ station.displayName }}
 			</div>
 
 			<span class="nav-toggle" v-on:click="controlBar = !controlBar">
@@ -22,26 +22,22 @@
 
 			<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
 				<router-link
-					v-if="$parent.$parent.role === 'admin'"
+					v-if="role === 'admin'"
 					class="nav-item is-tab admin"
-					href="#"
 					:to="{ path: '/admin' }"
 				>
 					<strong>Admin</strong>
 				</router-link>
-				<span v-if="$parent.$parent.loggedIn" class="grouped">
+				<span v-if="loggedIn" class="grouped">
 					<router-link
 						class="nav-item is-tab"
-						href="#"
-						:to="{ path: '/u/' + $parent.$parent.username }"
+						:to="{ path: '/u/' + username }"
 						>Profile</router-link
 					>
 					<router-link class="nav-item is-tab" to="/settings"
 						>Settings</router-link
 					>
-					<a class="nav-item is-tab" @click="$parent.$parent.logout()"
-						>Logout</a
-					>
+					<a class="nav-item is-tab" @click="logout()">Logout</a>
 				</span>
 				<span v-else class="grouped">
 					<a
@@ -64,19 +60,13 @@
 		<div class="control-sidebar" :class="{ 'show-controlBar': controlBar }">
 			<div class="inner-wrapper">
 				<div v-if="isOwner()">
-					<a
-						v-if="isOwner()"
-						class="sidebar-item"
-						href="#"
-						@click="settings()"
-					>
+					<a class="sidebar-item" href="#" @click="settings()">
 						<span class="icon">
 							<i class="material-icons">settings</i>
 						</span>
 						<span class="icon-purpose">Station settings</span>
 					</a>
 					<a
-						v-if="isOwner()"
 						class="sidebar-item"
 						href="#"
 						@click="$parent.skipStation()"
@@ -87,35 +77,32 @@
 						<span class="icon-purpose">Skip current song</span>
 					</a>
 					<a
-						v-if="isOwner() && !$parent.paused"
+						v-if="paused"
 						class="sidebar-item"
 						href="#"
-						@click="$parent.pauseStation()"
+						@click="$parent.resumeStation()"
 					>
 						<span class="icon">
-							<i class="material-icons">pause</i>
+							<i class="material-icons">play_arrow</i>
 						</span>
-						<span class="icon-purpose">Pause station</span>
+						<span class="icon-purpose">Resume station</span>
 					</a>
 					<a
-						v-if="isOwner() && $parent.paused"
+						v-if="!paused"
 						class="sidebar-item"
 						href="#"
-						@click="$parent.resumeStation()"
+						@click="$parent.pauseStation()"
 					>
 						<span class="icon">
-							<i class="material-icons">play_arrow</i>
+							<i class="material-icons">pause</i>
 						</span>
-						<span class="icon-purpose">Resume station</span>
+						<span class="icon-purpose">Pause station</span>
 					</a>
 					<hr />
 				</div>
-				<div v-if="$parent.$parent.loggedIn">
+				<div v-if="loggedIn">
 					<a
-						v-if="
-							$parent.type === 'official' &&
-								$parent.$parent.loggedIn
-						"
+						v-if="station.type === 'official'"
 						class="sidebar-item"
 						href="#"
 						@click="
@@ -131,11 +118,7 @@
 						<span class="icon-purpose">Add song to queue</span>
 					</a>
 					<a
-						v-if="
-							!isOwner() &&
-								$parent.$parent.loggedIn &&
-								!$parent.noSong
-						"
+						v-if="!isOwner() && !noSong"
 						class="sidebar-item"
 						href="#"
 						@click="$parent.voteSkipStation()"
@@ -144,16 +127,12 @@
 							<i class="material-icons">skip_next</i>
 						</span>
 						<span class="skip-votes">{{
-							$parent.currentSong.skipVotes
+							currentSong.skipVotes
 						}}</span>
 						<span class="icon-purpose">Skip current song</span>
 					</a>
 					<a
-						v-if="
-							$parent.$parent.loggedIn &&
-								!$parent.noSong &&
-								!$parent.simpleSong
-						"
+						v-if="!noSong && !currentSong.simpleSong"
 						class="sidebar-item"
 						href="#"
 						@click="
@@ -169,7 +148,7 @@
 						<span class="icon-purpose">Report a song</span>
 					</a>
 					<a
-						v-if="$parent.$parent.loggedIn && !$parent.noSong"
+						v-if="!noSong"
 						class="sidebar-item"
 						href="#"
 						@click="
@@ -186,9 +165,13 @@
 							>Add current song to playlist</span
 						>
 					</a>
-					<hr />
+					<hr v-if="!noSong" />
 				</div>
 				<a
+					v-if="
+						station.partyMode === true ||
+							station.type === 'official'
+					"
 					class="sidebar-item"
 					href="#"
 					@click="$parent.toggleSidebar('songslist')"
@@ -210,13 +193,24 @@
 						>Display users in the station</span
 					>
 				</a>
+				<a
+					v-if="loggedIn && station.type === 'community'"
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('playlist')"
+				>
+					<span class="icon">
+						<i class="material-icons">library_music</i>
+					</span>
+					<span class="icon-purpose">Show your playlists</span>
+				</a>
 			</div>
 		</div>
 	</div>
 </template>
 
 <script>
-import { mapActions } from "vuex";
+import { mapState, mapActions } from "vuex";
 
 export default {
 	data() {
@@ -231,6 +225,16 @@ export default {
 			}
 		};
 	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		userId: state => state.user.auth.userId,
+		username: state => state.user.auth.username,
+		role: state => state.user.auth.role,
+		station: state => state.station.station,
+		paused: state => state.station.paused,
+		noSong: state => state.station.noSong,
+		currentSong: state => state.station.currentSong
+	}),
 	mounted() {
 		lofig.get("frontendDomain", res => {
 			this.frontendDomain = res;
@@ -244,19 +248,19 @@ export default {
 	methods: {
 		isOwner() {
 			return (
-				this.$parent.$parent.loggedIn &&
-				this.$parent.$parent.role === "admin"
+				this.loggedIn &&
+				(this.role === "admin" || this.userId === this.station.owner)
 			);
 		},
 		settings() {
 			this.editStation({
-				_id: this.$parent.station._id,
-				name: this.$parent.station.name,
-				type: this.$parent.type,
-				partyMode: this.$parent.station.partyMode,
-				description: this.$parent.station.description,
-				privacy: this.$parent.station.privacy,
-				displayName: this.$parent.station.displayName
+				_id: this.station._id,
+				name: this.station.name,
+				type: this.station.type,
+				partyMode: this.station.partyMode,
+				description: this.station.description,
+				privacy: this.station.privacy,
+				displayName: this.station.displayName
 			});
 			this.openModal({
 				sector: "station",
@@ -264,22 +268,25 @@ export default {
 			});
 		},
 		...mapActions("modals", ["openModal"]),
-		...mapActions("station", ["editStation"])
+		...mapActions("station", ["editStation"]),
+		...mapActions("user/auth", ["logout"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .nav {
-	background-color: #03a9f4;
+	background-color: $primary-color;
 	line-height: 64px;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 
 	.is-brand {
 		font-size: 2.1rem !important;
-		line-height: 64px !important;
+		line-height: 38px !important;
 		padding: 0 20px;
-		color: #ffffff;
+		color: $white;
 		font-family: Pacifico, cursive;
 		filter: brightness(0) invert(1);
 
@@ -290,11 +297,11 @@ export default {
 }
 
 a.nav-item {
-	color: #ffffff;
+	color: $white;
 	font-size: 17px;
 
 	&:hover {
-		color: #ffffff;
+		color: $white;
 	}
 
 	padding: 0 12px;
@@ -311,11 +318,11 @@ a.nav-item {
 
 a.nav-item.is-tab:hover {
 	border-bottom: none;
-	border-top: solid 1px #ffffff;
+	border-top: solid 1px $white;
 }
 
 .admin strong {
-	color: #9d42b1;
+	color: $purple;
 }
 
 .grouped {
@@ -335,7 +342,7 @@ a.nav-item.is-tab:hover {
 
 @media screen and (max-width: 998px) {
 	.nav-menu {
-		background-color: white;
+		background-color: $white;
 		box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
 		left: 0;
 		display: none;
@@ -358,7 +365,7 @@ a.nav-item.is-tab:hover {
 .nav-center {
 	display: flex;
 	align-items: center;
-	color: #03a9f4;
+	color: $primary-color;
 	font-size: 22px;
 	position: absolute;
 	margin: auto;
@@ -368,7 +375,7 @@ a.nav-item.is-tab:hover {
 }
 
 .nav-right.is-active .nav-item {
-	background: #03a9f4;
+	background: $primary-color;
 	border: 0;
 }
 
@@ -383,7 +390,7 @@ a.nav-item.is-tab:hover {
 	left: 0;
 	width: 64px;
 	height: 100vh;
-	background-color: #03a9f4;
+	background-color: $primary-color;
 	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
 		0 2px 10px 0 rgba(0, 0, 0, 0.12);
 
@@ -427,7 +434,7 @@ a.nav-item.is-tab:hover {
 .control-sidebar .sidebar-item {
 	font-size: 2rem;
 	height: 50px;
-	color: white;
+	color: $white;
 	-webkit-box-align: center;
 	-ms-flex-align: center;
 	align-items: center;
@@ -454,7 +461,7 @@ a.nav-item.is-tab:hover {
 	width: 160px;
 	font-size: 12px;
 	background-color: rgba(3, 169, 244, 0.8);
-	color: #fff;
+	color: $white;
 	text-align: center;
 	border-radius: 6px;
 	padding: 5px;

+ 5 - 3
frontend/components/User/ResetPassword.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Reset password" />
 		<main-header />
 		<div class="container">
 			<!--Implement Validation-->
@@ -86,9 +87,8 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
+			this.socket = socket;
 		});
 	},
 	methods: {
@@ -140,12 +140,14 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .container {
 	padding: 25px;
 }
 
 .skip-step {
 	background-color: #7e7e7e;
-	color: #fff;
+	color: $white;
 }
 </style>

+ 34 - 30
frontend/components/User/Settings.vue

@@ -1,5 +1,6 @@
 <template>
 	<div>
+		<metadata title="Settings" />
 		<main-header />
 		<div class="container">
 			<!--Implement Validation-->
@@ -109,7 +110,7 @@
 			<a
 				v-if="!github"
 				class="button is-github"
-				:href="`${$parent.serverDomain}/auth/github/link`"
+				:href="`${serverDomain}/auth/github/link`"
 			>
 				<div class="icon">
 					<img class="invert" src="/assets/social/github.svg" />
@@ -146,6 +147,8 @@
 </template>
 
 <script>
+import { mapState } from "vuex";
+
 import { Toast } from "vue-roaster";
 
 import MainHeader from "../MainHeader.vue";
@@ -164,40 +167,43 @@ export default {
 			github: false,
 			setNewPassword: "",
 			passwordStep: 1,
-			passwordCode: ""
+			passwordCode: "",
+			serverDomain: ""
 		};
 	},
+	computed: mapState({
+		userId: state => state.user.auth.userId
+	}),
 	mounted() {
-		const _this = this;
+		lofig.get("serverDomain", res => {
+			this.serverDomain = res;
+		});
+
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("users.findBySession", res => {
+			this.socket = socket;
+			this.socket.emit("users.findBySession", res => {
 				if (res.status === "success") {
-					_this.user = res.data;
-					_this.password = _this.user.password;
-					_this.github = _this.user.github;
+					this.user = res.data;
+					this.password = this.user.password;
+					this.github = this.user.github;
 				} else {
-					_this.$parent.isLoginActive = true;
 					Toast.methods.addToast(
 						"Your are currently not signed in",
 						3000
 					);
 				}
 			});
-			_this.socket.on("event:user.username.changed", username => {
-				_this.$parent.username = username;
+			this.socket.on("event:user.linkPassword", () => {
+				this.password = true;
 			});
-			_this.socket.on("event:user.linkPassword", () => {
-				_this.password = true;
+			this.socket.on("event:user.linkGitHub", () => {
+				this.github = true;
 			});
-			_this.socket.on("event:user.linkGitHub", () => {
-				_this.github = true;
+			this.socket.on("event:user.unlinkPassword", () => {
+				this.password = false;
 			});
-			_this.socket.on("event:user.unlinkPassword", () => {
-				_this.password = false;
-			});
-			_this.socket.on("event:user.unlinkGitHub", () => {
-				_this.github = false;
+			this.socket.on("event:user.unlinkGitHub", () => {
+				this.github = false;
 			});
 		});
 	},
@@ -217,7 +223,7 @@ export default {
 
 			return this.socket.emit(
 				"users.updateEmail",
-				this.$parent.userId,
+				this.userId,
 				email,
 				res => {
 					if (res.status !== "success")
@@ -245,7 +251,7 @@ export default {
 
 			return this.socket.emit(
 				"users.updateUsername",
-				this.$parent.userId,
+				this.userId,
 				username,
 				res => {
 					if (res.status !== "success")
@@ -340,24 +346,22 @@ export default {
 			});
 		},
 		removeSessions() {
-			this.socket.emit(
-				`users.removeSessions`,
-				this.$parent.userId,
-				res => {
-					Toast.methods.addToast(res.message, 4000);
-				}
-			);
+			this.socket.emit(`users.removeSessions`, this.userId, res => {
+				Toast.methods.addToast(res.message, 4000);
+			});
 		}
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .container {
 	padding: 25px;
 }
 
 a {
-	color: #029ce3 !important;
+	color: $primary-color !important;
 }
 </style>

+ 19 - 12
frontend/components/User/Show.vue

@@ -1,14 +1,13 @@
 <template>
 	<div v-if="isUser">
+		<metadata v-bind:title="`Profile | ${user.username}`" />
 		<main-header />
 		<div class="container">
 			<img class="avatar" src="/assets/notes.png" />
 			<h2 class="has-text-centered username">@{{ user.username }}</h2>
 			<h5>A member since {{ user.createdAt }}</h5>
 			<div
-				v-if="
-					$parent.role === 'admin' && !($parent.userId === user._id)
-				"
+				v-if="role === 'admin' && userId !== user._id"
 				class="admin-functionality"
 			>
 				<a
@@ -64,7 +63,9 @@
 </template>
 
 <script>
+import { mapState } from "vuex";
 import { Toast } from "vue-roaster";
+import { format, parseISO } from "date-fns";
 
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
@@ -78,21 +79,25 @@ export default {
 			isUser: false
 		};
 	},
+	computed: mapState({
+		role: state => state.user.auth.role,
+		userId: state => state.user.auth.userId
+	}),
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit(
+			this.socket = socket;
+			this.socket.emit(
 				"users.findByUsername",
-				_this.$route.params.username,
+				this.$route.params.username,
 				res => {
 					if (res.status === "error") this.$router.go("/404");
 					else {
-						_this.user = res.data;
-						this.user.createdAt = moment(
-							this.user.createdAt
-						).format("LL");
-						_this.isUser = true;
+						this.user = res.data;
+						this.user.createdAt = format(
+							parseISO(this.user.createdAt),
+							"MMMM do yyyy"
+						);
+						this.isUser = true;
 					}
 				}
 			);
@@ -120,6 +125,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .container {
 	padding: 25px;
 }

+ 9 - 13
frontend/components/UserIdToUsername.vue

@@ -1,36 +1,32 @@
 <template>
 	<router-link
-		v-if="$props.link && username"
-		:to="{ path: `/u/${userIdMap['Z' + $props.userId]}` }"
+		v-if="$props.link && username !== 'unknown'"
+		:to="{ path: `/u/${username}` }"
+		:title="userId"
 	>
-		{{ username ? username : "unknown" }}
+		{{ username }}
 	</router-link>
-	<span v-else>
-		{{ username ? username : "unknown" }}
+	<span :title="userId" v-else>
+		{{ username }}
 	</span>
 </template>
 
 <script>
-import { mapState, mapActions } from "vuex";
+import { mapActions } from "vuex";
 
 export default {
 	props: ["userId", "link"],
 	data() {
 		return {
-			username: ""
+			username: "unknown"
 		};
 	},
-	computed: {
-		...mapState("user/auth", {
-			userIdMap: state => state.userIdMap
-		})
-	},
 	methods: {
 		...mapActions("user/auth", ["getUsernameFromId"])
 	},
 	mounted() {
 		this.getUsernameFromId(this.$props.userId).then(res => {
-			this.username = res;
+			if (res) this.username = res;
 		});
 	}
 };

+ 3 - 0
frontend/components/pages/About.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="app">
+		<metadata title="About" />
 		<main-header />
 		<div class="content-wrapper">
 			<div class="card is-fullwidth">
@@ -77,6 +78,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .card {
 	margin-top: 50px;
 }

+ 27 - 34
frontend/components/pages/Admin.vue

@@ -95,26 +95,17 @@
 <script>
 import MainHeader from "../MainHeader.vue";
 
-import QueueSongs from "../Admin/QueueSongs.vue";
-import Songs from "../Admin/Songs.vue";
-import Stations from "../Admin/Stations.vue";
-import Reports from "../Admin/Reports.vue";
-import News from "../Admin/News.vue";
-import Users from "../Admin/Users.vue";
-import Statistics from "../Admin/Statistics.vue";
-import Punishments from "../Admin/Punishments.vue";
-
 export default {
 	components: {
 		MainHeader,
-		QueueSongs,
-		Songs,
-		Stations,
-		Reports,
-		News,
-		Users,
-		Statistics,
-		Punishments
+		QueueSongs: () => import("../Admin/QueueSongs.vue"),
+		Songs: () => import("../Admin/Songs.vue"),
+		Stations: () => import("../Admin/Stations.vue"),
+		Reports: () => import("../Admin/Reports.vue"),
+		News: () => import("../Admin/News.vue"),
+		Users: () => import("../Admin/Users.vue"),
+		Statistics: () => import("../Admin/Statistics.vue"),
+		Punishments: () => import("../Admin/Punishments.vue")
 	},
 	data() {
 		return {
@@ -168,39 +159,41 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .tabs {
-	background-color: #ffffff;
+	background-color: $white;
 	.queueSongs {
-		color: #00d1b2;
-		border-color: #00d1b2;
+		color: $teal;
+		border-color: $teal;
 	}
 	.songs {
-		color: #03a9f4;
-		border-color: #03a9f4;
+		color: $primary-color;
+		border-color: $primary-color;
 	}
 	.stations {
-		color: #90298c;
-		border-color: #90298c;
+		color: $purple;
+		border-color: $purple;
 	}
 	.reports {
-		color: #f7c218;
-		border-color: #f7c218;
+		color: $yellow;
+		border-color: $yellow;
 	}
 	.news {
-		color: #e49ba6;
-		border-color: #e49ba6;
+		color: $light-pink;
+		border-color: $light-pink;
 	}
 	.users {
-		color: #ea4962;
-		border-color: #ea4962;
+		color: $dark-pink;
+		border-color: $dark-pink;
 	}
 	.statistics {
-		color: #ff5e00;
-		border-color: #ff5e00;
+		color: $light-orange;
+		border-color: $light-orange;
 	}
 	.punishments {
-		color: #fc3200;
-		border-color: #fc3200;
+		color: $dark-orange;
+		border-color: $dark-orange;
 	}
 	.tab {
 		transition: all 0.2s ease-in-out;

+ 14 - 7
frontend/components/pages/Banned.vue

@@ -1,27 +1,34 @@
 <template>
 	<div class="container">
+		<metadata title="Banned" />
 		<i class="material-icons">not_interested</i>
 		<h4>
 			You are banned for
-			<strong>{{ moment($parent.ban.expiresAt).fromNow(true) }}</strong>
+			<strong>{{
+				formatDistance(new Date(ban.expiresAt), Date.now())
+			}}</strong>
 		</h4>
 		<h5 class="reason">
 			<strong>Reason: </strong>
-			{{ $parent.ban.reason }}
+			{{ ban.reason }}
 		</h5>
 	</div>
 </template>
 <script>
+import { mapState } from "vuex";
+import { formatDistance } from "date-fns"; // eslint-disable-line no-unused-vars
+
 export default {
-	data() {
-		return {
-			moment
-		};
-	}
+	computed: mapState({
+		ban: state => state.user.auth.ban
+	}),
+	methods: { formatDistance }
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .container {
 	display: flex;
 	justify-content: center;

+ 407 - 393
frontend/components/pages/Home.vue

@@ -1,143 +1,136 @@
 <template>
 	<div>
+		<metadata title="Home" />
 		<div class="app">
 			<main-header />
 			<div class="content-wrapper">
-				<div class="group">
-					<div class="group-title">
-						Official Stations
-					</div>
-					<router-link
-						v-for="(station, index) in stations.official"
-						:key="index"
-						class="card station-card"
-						:to="{ name: 'official', params: { id: station.name } }"
-						:class="{ isPrivate: station.privacy === 'private' }"
+				<div class="stationsTitle">
+					Stations&nbsp;
+					<a
+						v-if="loggedIn"
+						href="#"
+						@click="
+							openModal({
+								sector: 'home',
+								modal: 'createCommunityStation'
+							})
+						"
 					>
-						<div class="card-image">
-							<figure class="image is-square">
-								<img
-									:src="station.currentSong.thumbnail"
-									onerror="this.src='/assets/notes-transparent.png'"
-								/>
-							</figure>
-						</div>
-						<div class="card-content">
-							<div class="media">
-								<div class="media-left displayName">
-									<h5>{{ station.displayName }}</h5>
-								</div>
-							</div>
-
-							<div class="content">
-								{{ station.description }}
-							</div>
-
-							<div class="under-content">
-								<span class="official"
-									><i class="badge material-icons"
-										>verified_user</i
-									>Official</span
-								>
-								<i
-									v-if="station.privacy !== 'public'"
-									class="material-icons right-icon"
-									title="This station is not visible to other users."
-									>lock</i
-								>
-							</div>
-						</div>
-						<router-link
-							href="#"
-							class="absolute-a"
-							:to="{
-								name: 'official',
-								params: { id: station.name }
-							}"
-						/>
-					</router-link>
-				</div>
-				<div class="group">
-					<div class="group-title">
-						Community Stations&nbsp;
-						<a
-							v-if="$parent.loggedIn"
-							href="#"
-							@click="
-								openModal({
-									sector: 'home',
-									modal: 'createCommunityStation'
-								})
-							"
+						<i class="material-icons community-button"
+							>add_circle_outline</i
 						>
-							<i class="material-icons community-button"
-								>add_circle_outline</i
-							>
-						</a>
-					</div>
+					</a>
+				</div>
+				<div class="stations">
 					<router-link
-						v-for="(station, index) in stations.community"
+						v-for="(station, index) in filteredStations"
 						:key="index"
 						:to="{
-							name: 'community',
+							name: 'station',
 							params: { id: station.name }
 						}"
-						class="card station-card"
-						:class="{
-							isPrivate: station.privacy === 'private',
-							isMine: isOwner(station)
-						}"
+						class="stationCard"
 					>
-						<div class="card-image">
-							<figure class="image is-square">
+						<div class="topContent">
+							<div class="albumArt">
+								<div
+									v-if="station.currentSong.ytThumbnail"
+									class="ytThumbnailBg"
+									v-bind:style="{
+										'background-image':
+											'url(' +
+											station.currentSong.ytThumbnail +
+											')'
+									}"
+								></div>
 								<img
+									v-if="station.currentSong.ytThumbnail"
+									:src="station.currentSong.ytThumbnail"
+									onerror="this.src='/assets/notes-transparent.png'"
+									class="ytThumbnail"
+								/>
+								<img
+									v-else
 									:src="station.currentSong.thumbnail"
 									onerror="this.src='/assets/notes-transparent.png'"
 								/>
-							</figure>
-						</div>
-						<div class="card-content">
-							<div class="media">
-								<div class="media-left displayName">
-									<h5>{{ station.displayName }}</h5>
-								</div>
 							</div>
-
-							<div class="content">
-								{{ station.description }}
-							</div>
-							<div class="under-content">
-								<span class="hostedby"
-									>Hosted by
+							<div class="info">
+								<h5 class="displayName">
+									{{ station.displayName }}
+									<i
+										v-if="station.type === 'official'"
+										class="badge material-icons"
+									>
+										verified_user
+									</i>
+								</h5>
+								<i
+									v-if="loggedIn && !isFavorite(station)"
+									@click="favoriteStation($event, station)"
+									class="favorite material-icons"
+									>star_border</i
+								>
+								<i
+									v-if="loggedIn && isFavorite(station)"
+									@click="unfavoriteStation($event, station)"
+									class="favorite material-icons"
+									>star</i
+								>
+								<p class="description">
+									{{ station.description }}
+								</p>
+								<p class="hostedBy">
+									Hosted by
 									<span class="host">
+										<span
+											v-if="station.type === 'official'"
+											title="Musare"
+											>Musare</span
+										>
 										<user-id-to-username
+											v-else
 											:userId="station.owner"
 											:link="true"
 										/>
 									</span>
-								</span>
-								<i
-									v-if="station.privacy !== 'public'"
-									class="material-icons right-icon"
-									title="This station is not visible to other users."
-									>lock</i
-								>
-								<i
-									v-if="isOwner(station)"
-									class="material-icons right-icon"
-									title="This is your station."
-									>home</i
-								>
+								</p>
+								<div class="bottomIcons">
+									<i
+										v-if="station.privacy !== 'public'"
+										class="privateIcon material-icons"
+										title="This station is not visible to other users."
+										>lock</i
+									>
+									<i
+										v-if="
+											station.type === 'community' &&
+												isOwner(station)
+										"
+										class="homeIcon material-icons"
+										title="This is your station."
+										>home</i
+									>
+								</div>
+							</div>
+						</div>
+						<div class="bottomBar">
+							<i class="material-icons">music_note</i>
+							<span
+								v-if="station.currentSong.title"
+								class="songTitle"
+								>{{ station.currentSong.title }}</span
+							>
+							<span v-else class="songTitle"
+								>No Songs Playing</span
+							>
+							<div class="right">
+								<i class="material-icons">people</i>
+								<span class="currentUsers">{{
+									station.userCount
+								}}</span>
 							</div>
 						</div>
-						<router-link
-							href="#"
-							class="absolute-a"
-							:to="{
-								name: 'community',
-								params: { id: station.name }
-							}"
-						/>
 					</router-link>
 				</div>
 			</div>
@@ -149,13 +142,13 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
+import { Toast } from "vue-roaster";
 
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
 import CreateCommunityStation from "../Modals/CreateCommunityStation.vue";
 import UserIdToUsername from "../UserIdToUsername.vue";
 
-import auth from "../../auth";
 import io from "../../io";
 
 export default {
@@ -164,129 +157,133 @@ export default {
 			recaptcha: {
 				key: ""
 			},
-			stations: {
-				official: [],
-				community: []
-			}
+			stations: [],
+			favoriteStations: [],
+			searchQuery: ""
 		};
 	},
-	computed: mapState("modals", {
-		modals: state => state.modals.home
-	}),
+	computed: {
+		filteredStations() {
+			return this.stations.filter(
+				station =>
+					JSON.stringify(Object.values(station)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
+		},
+		...mapState({
+			modals: state => state.modals.modals.home,
+			loggedIn: state => state.user.auth.loggedIn,
+			userId: state => state.user.auth.userId
+		})
+	},
 	mounted() {
-		const _this = this;
-		auth.getStatus(() => {
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => {
-					_this.init();
-				});
-				_this.socket.on("event:stations.created", res => {
-					const station = res;
-
-					if (!station.currentSong)
-						station.currentSong = {
-							thumbnail: "/assets/notes-transparent.png"
-						};
-					if (station.currentSong && !station.currentSong.thumbnail)
-						station.currentSong.thumbnail =
-							"/assets/notes-transparent.png";
-					_this.stations[station.type].push(station);
-				});
-				_this.socket.on(
-					"event:userCount.updated",
-					(stationId, userCount) => {
-						_this.stations.official.forEach(s => {
-							const station = s;
-							if (station._id === stationId) {
-								station.userCount = userCount;
-							}
-						});
-
-						_this.stations.community.forEach(s => {
-							const station = s;
-							if (station._id === stationId) {
-								station.userCount = userCount;
-							}
-						});
-					}
-				);
-				_this.socket.on("event:station.nextSong", (stationId, song) => {
-					let newSong = song;
-					_this.stations.official.forEach(s => {
-						const station = s;
-						if (station._id === stationId) {
-							if (!newSong)
-								newSong = {
-									thumbnail: "/assets/notes-transparent.png"
-								};
-							if (newSong && !newSong.thumbnail)
-								newSong.thumbnail =
-									"/assets/notes-transparent.png";
-							station.currentSong = newSong;
-						}
-					});
-
-					_this.stations.community.forEach(s => {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => {
+				this.init();
+			});
+			this.socket.on("event:stations.created", res => {
+				const station = res;
+
+				if (!station.currentSong)
+					station.currentSong = {
+						thumbnail: "/assets/notes-transparent.png"
+					};
+				if (station.currentSong && !station.currentSong.thumbnail)
+					station.currentSong.ytThumbnail = `https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg`;
+				this.stations.push(station);
+			});
+			this.socket.on(
+				"event:userCount.updated",
+				(stationId, userCount) => {
+					this.stations.forEach(s => {
 						const station = s;
 						if (station._id === stationId) {
-							if (!newSong)
-								newSong = {
-									thumbnail: "/assets/notes-transparent.png"
-								};
-							if (newSong && !newSong.thumbnail)
-								newSong.thumbnail =
-									"/assets/notes-transparent.png";
-							station.currentSong = newSong;
+							station.userCount = userCount;
 						}
 					});
+				}
+			);
+			this.socket.on("event:station.nextSong", (stationId, song) => {
+				let newSong = song;
+				this.stations.forEach(s => {
+					const station = s;
+					if (station._id === stationId) {
+						if (!newSong)
+							newSong = {
+								thumbnail: "/assets/notes-transparent.png"
+							};
+						if (newSong && !newSong.thumbnail)
+							newSong.ytThumbnail = `https://img.youtube.com/vi/${newSong.songId}/mqdefault.jpg`;
+						station.currentSong = newSong;
+					}
 				});
 			});
+			this.socket.on("event:user.favoritedStation", stationId => {
+				this.favoriteStations.push(stationId);
+			});
+			this.socket.on("event:user.unfavoritedStation", stationId => {
+				this.favoriteStations.$remove(stationId);
+			});
 		});
 	},
 	methods: {
 		init() {
-			const _this = this;
-			auth.getStatus((authenticated, role, username, userId) => {
-				_this.socket.emit("stations.index", data => {
-					_this.stations.community = [];
-					_this.stations.official = [];
-					if (data.status === "success")
-						data.stations.forEach(s => {
-							const station = s;
-							if (!station.currentSong)
-								station.currentSong = {
-									thumbnail: "/assets/notes-transparent.png"
-								};
-							if (
-								station.currentSong &&
-								!station.currentSong.thumbnail
-							)
-								station.currentSong.thumbnail =
-									"/assets/notes-transparent.png";
-							if (station.privacy !== "public")
-								station.class = { "station-red": true };
-							else if (
-								station.type === "community" &&
-								station.owner === userId
-							)
-								station.class = { "station-blue": true };
-							if (station.type === "official")
-								_this.stations.official.push(station);
-							else _this.stations.community.push(station);
-						});
-				});
-				_this.socket.emit("apis.joinRoom", "home", () => {});
+			this.socket.emit("stations.index", data => {
+				this.stations = [];
+				if (data.status === "success")
+					data.stations.forEach(s => {
+						const station = s;
+						if (!station.currentSong)
+							station.currentSong = {
+								thumbnail: "/assets/notes-transparent.png"
+							};
+						if (
+							station.currentSong &&
+							!station.currentSong.thumbnail
+						)
+							station.currentSong.ytThumbnail = `https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg`;
+						this.stations.push(station);
+					});
+			});
+			this.socket.emit("users.getFavoriteStations", data => {
+				if (data.status === "success")
+					this.favoriteStations = data.favoriteStations;
 			});
+			this.socket.emit("apis.joinRoom", "home", () => {});
 		},
 		isOwner(station) {
-			const _this = this;
 			return (
-				station.owner === _this.$parent.userId &&
-				station.privacy === "public"
+				station.owner === this.userId && station.privacy === "public"
 			);
 		},
+		isFavorite(station) {
+			return this.favoriteStations.indexOf(station._id) !== -1;
+		},
+		favoriteStation(event, station) {
+			event.preventDefault();
+			this.socket.emit("stations.favoriteStation", station._id, res => {
+				if (res.status === "success") {
+					Toast.methods.addToast(
+						"Successfully favorited station.",
+						4000
+					);
+				} else Toast.methods.addToast(res.message, 8000);
+			});
+		},
+		unfavoriteStation(event, station) {
+			event.preventDefault();
+			this.socket.emit("stations.unfavoriteStation", station._id, res => {
+				if (res.status === "success") {
+					Toast.methods.addToast(
+						"Successfully unfavorited station.",
+						4000
+					);
+				} else Toast.methods.addToast(res.message, 8000);
+			});
+		},
 		...mapActions("modals", ["openModal"])
 	},
 	components: {
@@ -299,6 +296,8 @@ export default {
 </script>
 
 <style lang="scss">
+@import "styles/global.scss";
+
 * {
 	box-sizing: border-box;
 }
@@ -306,7 +305,7 @@ export default {
 html {
 	width: 100%;
 	height: 100%;
-	color: rgba(0, 0, 0, 0.87);
+	color: $dark-grey-2;
 
 	body {
 		width: 100%;
@@ -316,198 +315,213 @@ html {
 	}
 }
 
-@media only screen and (min-width: 1200px) {
-	html {
-		font-size: 15px;
-	}
+.stationsTitle {
+	width: 100%;
+	height: 64px;
+	line-height: 48px;
+	text-align: center;
+	font-size: 48px;
+	margin-bottom: 25px;
 }
-
-@media only screen and (min-width: 992px) {
-	html {
-		font-size: 14.5px;
+.community-button {
+	cursor: pointer;
+	transition: 0.25s ease color;
+	font-size: 30px;
+	color: $dark-grey;
+	&:hover {
+		color: $primary-color;
 	}
 }
 
-@media only screen and (min-width: 0) {
-	html {
-		font-size: 14px;
-	}
+.stations {
+	display: flex;
+	flex: 1;
+	flex-wrap: wrap;
+	justify-content: center;
+	margin-left: 10px;
+	margin-right: 10px;
 }
-
-.under-content {
-	width: calc(100% - 40px);
-	left: 20px;
-	right: 20px;
-	bottom: 10px;
-	text-align: left;
-	height: 25px;
-	position: absolute;
-	margin-bottom: 10px;
-	line-height: 1;
-	font-size: 24px;
-	vertical-align: middle;
-
-	* {
-		z-index: 10;
-		position: relative;
-	}
-
-	.official {
-		font-size: 18px;
-		color: #03a9f4;
+.stationCard {
+	display: inline-flex;
+	flex-direction: column;
+	width: 450px;
+	height: 180px;
+	background: $white;
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	color: $dark-grey;
+	margin: 10px;
+	transition: all ease-in-out 0.2s;
+	cursor: pointer;
+	overflow: hidden;
+	.albumArt {
+		display: inline-flex;
 		position: relative;
-		top: -5px;
-
-		.badge {
-			position: relative;
-			padding-right: 2px;
-			color: #38d227;
-			top: +5px;
+		height: 150px;
+		width: 150px;
+		box-shadow: 1px 0px 3px rgba(7, 136, 191, 0.3);
+		overflow: hidden;
+		img {
+			width: auto;
+			height: 100%;
+		}
+		.ytThumbnailBg {
+			background: url("/assets/notes-transparent.png") no-repeat center
+				center;
+			background-size: cover;
+			height: 100%;
+			width: 100%;
+			position: absolute;
+			top: 0;
+			filter: blur(5px);
+		}
+		.ytThumbnail {
+			height: auto;
+			width: 100%;
+			top: 0;
+			margin-top: auto;
+			margin-bottom: auto;
+			z-index: 1;
 		}
 	}
-
-	.hostedby {
-		font-size: 15px;
-
-		.host {
-			color: #03a9f4;
-
-			a {
-				color: #03a9f4;
+	.topContent {
+		width: 100%;
+		height: 100%;
+		display: inline-flex;
+		.info {
+			padding: 15px 12px 12px 15px;
+			position: relative;
+			width: 100%;
+			max-width: 300px;
+			.displayName {
+				color: $black;
+				margin: 0;
+				font-size: 20px;
+				font-weight: 500;
+				margin-bottom: 5px;
+				width: calc(100% - 30px);
+				word-wrap: break-word;
+				overflow: hidden;
+				text-overflow: ellipsis;
+				display: -webkit-box;
+				-webkit-box-orient: vertical;
+				-webkit-line-clamp: 1;
+				line-height: 30px;
+				max-height: 30px;
+				.badge {
+					position: relative;
+					padding-right: 2px;
+					color: $lime;
+					top: 3px;
+					font-size: 22px;
+				}
+			}
+			.favorite {
+				color: $yellow;
+				top: 12px;
+				right: 12px;
+				position: absolute;
+				display: none;
+			}
+			.description {
+				width: calc(100% - 30px);
+				margin: 0;
+				font-size: 14px;
+				font-weight: 400;
+				word-wrap: break-word;
+				overflow: hidden;
+				text-overflow: ellipsis;
+				display: -webkit-box;
+				-webkit-box-orient: vertical;
+				-webkit-line-clamp: 3;
+				line-height: 20px;
+				max-height: 60px;
+			}
+			.hostedBy {
+				font-weight: 400;
+				font-size: 12px;
+				position: absolute;
+				bottom: 12px;
+				color: $black;
+				.host {
+					font-weight: 400;
+					color: $primary-color;
+				}
+			}
+			.bottomIcons {
+				position: absolute;
+				bottom: 12px;
+				right: 12px;
+				.material-icons {
+					margin-left: 5px;
+					font-size: 22px;
+				}
+				.privateIcon {
+					color: $dark-pink;
+				}
+				.homeIcon {
+					color: $light-purple;
+				}
 			}
 		}
 	}
-
-	.right-icon {
-		float: right;
-	}
-}
-
-.users-count {
-	font-size: 20px;
-	position: relative;
-	top: -4px;
-}
-
-.right {
-	float: right;
-}
-
-.group {
-	min-height: 64px;
-}
-
-.station-card {
-	margin: 10px;
-	cursor: pointer;
-	height: 475px;
-
-	transition: all ease-in-out 0.2s;
-
-	.card-content {
-		max-height: 159px;
-
-		.content {
-			word-wrap: break-word;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			display: -webkit-box;
-			-webkit-box-orient: vertical;
-			-webkit-line-clamp: 3;
-			line-height: 20px;
-			max-height: 60px;
+	.bottomBar {
+		background: $primary-color;
+		box-shadow: inset 0px 2px 4px rgba(7, 136, 191, 0.6);
+		width: 100%;
+		height: 30px;
+		line-height: 30px;
+		color: $white;
+		font-weight: 400;
+		font-size: 12px;
+		i.material-icons {
+			vertical-align: middle;
+			margin-left: 12px;
+			font-size: 22px;
+		}
+		.songTitle {
+			vertical-align: middle;
+			margin-left: 5px;
+		}
+		.right {
+			float: right;
+			margin-right: 12px;
+			.currentUsers {
+				vertical-align: middle;
+				margin-left: 5px;
+				font-size: 14px;
+			}
 		}
 	}
 }
-
-.station-card:hover {
+.stationCard:hover {
 	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.3), 0 0 10px rgba(10, 10, 10, 0.3);
 	transition: all ease-in-out 0.2s;
 }
 
-/*.isPrivate {
-		background-color: #F8BBD0;
-	}
-
-	.isMine {
-		background-color: #29B6F6;
-	}*/
-
-.community-button {
-	cursor: pointer;
-	transition: 0.25s ease color;
-	font-size: 30px;
-	color: #4a4a4a;
-}
-
-.community-button:hover {
-	color: #03a9f4;
-}
-
-.station-privacy {
-	text-transform: capitalize;
-}
-
-.label {
-	display: flex;
-}
-
-.g-recaptcha {
-	display: flex;
-	justify-content: center;
-	margin-top: 20px;
-}
-
-.group {
-	text-align: center;
-	width: 100%;
-
-	.group-title {
-		float: left;
-		clear: none;
-		width: 100%;
-		height: 64px;
-		line-height: 48px;
-		text-align: center;
-		font-size: 48px;
-		margin-bottom: 25px;
-	}
-}
-
-.group .card {
-	display: inline-flex;
-	flex-direction: column;
-	overflow: hidden;
-
-	.content {
-		text-align: left;
-		word-wrap: break-word;
-	}
-
-	.media {
-		display: flex;
-		align-items: center;
-
-		.station-status {
-			line-height: 13px;
-		}
-
-		h5 {
-			margin: 0;
+@media screen and (max-width: 490px) {
+	.stationCard {
+		width: calc(100% - 20px);
+		height: auto;
+		.topContent {
+			.albumArt {
+				max-height: 100px;
+				max-width: 100px;
+			}
+			.info {
+				width: calc(100% - 100px);
+				padding: 5px 2px 2px 10px !important;
+				.displayName {
+					font-size: 16px !important;
+					margin-bottom: 3px !important;
+				}
+				.description {
+					font-size: 12px !important;
+					-webkit-line-clamp: 2;
+					line-height: 15px;
+					max-height: 30px;
+				}
+			}
 		}
 	}
 }
-
-.displayName {
-	word-wrap: break-word;
-	width: 80%;
-	word-wrap: break-word;
-	overflow: hidden;
-	text-overflow: ellipsis;
-	display: -webkit-box;
-	-webkit-box-orient: vertical;
-	-webkit-line-clamp: 1;
-	line-height: 30px;
-	max-height: 30px;
-}
 </style>

+ 21 - 17
frontend/components/pages/News.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="app">
+		<metadata title="News" />
 		<main-header />
 		<div class="container">
 			<div
@@ -77,6 +78,8 @@
 </template>
 
 <script>
+import { format } from "date-fns";
+
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
 import io from "../../io";
@@ -90,39 +93,40 @@ export default {
 		};
 	},
 	mounted() {
-		const _this = this;
 		io.getSocket(socket => {
-			_this.socket = socket;
-			_this.socket.emit("news.index", res => {
-				_this.news = res.data;
-				if (_this.news.length === 0) _this.noFound = true;
+			this.socket = socket;
+			this.socket.emit("news.index", res => {
+				this.news = res.data;
+				if (this.news.length === 0) this.noFound = true;
 			});
-			_this.socket.on("event:admin.news.created", news => {
-				_this.news.unshift(news);
-				_this.noFound = false;
+			this.socket.on("event:admin.news.created", news => {
+				this.news.unshift(news);
+				this.noFound = false;
 			});
-			_this.socket.on("event:admin.news.updated", news => {
-				for (let n = 0; n < _this.news.length; n += 1) {
-					if (_this.news[n]._id === news._id) {
-						_this.news.$set(n, news);
+			this.socket.on("event:admin.news.updated", news => {
+				for (let n = 0; n < this.news.length; n += 1) {
+					if (this.news[n]._id === news._id) {
+						this.news.$set(n, news);
 					}
 				}
 			});
-			_this.socket.on("event:admin.news.removed", news => {
-				_this.news = _this.news.filter(item => item._id !== news._id);
-				if (_this.news.length === 0) _this.noFound = true;
+			this.socket.on("event:admin.news.removed", news => {
+				this.news = this.news.filter(item => item._id !== news._id);
+				if (this.news.length === 0) this.noFound = true;
 			});
 		});
 	},
 	methods: {
 		formatDate: unix => {
-			return moment(unix).format("DD-MM-YYYY");
+			return format(unix, "dd-MM-yyyy");
 		}
 	}
 };
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 .card {
 	margin-top: 50px;
 }
@@ -133,7 +137,7 @@ export default {
 		padding: 12px;
 		text-transform: uppercase;
 		font-weight: bold;
-		color: #fff;
+		color: $white;
 	}
 
 	.sect-head-features {

+ 1 - 0
frontend/components/pages/Privacy.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="app">
+		<metadata title="Privacy policy" />
 		<main-header />
 		<div class="container">
 			<h1>MUSARE PRIVACY POLICY</h1>

+ 4 - 1
frontend/components/pages/Team.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="app">
+		<metadata title="Team" />
 		<main-header />
 		<div class="content-wrapper">
 			<h3 class="center">
@@ -202,6 +203,8 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+@import "styles/global.scss";
+
 li a {
 	color: dodgerblue;
 	border-bottom: 0 !important;
@@ -245,7 +248,7 @@ ul {
 }
 
 .custom-tag.purple {
-	border-bottom: 2px #90298c solid;
+	border-bottom: 2px $purple solid;
 }
 
 .thanks {

+ 1 - 0
frontend/components/pages/Terms.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="app">
+		<metadata title="Terms of Service" />
 		<main-header />
 		<div class="content-wrapper">
 			<h1>MUSARE TERMS OF SERVICE</h1>

+ 3 - 0
frontend/dist/assets/arrow_down.svg

@@ -0,0 +1,3 @@
+<svg width="46" height="10" viewBox="0 0 46 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1 1L23 9L45 1" stroke="#333333" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
frontend/dist/assets/arrow_up.svg

@@ -0,0 +1,3 @@
+<svg width="46" height="10" viewBox="0 0 46 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M45 9L23 1L1 9" stroke="#333333" stroke-width="1.5"/>
+</svg>

+ 0 - 0
frontend/dist/android-chrome-144x144.png → frontend/dist/assets/favicon/android-chrome-144x144.png


+ 0 - 0
frontend/dist/android-chrome-192x192.png → frontend/dist/assets/favicon/android-chrome-192x192.png


+ 0 - 0
frontend/dist/android-chrome-36x36.png → frontend/dist/assets/favicon/android-chrome-36x36.png


+ 0 - 0
frontend/dist/android-chrome-48x48.png → frontend/dist/assets/favicon/android-chrome-48x48.png


Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно