소스 검색

Merge pull request #32 from Musare/staging

Queue's, Security and Management Update
Jonathan 8 년 전
부모
커밋
6f87b4da7e
64개의 변경된 파일2616개의 추가작업 그리고 467개의 파일을 삭제
  1. 3 0
      .env
  2. 3 1
      .gitignore
  3. 75 9
      README.md
  4. 8 5
      backend/config/template.json
  5. 150 16
      backend/index.js
  6. 1 1
      backend/logic/actions/hooks/adminRequired.js
  7. 1 1
      backend/logic/actions/hooks/loginRequired.js
  8. 1 1
      backend/logic/actions/hooks/ownerRequired.js
  9. 2 1
      backend/logic/actions/index.js
  10. 2 2
      backend/logic/actions/news.js
  11. 25 22
      backend/logic/actions/playlists.js
  12. 123 0
      backend/logic/actions/punishments.js
  13. 179 69
      backend/logic/actions/songs.js
  14. 62 12
      backend/logic/actions/stations.js
  15. 187 7
      backend/logic/actions/users.js
  16. 7 0
      backend/logic/api.js
  17. 14 1
      backend/logic/app.js
  18. 32 16
      backend/logic/cache/index.js
  19. 5 0
      backend/logic/cache/schemas/punishment.js
  20. 18 6
      backend/logic/db/index.js
  21. 9 0
      backend/logic/db/schemas/punishment.js
  22. 0 6
      backend/logic/db/schemas/user.js
  23. 113 73
      backend/logic/io.js
  24. 56 25
      backend/logic/logger.js
  25. 21 2
      backend/logic/mail/index.js
  26. 42 16
      backend/logic/notifications.js
  27. 14 2
      backend/logic/playlists.js
  28. 235 0
      backend/logic/punishments.js
  29. 21 9
      backend/logic/songs.js
  30. 40 17
      backend/logic/stations.js
  31. 20 4
      backend/logic/tasks.js
  32. 30 4
      backend/logic/utils.js
  33. 1 0
      backend/package.json
  34. 0 30
      docker-compose-production.yml
  35. 9 6
      docker-compose.yml
  36. 148 0
      fallback.html
  37. 35 8
      frontend/App.vue
  38. 22 1
      frontend/auth.js
  39. 5 2
      frontend/components/Admin/News.vue
  40. 114 0
      frontend/components/Admin/Punishments.vue
  41. 1 1
      frontend/components/Admin/Stations.vue
  42. 11 1
      frontend/components/MainFooter.vue
  43. 42 0
      frontend/components/MainHeader.vue
  44. 1 1
      frontend/components/Modals/AddSongToQueue.vue
  45. 51 7
      frontend/components/Modals/EditSong.vue
  46. 10 0
      frontend/components/Modals/EditStation.vue
  47. 38 1
      frontend/components/Modals/EditUser.vue
  48. 4 1
      frontend/components/Modals/Login.vue
  49. 28 7
      frontend/components/Modals/Playlists/Edit.vue
  50. 4 1
      frontend/components/Modals/Register.vue
  51. 64 0
      frontend/components/Modals/ViewPunishment.vue
  52. 7 3
      frontend/components/Modals/WhatIsNew.vue
  53. 57 12
      frontend/components/Sidebars/SongsList.vue
  54. 45 10
      frontend/components/Station/CommunityHeader.vue
  55. 44 12
      frontend/components/Station/OfficialHeader.vue
  56. 183 14
      frontend/components/Station/Station.vue
  57. 12 8
      frontend/components/User/Settings.vue
  58. 13 1
      frontend/components/pages/Admin.vue
  59. 45 0
      frontend/components/pages/Banned.vue
  60. 108 7
      frontend/components/pages/Home.vue
  61. 6 0
      frontend/components/pages/Team.vue
  62. 2 2
      frontend/io.js
  63. 3 0
      frontend/main.js
  64. 4 3
      windows-start.cmd

+ 3 - 0
.env

@@ -0,0 +1,3 @@
+REDIS_PASSWORD=PASSWORD
+BACKEND_PORT=8080
+FRONTEND_PORT=80

+ 3 - 1
.gitignore

@@ -7,6 +7,7 @@ Thumbs.db
 startRedis.cmd
 startMongo.cmd
 .database
+.redis
 dump.rdb
 npm-debug.log
 
@@ -22,4 +23,5 @@ frontend/build/config/default.json
 npm
 
 # Logs
-log/
+log/
+.env

+ 75 - 9
README.md

@@ -57,10 +57,13 @@ Once you've installed the required tools:
 	To set up a GitHub OAuth Application, you need to fill in some value's. 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.recaptcha.secret` value can be obtained by setting up a [ReCaptcha Site](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).  
-   	`apis.discord` is currently not needed.  
+   	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/).  
    	The `redis.url` url should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.
-   	The `mongo.url` url should be left alone for Docker, and changed to `mongodb://localhost:27017/musare` for non-Docker.  
+   	The `redis.password` should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `docker-compose.yml` 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.  
 
@@ -80,15 +83,50 @@ Now you have different paths here.
 
    `docker-compose build`
 
-2. Start the databases and tools in the background, as we usually don't need to monitor these for errors
+2. Set up the MongoDB database
+
+	1. Disable auth
+	
+		In `docker-compose.yml` remove `--auth` from the line `command: "--auth"` for mongo.
+	
+	2. Start the database
+	
+		`docker-compose up mongo`
+		
+	3. Connect to Mongo
+	
+		`docker-compose exec mongo mongo admin`
+	
+	4. Create an admin user
+	
+		`db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+		
+	5. Connect to the Musare database
+	
+		`use musare`
+		
+	6. Create the musare user
+	
+		`db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+	
+	7. Exit
+	
+		`exit`
+	
+	8. Add back authentication
+	
+		In `docker-compose.yml` add back `--auth` on the line `command: ""` for mongo.
+	
+
+3. Start the databases and tools in the background, as we usually don't need to monitor these for errors
 
    `docker-compose up -d mongo mongoclient redis`
 
-3. Start the backend and frontend in the foreground, so we can watch for errors during development
+4. Start the backend and frontend in the foreground, so we can watch for errors during development
 
    `docker-compose up backend frontend`
 
-4. You should now be able to begin development! The backend is auto reloaded when
+5. You should now be able to begin development! The backend is auto reloaded when
    you make changes and the frontend is auto compiled and live reloaded by webpack
    when you make changes. You should be able to access Musare in your local browser
    at `http://<docker-machine-ip>:8080/` where `<docker-machine-ip>` can be found below:
@@ -108,10 +146,38 @@ Steps 1-4 are things you only have to do once. The steps to start servers follow
 		"C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
 
 	Make sure to adjust your paths accordingly.
-
-3. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
-
-4. Create a file called `startRedis.cmd` in the main folder with the contents:
+	
+3. Set up the MongoDB database
+	
+	1. Start the database by executing the script `startMongo.cmd` you just made
+		
+	2. Connect to Mongo from a command prompt
+	
+		`mongo admin`
+	
+	3. Create an admin user
+	
+		`db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+		
+	4. Connect to the Musare database
+	
+		`use musare`
+		
+	5. Create the musare user
+	
+		`db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+	
+	6. Exit
+	
+		`exit`
+	
+	7. Add the authentication
+	
+		In `startMongo.cmd` add ` --auth` at the end of the first line
+
+4. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
+
+5. Create a file called `startRedis.cmd` in the main folder with the contents:
 
 		"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf"
 

+ 8 - 5
backend/config/template.json

@@ -17,12 +17,14 @@
 			"redirect_uri": ""
 		},
 		"discord": {
-			"client": "",
-			"secret": ""
+			"token": "",
+			"loggingChannel": "",
+			"loggingServer": ""
 		},
 		"mailgun": {
 			"key": "",
-			"domain": ""
+			"domain": "",
+		  	"enabled": true
 		}
 	},
 	"cors": {
@@ -33,10 +35,11 @@
 		]
 	},
   	"redis": {
-	  	"url": "redis://redis:6379/0"
+	  	"url": "redis://redis:6379/0",
+	    "password": "PASSWORD"
 	},
   	"mongo": {
-	  	"url": "mongodb://mongo:27017/musare"
+	  	"url": "mongodb://musare:PASSWORD@mongo:27017/musare"
 	},
   	"cookie": {
 	  	"domain": "",

+ 150 - 16
backend/index.js

@@ -5,6 +5,9 @@ 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');
@@ -15,59 +18,188 @@ const songs = require('./logic/songs');
 const playlists = require('./logic/playlists');
 const cache = require('./logic/cache');
 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;
+
 process.on('uncaughtException', err => {
-	//console.log(`ERROR: ${err.message}`);
-	console.log(`ERROR: ${err.stack}`);
+	if (lockdownB || 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;
+	}
+	return error;
+};
+
+client.on('ready', () => {
+	discordClientCBS.forEach((cb) => {
+		cb();
+	});
+	discordClientCBS = [];
+	console.log(`Logged in to Discord as ${client.user.username}#${client.user.discriminator}`);
 });
 
+client.on('disconnect', (err) => {
+	console.log(`Discord disconnected. Code: ${err.code}.`);
+});
+
+client.login(config.get('apis.discord.token'));
+
+let discordClientCBS = [];
+const getDiscordClient = (cb) => {
+	if (client.status === 0) return cb();
+	else discordClientCBS.push(cb);
+};
+
+const logToDiscord = (message, color, type, critical, extraFields, cb = ()=>{}) => {
+	getDiscordClient(() => {
+		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.channels.get(config.get('apis.discord.loggingChannel')).sendEmbed(richEmbed).then(() => {
+			cb();
+		}).then((reason) => {
+			cb(reason);
+		});
+	});
+};
+
+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();
+	logToDiscord(message, "#FF0000", message, true, [{name: "Error:", value: err, inline: false}, {name: "Component:", value: component, inline: true}]);
+}
+
 async.waterfall([
 
 	// setup our Redis cache
 	(next) => {
-		cache.init(config.get('redis').url, () => {
+		currentComponent = 'Cache';
+		cache.init(config.get('redis').url, config.get('redis').password, errorCb, () => {
 			next();
 		});
 	},
 
 	// setup our MongoDB database
-	(next) => db.init(config.get("mongo").url, next),
+	(next) => {
+		initializedComponents.push(cache);
+		currentComponent = 'DB';
+		db.init(config.get("mongo").url, errorCb, next);
+	},
 
 	// setup the express server
-	(next) => app.init(next),
+	(next) => {
+		initializedComponents.push(db);
+		currentComponent = 'App';
+		app.init(next);
+	},
 
 	// setup the mail
-	(next) => mail.init(next),
+	(next) => {
+		initializedComponents.push(app);
+		currentComponent = 'Mail';
+		mail.init(next);
+	},
 
 	// setup the socket.io server (all client / server communication is done over this)
-	(next) => io.init(next),
+	(next) => {
+		initializedComponents.push(mail);
+		currentComponent = 'IO';
+		io.init(next);
+	},
+
+	// setup the punishment system
+	(next) => {
+		initializedComponents.push(io);
+		currentComponent = 'Punishments';
+		punishments.init(next);
+	},
 
 	// setup the notifications
-	(next) => notifications.init(config.get('redis').url, next),
+	(next) => {
+		initializedComponents.push(punishments);
+		currentComponent = 'Notifications';
+		notifications.init(config.get('redis').url, config.get('redis').password, errorCb, next);
+	},
 
 	// setup the stations
-	(next) => stations.init(next),
+	(next) => {
+		initializedComponents.push(notifications);
+		currentComponent = 'Stations';
+		stations.init(next)
+	},
 
 	// setup the songs
-	(next) => songs.init(next),
+	(next) => {
+		initializedComponents.push(stations);
+		currentComponent = 'Songs';
+		songs.init(next)
+	},
 
 	// setup the playlists
-	(next) => playlists.init(next),
+	(next) => {
+		initializedComponents.push(songs);
+		currentComponent = 'Playlists';
+		playlists.init(next)
+	},
 
 	// setup the API
-	(next) => api.init(next),
+	(next) => {
+		initializedComponents.push(playlists);
+		currentComponent = 'API';
+		api.init(next)
+	},
 
 	// setup the logger
-	(next) => logger.init(next),
+	(next) => {
+		initializedComponents.push(api);
+		currentComponent = 'Logger';
+		logger.init(next)
+	},
 
 	// setup the tasks system
-	(next) => tasks.init(next),
+	(next) => {
+		initializedComponents.push(logger);
+		currentComponent = 'Tasks';
+		tasks.init(next)
+	},
 
 	// setup the frontend for local setups
 	(next) => {
+		initializedComponents.push(tasks);
+		currentComponent = 'Windows';
 		if (!config.get("isDocker")) {
 			const express = require('express');
 			const app = express();
@@ -85,14 +217,16 @@ async.waterfall([
 				});
 			});
 		}
+		if (lockdownB) return;
 		next();
 	}
 ], (err) => {
 	if (err && err !== true) {
+		lockdown();
+		logToDiscord("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');
-		console.error(err);
-		process.exit();
 	} else {
+		logToDiscord("The backend server started successfully.", "#00AA00", "Startup", false, []);
 		console.info('Backend server has been successfully started');
 	}
 });

+ 1 - 1
backend/logic/actions/hooks/adminRequired.js

@@ -29,7 +29,7 @@ module.exports = function(next) {
 				logger.info("ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
-			logger.info("ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`);
+			logger.info("ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
 			args.push(session.userId);
 			next.apply(null, args);
 		});

+ 1 - 1
backend/logic/actions/hooks/loginRequired.js

@@ -23,7 +23,7 @@ module.exports = function(next) {
 				logger.info("LOGIN_REQUIRED", `User failed to pass login required check.`);
 				return cb({status: 'failure', message: err});
 			}
-			logger.info("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`);
+			logger.info("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`, false);
 			args.push(session.userId);
 			next.apply(null, args);
 		});

+ 1 - 1
backend/logic/actions/hooks/ownerRequired.js

@@ -35,7 +35,7 @@ module.exports = function(next) {
 				logger.info("OWNER_REQUIRED", `User failed to pass owner required check for station "${stationId}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
-			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`);
+			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`, false);
 			args.push(session.userId);
 			next.apply(null, args);
 		});

+ 2 - 1
backend/logic/actions/index.js

@@ -8,5 +8,6 @@ module.exports = {
 	playlists: require('./playlists'),
 	users: require('./users'),
 	reports: require('./reports'),
-	news: require('./news')
+	news: require('./news'),
+	punishments: require('./punishments')
 };

+ 2 - 2
backend/logic/actions/news.js

@@ -51,7 +51,7 @@ module.exports = {
 				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
-			logger.success("NEWS_INDEX", `Indexing news successful.`);
+			logger.success("NEWS_INDEX", `Indexing news successful.`, false);
 			return cb({ status: 'success', data: news });
 		});
 	},
@@ -100,7 +100,7 @@ module.exports = {
 				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
-			logger.success("NEWS_NEWEST", `Successfully got the latest news.`);
+			logger.success("NEWS_NEWEST", `Successfully got the latest news.`, false);
 			return cb({ status: 'success', data: news });
 		});
 	},

+ 25 - 22
backend/logic/actions/playlists.js

@@ -14,7 +14,7 @@ cache.sub('playlist.create', playlistId => {
 	playlists.getPlaylist(playlistId, (err, playlist) => {
 		if (!err) {
 			utils.socketsFromUser(playlist.createdBy, (sockets) => {
-				sockets.forEach((socket) => {
+				sockets.forEach(socket => {
 					socket.emit('event:playlist.create', playlist);
 				});
 			});
@@ -23,48 +23,48 @@ cache.sub('playlist.create', playlistId => {
 });
 
 cache.sub('playlist.delete', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
+	utils.socketsFromUser(res.userId, sockets => {
+		sockets.forEach(socket => {
 			socket.emit('event:playlist.delete', res.playlistId);
 		});
 	});
 });
 
 cache.sub('playlist.moveSongToTop', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
+	utils.socketsFromUser(res.userId, sockets => {
+		sockets.forEach(socket => {
 			socket.emit('event:playlist.moveSongToTop', {playlistId: res.playlistId, songId: res.songId});
 		});
 	});
 });
 
 cache.sub('playlist.moveSongToBottom', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
+	utils.socketsFromUser(res.userId, sockets => {
+		sockets.forEach(socket => {
 			socket.emit('event:playlist.moveSongToBottom', {playlistId: res.playlistId, songId: res.songId});
 		});
 	});
 });
 
 cache.sub('playlist.addSong', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
+	utils.socketsFromUser(res.userId, sockets => {
+		sockets.forEach(socket => {
 			socket.emit('event:playlist.addSong', { playlistId: res.playlistId, song: res.song });
 		});
 	});
 });
 
 cache.sub('playlist.removeSong', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
+	utils.socketsFromUser(res.userId, sockets => {
+		sockets.forEach(socket => {
 			socket.emit('event:playlist.removeSong', { playlistId: res.playlistId, songId: res.songId });
 		});
 	});
 });
 
 cache.sub('playlist.updateDisplayName', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
+	utils.socketsFromUser(res.userId, sockets => {
+		sockets.forEach(socket => {
 			socket.emit('event:playlist.updateDisplayName', { playlistId: res.playlistId, displayName: res.displayName });
 		});
 	});
@@ -283,10 +283,11 @@ let lib = {
 				err = 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 {
+				logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${userId}".`);
+				cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId });
+				return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
 			}
-			logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: userId });
-			return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
 		});
 	}),
 
@@ -331,9 +332,10 @@ let lib = {
 				err = 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 {
+				logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${userId}".`);
+				cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
 			}
-			logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${userId}".`);
-			cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
 		});
 	}),
 
@@ -371,10 +373,11 @@ let lib = {
 				err = 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 {
+				logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${userId}".`);
+				cache.pub('playlist.removeSong', { playlistId: playlist._id, songId: songId, userId });
+				return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
 			}
-			logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.removeSong', {playlistId: playlist._id, songId: songId, userId: userId});
-			return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
 		});
 	}),
 
@@ -549,4 +552,4 @@ let lib = {
 
 };
 
-module.exports = lib;
+module.exports = lib;

+ 123 - 0
backend/logic/actions/punishments.js

@@ -0,0 +1,123 @@
+'use strict';
+
+const 	hooks 	    = require('./hooks'),
+	 	async 	    = require('async'),
+	 	logger 	    = require('../logger'),
+	 	utils 	    = require('../utils'),
+		cache       = require('../cache'),
+	 	db 	        = require('../db'),
+		punishments = require('../punishments');
+
+cache.sub('ip.ban', data => {
+	utils.socketsFromIP(data.ip, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('keep.event:banned', data.punishment);
+			socket.disconnect(true);
+		});
+	});
+});
+
+module.exports = {
+
+	/**
+	 * Gets all punishments
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: hooks.adminRequired((session, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.punishment.find({}, next);
+			}
+		], (err, punishments) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
+				return cb({ 'status': 'failure', 'message': err});
+			}
+			logger.success("PUNISHMENTS_INDEX", "Indexing punishments successful.");
+			cb({ status: 'success', data: punishments });
+		});
+	}),
+
+	/**
+	 * Bans an IP address
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} value - the ip address that is going to be banned
+	 * @param {String} reason - the reason for the ban
+	 * @param {String} expiresAt - the time the ban expires
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	banIP: hooks.adminRequired((session, value, reason, expiresAt, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				if (value === '') return next('You must provide an IP address to ban.');
+				else if (reason === '') return next('You must provide a reason for the ban.');
+				else return next();
+			},
+
+			(next) => {
+				if (!expiresAt || typeof expiresAt !== 'string') return next('Invalid expire date.');
+				let date = new Date();
+				switch(expiresAt) {
+					case '1h':
+						expiresAt = date.setHours(date.getHours() + 1);
+						break;
+					case '12h':
+						expiresAt = date.setHours(date.getHours() + 12);
+						break;
+					case '1d':
+						expiresAt = date.setDate(date.getDate() + 1);
+						break;
+					case '1w':
+						expiresAt = date.setDate(date.getDate() + 7);
+						break;
+					case '1m':
+						expiresAt = date.setMonth(date.getMonth() + 1);
+						break;
+					case '3m':
+						expiresAt = date.setMonth(date.getMonth() + 3);
+						break;
+					case '6m':
+						expiresAt = date.setMonth(date.getMonth() + 6);
+						break;
+					case '1y':
+						expiresAt = date.setFullYear(date.getFullYear() + 1);
+						break;
+					case 'never':
+						expiresAt = new Date(3093527980800000);
+						break;
+					default:
+						return next('Invalid expire date.');
+				}
+
+				next();
+			},
+
+			(next) => {
+				punishments.addPunishment('banUserIp', value, reason, expiresAt, userId, next)
+			},
+
+			(punishment, next) => {
+				cache.pub('ip.ban', {ip: value, punishment});
+				next();
+			},
+		], (err) => {
+			if (err && err !== true) {
+				err = 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.'
+				});
+			}
+		});
+	}),
+
+};

+ 179 - 69
backend/logic/actions/songs.js

@@ -219,23 +219,41 @@ module.exports = {
 	 * @param userId
 	 */
 	like: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-			db.models.user.update({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({songId}, next);
+			},
+
+			(song, next) => {
+				if (!song) return next('No song found with that id.');
+				next(null, song);
+			}
+		], (err, song) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("SONGS_LIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			let oldSongId = songId;
+			songId = song._id;
+			db.models.user.findOne({ _id: userId }, (err, user) => {
+				if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
+				db.models.user.update({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
+					if (!err) {
+						db.models.user.count({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-							db.models.song.update({songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+							db.models.user.count({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.like', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully liked this song.' });
+								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+									if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
+									songs.updateSong(songId, (err, song) => {});
+									cache.pub('song.like', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
+									return cb({ status: 'success', message: 'You have successfully liked this song.' });
+								});
 							});
 						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
+					} else return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
+				});
 			});
 		});
 	}),
@@ -249,23 +267,41 @@ module.exports = {
 	 * @param userId
 	 */
 	dislike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-			db.models.user.update({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({songId}, next);
+			},
+
+			(song, next) => {
+				if (!song) return next('No song found with that id.');
+				next(null, song);
+			}
+		], (err, song) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("SONGS_DISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			let oldSongId = songId;
+			songId = song._id;
+			db.models.user.findOne({ _id: userId }, (err, user) => {
+				if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
+				db.models.user.update({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
+					if (!err) {
+						db.models.user.count({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-							db.models.song.update({songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+							db.models.user.count({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.dislike', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully disliked this song.' });
+								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err, res) => {
+									if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+									songs.updateSong(songId, (err, song) => {});
+									cache.pub('song.dislike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
+									return cb({ status: 'success', message: 'You have successfully disliked this song.' });
+								});
 							});
 						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+					} else return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+				});
 			});
 		});
 	}),
@@ -279,23 +315,62 @@ module.exports = {
 	 * @param userId
 	 */
 	undislike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.disliked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not disliked this song.' });
-			db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
-							db.models.song.update({songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.undislike', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully undisliked this song.' });
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({songId}, next);
+			},
+
+			(song, next) => {
+				if (!song) return next('No song found with that id.');
+				next(null, song);
+			}
+		], (err, song) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("SONGS_UNDISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			let oldSongId = songId;
+			songId = song._id;
+			db.models.user.findOne({_id: userId}, (err, user) => {
+				if (user.disliked.indexOf(songId) === -1) return cb({
+					status: 'failure',
+					message: 'You have not disliked this song.'
+				});
+				db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+					if (!err) {
+						db.models.user.count({"liked": songId}, (err, likes) => {
+							if (err) return cb({
+								status: 'failure',
+								message: 'Something went wrong while undisliking this song.'
+							});
+							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+								if (err) return cb({
+									status: 'failure',
+									message: 'Something went wrong while undisliking this song.'
+								});
+								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+									if (err) return cb({
+										status: 'failure',
+										message: 'Something went wrong while undisliking this song.'
+									});
+									songs.updateSong(songId, (err, song) => {
+									});
+									cache.pub('song.undislike', JSON.stringify({
+										songId: oldSongId,
+										userId: session.userId,
+										likes: likes,
+										dislikes: dislikes
+									}));
+									return cb({
+										status: 'success',
+										message: 'You have successfully undisliked this song.'
+									});
+								});
 							});
 						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
+					} else return cb({status: 'failure', message: 'Something went wrong while undisliking this song.'});
+				});
 			});
 		});
 	}),
@@ -309,23 +384,41 @@ module.exports = {
 	 * @param userId
 	 */
 	unlike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-			db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
-							db.models.song.update({songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.unlike', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully unliked this song.' });
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({songId}, next);
+			},
+
+			(song, next) => {
+				if (!song) return next('No song found with that id.');
+				next(null, song);
+			}
+		], (err, song) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("SONGS_UNLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			let oldSongId = songId;
+			songId = song._id;
+			db.models.user.findOne({ _id: userId }, (err, user) => {
+				if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
+				db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+					if (!err) {
+						db.models.user.count({"liked": songId}, (err, likes) => {
+							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+								if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
+								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+									if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+									songs.updateSong(songId, (err, song) => {});
+									cache.pub('song.unlike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
+									return cb({ status: 'success', message: 'You have successfully unliked this song.' });
+								});
 							});
 						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+					} else return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+				});
 			});
 		});
 	}),
@@ -339,20 +432,37 @@ module.exports = {
 	 * @param userId
 	 */
 	getOwnSongRatings: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({_id: userId}, (err, user) => {
-			if (!err && user) {
-				return cb({
-					status: 'success',
-					songId: songId,
-					liked: (user.liked.indexOf(songId) !== -1),
-					disliked: (user.disliked.indexOf(songId) !== -1)
-				});
-			} else {
-				return cb({
-					status: 'failure',
-					message: utils.getError(err)
-				});
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({songId}, next);
+			},
+
+			(song, next) => {
+				if (!song) return next('No song found with that id.');
+				next(null, song);
 			}
+		], (err, song) => {
+			if (err) {
+				err = utils.getError(err);
+				logger.error("SONGS_GET_OWN_RATINGS", `User "${userId}" failed to get ratings for ${songId}. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			let newSongId = song._id;
+			db.models.user.findOne({_id: userId}, (err, user) => {
+				if (!err && user) {
+					return cb({
+						status: 'success',
+						songId: songId,
+						liked: (user.liked.indexOf(newSongId) !== -1),
+						disliked: (user.disliked.indexOf(newSongId) !== -1)
+					});
+				} else {
+					return cb({
+						status: 'failure',
+						message: utils.getError(err)
+					});
+				}
+			});
 		});
 	})
 };

+ 62 - 12
backend/logic/actions/stations.js

@@ -78,12 +78,12 @@ setInterval(() => {
 		}
 
 		stationsCountUpdated.forEach((stationId) => {
-			console.log("Updating count of ", stationId);
+			//logger.info("UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
 			cache.pub('station.updateUserCount', stationId);
 		});
 
 		stationsUpdated.forEach((stationId) => {
-			console.log("Updating ", stationId);
+			//logger.info("UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
 			cache.pub('station.updateUsers', stationId);
 		});
 
@@ -121,6 +121,10 @@ cache.sub('station.updateUserCount', stationId => {
 	})
 });
 
+cache.sub('station.queueLockToggled', data => {
+	utils.emitToRoom(`station.${data.stationId}`, "event:queueLockToggled", data.locked)
+});
+
 cache.sub('station.updatePartyMode', data => {
 	utils.emitToRoom(`station.${data.stationId}`, "event:partyMode.updated", data.partyMode);
 });
@@ -241,7 +245,7 @@ module.exports = {
 				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("STATIONS_INDEX", `Indexing stations successful.`);
+			logger.success("STATIONS_INDEX", `Indexing stations successful.`, false);
 			return cb({'status': 'success', 'stations': stations});
 		});
 	},
@@ -269,7 +273,7 @@ module.exports = {
 				logger.error("STATIONS_FIND_BY_NAME", `Finding station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("STATIONS_FIND_BY_NAME", `Found station "${stationName}" successfully.`);
+			logger.success("STATIONS_FIND_BY_NAME", `Found station "${stationName}" successfully.`, false);
 			cb({status: 'success', data: station});
 		});
 	},
@@ -289,12 +293,12 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				if (station.type !== 'official') return next('This is not an official station.');
-				next();
+				else if (station.type !== 'official') return next('This is not an official station.');
+				else next();
 			},
 
 			(next) => {
-				cache.hget("officialPlaylists", stationId, next);
+				cache.hget('officialPlaylists', stationId, next);
 			},
 
 			(playlist, next) => {
@@ -305,10 +309,11 @@ module.exports = {
 			if (err) {
 				err = utils.getError(err);
 				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
+				return cb({ status: 'failure', message: err });
+			} else {
+				logger.success("STATIONS_GET_PLAYLIST", `Got playlist for station "${stationId}" successfully.`, false);
+				cb({ status: 'success', data: playlist.songs });
 			}
-			logger.success("STATIONS_GET_PLAYLIST", `Got playlist for station "${stationId}" successfully.`);
-			cb({status: 'success', data: playlist.songs})
 		});
 	},
 
@@ -364,6 +369,7 @@ module.exports = {
 					description: station.description,
 					displayName: station.displayName,
 					privacy: station.privacy,
+					locked: station.locked,
 					partyMode: station.partyMode,
 					owner: station.owner,
 					privatePlaylist: station.privatePlaylist
@@ -400,6 +406,39 @@ module.exports = {
 		});
 	},
 
+	/**
+	 * Toggles if a station is locked
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	toggleLock: hooks.ownerRequired((session, stationId, cb) => {
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				db.models.station.update({ _id: stationId }, { $set: { locked: !station.locked} }, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], (err, station) => {
+			if (err) {
+				err = 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 {
+				logger.success("STATIONS_UPDATE_LOCKED_STATUS", `Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`);
+				cache.pub('station.queueLockToggled', {stationId, locked: station.locked});
+				return cb({ status: 'success', data: station.locked });
+			}
+		});
+	}),
+
 	/**
 	 * Votes to skip a station
 	 *
@@ -844,6 +883,17 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found.');
+				if (station.locked) {
+					db.models.user.findOne({ _id: userId }, (err, user) => {
+						if (user.role !== 'admin' && station.owner !== userId) return next('Only owners and admins can add songs to a locked queue.');
+						else return next(null, station);
+					});
+				} else {
+					return next(null, station);
+				}
+			},
+
+			(station, next) => {
 				if (station.type !== 'community') return next('That station is not a community station.');
 				utils.canUserBeInStation(station, userId, (canBe) => {
 					if (canBe) return next(null, station);
@@ -969,10 +1019,10 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
+				db.models.station.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
 			},
 
-			(next) => {
+			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
 		], (err, station) => {

+ 187 - 7
backend/logic/actions/users.js

@@ -8,6 +8,7 @@ const bcrypt = require('bcrypt');
 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');
@@ -21,8 +22,15 @@ cache.sub('user.updateUsername', user => {
 	});
 });
 
+cache.sub('user.removeSessions', userId => {
+	utils.socketsFromUserWithoutCache(userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('keep.event:user.session.removed');
+		});
+	});
+});
+
 cache.sub('user.linkPassword', userId => {
-	console.log("LINK4", userId);
 	utils.socketsFromUser(userId, sockets => {
 		sockets.forEach(socket => {
 			socket.emit('event:user.linkPassword');
@@ -31,7 +39,6 @@ cache.sub('user.linkPassword', userId => {
 });
 
 cache.sub('user.linkGitHub', userId => {
-	console.log("LINK1", userId);
 	utils.socketsFromUser(userId, sockets => {
 		sockets.forEach(socket => {
 			socket.emit('event:user.linkGitHub');
@@ -40,7 +47,6 @@ cache.sub('user.linkGitHub', userId => {
 });
 
 cache.sub('user.unlinkPassword', userId => {
-	console.log("LINK2", userId);
 	utils.socketsFromUser(userId, sockets => {
 		sockets.forEach(socket => {
 			socket.emit('event:user.unlinkPassword');
@@ -49,7 +55,6 @@ cache.sub('user.unlinkPassword', userId => {
 });
 
 cache.sub('user.unlinkGitHub', userId => {
-	console.log("LINK3", userId);
 	utils.socketsFromUser(userId, sockets => {
 		sockets.forEach(socket => {
 			socket.emit('event:user.unlinkGitHub');
@@ -57,6 +62,15 @@ cache.sub('user.unlinkGitHub', userId => {
 	});
 });
 
+cache.sub('user.ban', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('keep.event:banned', data.punishment);
+			socket.disconnect(true);
+		});
+	});
+});
+
 module.exports = {
 
 	/**
@@ -280,15 +294,74 @@ module.exports = {
 			if (err && err !== true) {
 				err = utils.getError(err);
 				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
-				cb({status: 'failure', message: err});
+				cb({ status: 'failure', message: err });
 			} else {
 				logger.success("USER_LOGOUT", `Logout successful.`);
-				cb({status: 'success', message: 'Successfully logged out.'});
+				cb({ status: 'success', message: 'Successfully logged out.' });
 			}
 		});
 
 	},
 
+	/**
+	 * Removes all sessions for a user
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} userId - the id of the user we are trying to delete the sessions of
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} loggedInUser - the logged in userId automatically added by hooks
+	 */
+	removeSessions:  hooks.loginRequired((session, userId, cb, loggedInUser) => {
+
+		async.waterfall([
+
+			(next) => {
+				db.models.user.findOne({ _id: loggedInUser }, (err, user) => {
+					if (user.role !== 'admin' && loggedInUser !== userId) return next('Only admins and the owner of the account can remove their sessions.');
+					else return next();
+				});
+			},
+
+			(next) => {
+				cache.hgetall('sessions', next);
+			},
+
+			(sessions, next) => {
+				if (!sessions) return next('There are no sessions for this user to remove.');
+				else {
+					let keys = Object.keys(sessions);
+					next(null, keys, sessions);
+				}
+			},
+
+			(keys, sessions, next) => {
+				cache.pub('user.removeSessions', userId);
+				async.each(keys, (sessionId, callback) => {
+					let session = sessions[sessionId];
+					if (session.userId === userId) {
+						cache.hdel('sessions', sessionId, err => {
+							if (err) return callback(err);
+							else callback(null);
+						});
+					}
+				}, err => {
+					next(err);
+				});
+			}
+
+		], err => {
+			if (err) {
+				err = 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 {
+				logger.success("REMOVE_SESSIONS_FOR_USER", `Removed all sessions for user "${userId}".`);
+				return cb({ status: 'success', message: 'Successfully removed all sessions.' });
+			}
+		});
+
+	}),
+
 	/**
 	 * Gets user object from username (only a few properties)
 	 *
@@ -330,6 +403,34 @@ module.exports = {
 		});
 	},
 
+
+	/**
+	 * Gets a username from an userId
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} userId - the userId of the person we are trying to get the username from
+	 * @param {Function} cb - gets called with the result
+	 */
+	getUsernameFromId: (session, userId, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ _id: userId }, next);
+			},
+		], (err, user) => {
+			if (err && err !== true) {
+				err = utils.getError(err);
+				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+				return cb({
+					status: 'success',
+					data: user.username
+				});
+			}
+		});
+	},
+
 	//TODO Fix security issues
 	/**
 	 * Gets user info from session
@@ -925,5 +1026,84 @@ module.exports = {
 				});
 			}
 		});
-	}
+	},
+
+	/**
+	 * Bans a user by userId
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} value - the user id that is going to be banned
+	 * @param {String} reason - the reason for the ban
+	 * @param {String} expiresAt - the time the ban expires
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	banUserById: hooks.adminRequired((session, value, reason, expiresAt, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				if (value === '') return next('You must provide an IP address to ban.');
+				else if (reason === '') return next('You must provide a reason for the ban.');
+				else return next();
+			},
+
+			(next) => {
+				if (!expiresAt || typeof expiresAt !== 'string') return next('Invalid expire date.');
+				let date = new Date();
+				switch(expiresAt) {
+					case '1h':
+						expiresAt = date.setHours(date.getHours() + 1);
+						break;
+					case '12h':
+						expiresAt = date.setHours(date.getHours() + 12);
+						break;
+					case '1d':
+						expiresAt = date.setDate(date.getDate() + 1);
+						break;
+					case '1w':
+						expiresAt = date.setDate(date.getDate() + 7);
+						break;
+					case '1m':
+						expiresAt = date.setMonth(date.getMonth() + 1);
+						break;
+					case '3m':
+						expiresAt = date.setMonth(date.getMonth() + 3);
+						break;
+					case '6m':
+						expiresAt = date.setMonth(date.getMonth() + 6);
+						break;
+					case '1y':
+						expiresAt = date.setFullYear(date.getFullYear() + 1);
+						break;
+					case 'never':
+						expiresAt = new Date(3093527980800000);
+						break;
+					default:
+						return next('Invalid expire date.');
+				}
+
+				next();
+			},
+
+			(next) => {
+				punishments.addPunishment('banUserId', value, reason, expiresAt, userId, next)
+			},
+
+			(punishment, next) => {
+				cache.pub('user.ban', {userId: value, punishment});
+				next();
+			},
+		], (err) => {
+			if (err && err !== true) {
+				err = 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 {
+				logger.success("BAN_USER_BY_ID", `User ${userId} has successfully banned user ${value} with the reason ${reason}.`);
+				cb({
+					status: 'success',
+					message: 'Successfully banned user.'
+				});
+			}
+		});
+	})
 };

+ 7 - 0
backend/logic/api.js

@@ -1,3 +1,5 @@
+let lockdown = false;
+
 module.exports = {
 	init: (cb) => {
 		const { app } = require('./app.js');
@@ -22,6 +24,11 @@ module.exports = {
 			})
 		});
 
+		if (lockdown) return this._lockdown();
 		cb();
+	},
+
+	_lockdown: () => {
+		lockdown = true;
 	}
 }

+ 14 - 1
backend/logic/app.js

@@ -16,6 +16,8 @@ const cache = require('./cache');
 const db = require('./db');
 
 let utils;
+let initialized = false;
+let lockdown = false;
 
 const lib = {
 
@@ -52,6 +54,7 @@ const lib = {
 		let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
 
 		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`,
@@ -61,6 +64,7 @@ const lib = {
 		});
 
 		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`,
@@ -75,6 +79,7 @@ const lib = {
 		}
 
 		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;
@@ -134,7 +139,7 @@ const lib = {
 							next(true, user._id);
 						});
 					}
-					db.models.user.findOne({username: new RegExp(`^${body.login}$`, 'i')}, (err, user) => {
+					db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i' )}, (err, user) => {
 						next(err, user);
 					});
 				},
@@ -231,7 +236,15 @@ const lib = {
 			});
 		});
 
+		initialized = true;
+
+		if (lockdown) return this._lockdown();
 		cb();
+	},
+
+	_lockdown: () => {
+		lib.server.close();
+		lockdown = true;
 	}
 };
 

+ 32 - 16
backend/logic/cache/index.js

@@ -6,41 +6,49 @@ const mongoose = require('mongoose');
 // Lightweight / convenience wrapper around redis module for our needs
 
 const pubs = {}, subs = {};
-let initialized = false;
 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')
+		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, cb) => {
+	init: (url, password, errorCb, cb) => {
+		lib.errorCb = errorCb;
 		lib.url = url;
+		lib.password = password;
 
-		lib.client = redis.createClient({ url: lib.url });
+		lib.client = redis.createClient({ url: lib.url, password: lib.password });
 		lib.client.on('error', (err) => {
-			console.error(err);
-			process.exit();
+			if (lockdown) return;
+			errorCb('Cache connection error.', err, 'Cache');
 		});
 
-		initialized = true;
 		callbacks.forEach((callback) => {
 			callback();
 		});
 
+		initialized = true;
+
+		if (lockdown) return this._lockdown();
 		cb();
 	},
 
@@ -48,9 +56,11 @@ const lib = {
 	 * Gracefully closes all the Redis client connections
 	 */
 	quit: () => {
-		lib.client.quit();
-		Object.keys(pubs).forEach((channel) => pubs[channel].quit());
-		Object.keys(subs).forEach((channel) => subs[channel].client.quit());
+		if (lib.client.connected) {
+			lib.client.quit();
+			Object.keys(pubs).forEach((channel) => pubs[channel].quit());
+			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
+		}
 	},
 
 	/**
@@ -63,6 +73,7 @@ const lib = {
 	 * @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');
 		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);
@@ -84,6 +95,7 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 */
 	hget: (table, key, cb, parseJson = true) => {
+		if (lockdown) return cb('Lockdown');
 		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) => {
@@ -104,6 +116,7 @@ const lib = {
 	 * @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');
 		if (!key || !table) return cb(null, null);
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		lib.client.hdel(table, key, (err) => {
@@ -120,10 +133,12 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 */
 	hgetall: (table, cb, parseJson = true) => {
+		if (lockdown) return cb('Lockdown');
 		if (!table) return cb(null, null);
 		lib.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);
 		});
 	},
@@ -156,6 +171,7 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - parse the message as JSON
 	 */
 	sub: (channel, cb, parseJson = true) => {
+		if (lockdown) return;
 		if (initialized) subToChannel();
 		else {
 			callbacks.push(() => {
@@ -164,11 +180,7 @@ const lib = {
 		}
 		function subToChannel() {
 			if (subs[channel] === undefined) {
-				subs[channel] = { client: redis.createClient({ url: lib.url }), cbs: [] };
-				subs[channel].client.on('error', (err) => {
-					console.error(err);
-					process.exit();
-				});
+				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));
@@ -178,8 +190,12 @@ const lib = {
 
 			subs[channel].cbs.push(cb);
 		}
-	}
+	},
 
+	_lockdown: () => {
+		lib.quit();
+		lockdown = true;
+	}
 };
 
 module.exports = lib;

+ 5 - 0
backend/logic/cache/schemas/punishment.js

@@ -0,0 +1,5 @@
+'use strict';
+
+module.exports = (punishment, punishmentId) => {
+	return { type: punishment.type, value: punishment.value, reason: punishment.reason, expiresAt: new Date(punishment.expiresAt).getTime(), punishmentId };
+};

+ 18 - 6
backend/logic/db/index.js

@@ -1,6 +1,7 @@
 'use strict';
 
 const mongoose = require('mongoose');
+const config = require('config');
 
 const bluebird = require('bluebird');
 
@@ -18,19 +19,20 @@ const isLength = (string, min, max) => {
 
 mongoose.Promise = bluebird;
 
+let initialized = false;
+let lockdown = false;
+
 let lib = {
 
 	connection: null,
 	schemas: {},
 	models: {},
 
-	init: (url, cb) => {
-
+	init: (url, errorCb,  cb) => {
 		lib.connection = mongoose.connect(url).connection;
 
 		lib.connection.on('error', err => {
-			console.error('Database error: ' + err.message)
-			process.exit();
+			errorCb('Database connection error.', err, 'DB');
 		});
 
 		lib.connection.once('open', _ => {
@@ -42,7 +44,8 @@ let lib = {
 				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`))
+				report: new mongoose.Schema(require(`./schemas/report`)),
+				punishment: new mongoose.Schema(require(`./schemas/punishment`))
 			};
 
 			lib.models = {
@@ -52,7 +55,8 @@ let lib = {
 				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)
+				report: mongoose.model('report', lib.schemas.report),
+				punishment: mongoose.model('punishment', lib.schemas.punishment)
 			};
 
 			lib.schemas.user.path('username').validate((username) => {
@@ -184,6 +188,9 @@ let lib = {
 				return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
 			}, 'Invalid description.');
 
+			initialized = true;
+
+			if (lockdown) return this._lockdown();
 			cb();
 		});
 	},
@@ -191,6 +198,11 @@ let lib = {
 	passwordValid: (password) => {
 		if (!isLength(password, 6, 200)) return false;
 		return regex.password.test(password);
+	},
+
+	_lockdown: () => {
+		lib.connection.close();
+		lockdown = true;
 	}
 };
 

+ 9 - 0
backend/logic/db/schemas/punishment.js

@@ -0,0 +1,9 @@
+module.exports = {
+	type: { type: String, enum: ["banUserId", "banUserIp"], required: true },
+	value: { type: String, required: true },
+	reason: { type: String, required: true, default: 'Unknown' },
+	active: { type: Boolean, required: true, default: true },
+	expiresAt: { type: Date, required: true },
+	punishedAt: { type: Date, default: Date.now(), required: true },
+	punishedBy: { type: String, required: true }
+};

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

@@ -24,12 +24,6 @@ module.exports = {
 			access_token: String
 		}
 	},
-	ban: {
-		banned: { type: Boolean, default: false, required: true },
-		reason: String,
-		bannedAt: Date,
-		bannedUntil: Date
-	},
 	statistics: {
 		songsRequested: { type: Number, default: 0, required: true }
 	},

+ 113 - 73
backend/logic/io.js

@@ -8,6 +8,11 @@ const async = require('async');
 const cache = require('./cache');
 const utils = require('./utils');
 const db = require('./db');
+const logger = require('./logger');
+const punishments = require('./punishments');
+
+let initialized = false;
+let lockdown = false;
 
 module.exports = {
 
@@ -18,9 +23,12 @@ module.exports = {
 		this.io = require('socket.io')(app.server);
 
 		this.io.use((socket, next) => {
+			if (lockdown) return;
 			let cookies = socket.request.headers.cookie;
 			let SID = utils.cookies.parseCookies(cookies).SID;
 
+			socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
+
 			async.waterfall([
 				(next) => {
 					if (!SID) return next('No SID.');
@@ -34,93 +42,125 @@ module.exports = {
 					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;
+						});
+						socket.banned = banned;
+						socket.ban = ban;
+						next();
+					});
 				}
 			], () => {
 				if (!socket.session) {
-					socket.session = {socketId: socket.id};
+					socket.session = { socketId: socket.id };
 				} else socket.session.socketId = socket.id;
 				next();
 			});
 		});
 
 		this.io.on('connection', socket => {
-			console.info('User has connected');
-
-			// catch when the socket has been disconnected
-			socket.on('disconnect', () => {
-
-				// remove the user from their current station (if any)
-				if (socket.session) {
-					//actions.stations.leave(socket.sessionId, result => {});
-					// Remove session from Redis
-					//cache.hdel('sessions', socket.session.sessionId);
-				}
-
-				console.info('User has disconnected');
-			});
-
-			// catch errors on the socket (internal to socket.io)
-			socket.on('error', err => console.error(err));
-
-			// 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, function () {
-
-						let args = Array.prototype.slice.call(arguments, 0, -1);
-						let cb = arguments[arguments.length - 1];
-
-						// 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'
-								});
-							}
-
-							// make sure the sockets sessionId isn't set if there is no session
-							if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+			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}`);
+				});
+
+				// catch errors on the socket (internal to socket.io)
+				socket.on('error', err => console.error(err));
+
+				// 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, function () {
+							let args = Array.prototype.slice.call(arguments, 0, -1);
+							let cb = arguments[arguments.length - 1];
+
+							if (lockdown) 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'
+									});
+								}
 
-							// 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);
+								// 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 (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);
 					})
-				})
-			});
-
-			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);
+				} else socket.emit('ready', false);
+			}
 		});
 
+		initialized = true;
+
+		if (lockdown) return this._lockdown();
 		cb();
+	},
+
+	_lockdown: () => {
+		this.io.close();
+		let connected = this.io.of('/').connected;
+		for (let key in connected) {
+			connected[key].disconnect('Lockdown');
+		}
+		lockdown = true;
 	}
 
-};
+};

+ 56 - 25
backend/logic/logger.js

@@ -3,6 +3,8 @@
 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}`)) {
@@ -70,18 +72,26 @@ let twoDigits = (num) => {
 	return (num < 10) ? '0' + num : num;
 };
 
-let getTime = (cb) => {
+let getTime = () => {
 	let time = new Date();
-	return cb ({
+	return {
 		year: time.getFullYear(),
 		month: time.getMonth() + 1,
 		day: time.getDate(),
 		hour: time.getHours(),
 		minute: time.getMinutes(),
 		second: time.getSeconds()
-	});
+	}
 };
 
+let 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');
@@ -91,60 +101,77 @@ module.exports = {
 		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) => {
+	success: (type, message, display = true) => {
+		if (lockdown) return;
 		success++;
 		successThisMinute++;
 		successThisHour++;
-		getTime((time) => {
-			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			fs.appendFile(dir + '/all.log', `${timeString} SUCCESS - ${type} - ${message}\n`, ()=>{});
-			fs.appendFile(dir + '/success.log', `${timeString} SUCCESS - ${type} - ${message}\n`, ()=>{});
-			console.info('\x1b[32m', timeString, 'SUCCESS', '-', type, '-', message, '\x1b[0m');
-		});
+		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) => {
+	error: (type, message, display = true) => {
+		if (lockdown) return;
 		error++;
 		errorThisMinute++;
 		errorThisHour++;
-		getTime((time) => {
-			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			fs.appendFile(dir + '/all.log', `${timeString} ERROR - ${type} - ${message}\n`, ()=>{});
-			fs.appendFile(dir + '/error.log', `${timeString} ERROR - ${type} - ${message}\n`, ()=>{});
-			console.warn('\x1b[31m', timeString, 'ERROR', '-', type, '-', message, '\x1b[0m');
-		});
+		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) => {
+	info: (type, message, display = true) => {
+		if (lockdown) return;
 		info++;
 		infoThisMinute++;
 		infoThisHour++;
-		getTime((time) => {
-			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			fs.appendFile(dir + '/all.log', `${timeString} INFO - ${type} - ${message}\n`, ()=>{});
-			fs.appendFile(dir + '/info.log', `${timeString} INFO - ${type} - ${message}\n`, ()=>{});
-
-			console.info('\x1b[36m', timeString, 'INFO', '-', type, '-', message, '\x1b[0m');
-		});
+		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: {
@@ -169,5 +196,9 @@ module.exports = {
 			}
 		});
 		setTimeout(_this.calculate, 1000 * 30);
+	},
+
+	_lockdown: () => {
+		lockdown = true;
 	}
 };

+ 21 - 2
backend/logic/mail/index.js

@@ -1,7 +1,17 @@
 'use strict';
 
 const config = require('config');
-const mailgun = require('mailgun-js')({apiKey: config.get("apis.mailgun.key"), domain: config.get("apis.mailgun.domain")});
+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")
+	});
+}
+
+let initialized = false;
+let lockdown = false;
 
 let lib = {
 
@@ -14,12 +24,21 @@ let lib = {
 			passwordRequest: require('./schemas/passwordRequest')
 		};
 
+		initialized = true;
+
+		if (lockdown) return this._lockdown();
 		cb();
 	},
 
 	sendMail: (data, cb) => {
+		if (lockdown) return cb('Lockdown');
 		if (!cb) cb = ()=>{};
-		mailgun.messages().send(data, cb);
+		if (enabled) mailgun.messages().send(data, cb);
+		else cb();
+	},
+
+	_lockdown: () => {
+		lockdown = true;
 	}
 };
 

+ 42 - 16
backend/logic/notifications.js

@@ -2,34 +2,46 @@
 
 const crypto = require('crypto');
 const redis = require('redis');
-
-let pub = null;
-let sub = null;
+const logger = require('./logger');
 
 const subscriptions = [];
 
+let initialized = false;
+let lockdown = false;
+let errorCb;
+
 const lib = {
 
+	pub: null,
+	sub: null,
+	errorCb: null,
+
 	/**
 	 * 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, cb) => {
-		pub = redis.createClient({ url: url });
-		sub = redis.createClient({ url: url });
-		sub.on('error', (err) => {
-			console.error(err);
-			process.exit();
+	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');
 		});
-		sub.on('pmessage', (pattern, channel, expiredKey) => {
+		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();
 			});
 		});
-		sub.psubscribe('__keyevent@0__:expired');
+		lib.sub.psubscribe('__keyevent@0__:expired');
+
+		initialized = true;
+
+		if (lockdown) return this._lockdown();
 		cb();
 	},
 
@@ -42,9 +54,12 @@ 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) => {
+	schedule: (name, time, cb, station) => {
+		if (lockdown) return;
+		if (!cb) cb = ()=>{};
 		time = Math.round(time);
-		pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
+		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);
 	},
 
 	/**
@@ -55,8 +70,10 @@ 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) => {
-		if (unique && subscriptions.find((subscription) => subscription.originalName == name)) return;
+	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)};`);
+		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;
@@ -68,13 +85,22 @@ const lib = {
 	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
 	 */
 	remove: (subscription) => {
+		if (lockdown) return;
 		let index = subscriptions.indexOf(subscription);
 		if (index) subscriptions.splice(index, 1);
 	},
 
 	unschedule: (name) => {
-		pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
+		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;

+ 14 - 2
backend/logic/playlists.js

@@ -4,6 +4,9 @@ const cache = require('./cache');
 const db = require('./db');
 const async = require('async');
 
+let initialized = false;
+let lockdown = false;
+
 module.exports = {
 
 	/**
@@ -41,10 +44,12 @@ module.exports = {
 				}, next);
 			}
 		], (err) => {
+			if (lockdown) return this._lockdown();
 			if (err) {
-				console.log(`FAILED TO INITIALIZE PLAYLISTS. ABORTING. "${err.message}"`);
-				process.exit();
+				err = utils.getError(err);
+				cb(err);
 			} else {
+				initialized = true;
 				cb();
 			}
 		});
@@ -57,6 +62,7 @@ module.exports = {
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
 	getPlaylist: (playlistId, cb) => {
+		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 			(next) => {
 				cache.hgetall('playlists', next);
@@ -104,6 +110,7 @@ module.exports = {
 	 * @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([
 
 			(next) => {
@@ -131,6 +138,7 @@ module.exports = {
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
 	deletePlaylist: (playlistId, cb) => {
+		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 
 			(next) => {
@@ -146,5 +154,9 @@ module.exports = {
 
 			cb(null);
 		});
+	},
+
+	_lockdown: () => {
+		lockdown = true;
 	}
 };

+ 235 - 0
backend/logic/punishments.js

@@ -0,0 +1,235 @@
+'use strict';
+
+const cache = require('./cache');
+const db = require('./db');
+const io = require('./io');
+const utils = require('./utils');
+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);
+			},
+
+			(next) => {
+				db.models.punishment.find({}, next);
+			},
+
+			(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();
+			}
+		});
+	},
+
+	/**
+	 * 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');
+		let punishmentsToRemove = [];
+		async.waterfall([
+			(next) => {
+				cache.hgetall('punishments', next);
+			},
+
+			(punishmentsObj, next) => {
+				let punishments = [];
+				for (let id in punishmentsObj) {
+					let obj = punishmentsObj[id];
+					obj.punishmentId = id;
+					punishments.push(obj);
+				}
+				punishments = punishments.filter(punishment => {
+					if (punishment.expiresAt < Date.now()) punishmentsToRemove.push(punishment);
+					return punishment.expiresAt > Date.now();
+				});
+				next(null, punishments);
+			},
+
+			(punishments, next) => {
+				async.each(
+					punishmentsToRemove,
+					(punishment, next2) => {
+						cache.hdel('punishments', punishment.punishmentId, () => {
+							next2();
+						});
+					},
+					() => {
+						next(null, punishments);
+					}
+				);
+			}
+		], (err, punishments) => {
+			if (err && err !== true) return cb(err);
+
+			cb(null, punishments);
+		});
+	},
+
+	/**
+	 * Gets a punishment by id
+	 *
+	 * @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.waterfall([
+
+			(next) => {
+				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
+				cache.hget('punishments', id, next);
+			},
+
+			(punishment, next) => {
+				if (punishment) return next(true, punishment);
+				db.models.punishment.findOne({_id: id}, next);
+			},
+
+			(punishment, next) => {
+				if (punishment) {
+					cache.hset('punishments', id, punishment, next);
+				} else next('Punishment not found.');
+			},
+
+		], (err, punishment) => {
+			if (err && err !== true) return cb(err);
+
+			cb(null, punishment);
+		});
+	},
+
+	/**
+	 * Gets all punishments from a userId
+	 *
+	 * @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.waterfall([
+			(next) => {
+				module.exports.getPunishments(next);
+			},
+			(punishments, next) => {
+				punishments = punishments.filter((punishment) => {
+					return punishment.type === 'banUserId' && punishment.value === userId;
+				});
+				next(null, punishments);
+			}
+		], (err, punishments) => {
+			if (err && err !== true) return cb(err);
+
+			cb(null, punishments);
+		});
+	},
+
+	addPunishment: function(type, value, reason, expiresAt, punishedBy, cb) {
+		if (lockdown) return cb('Lockdown');
+		async.waterfall([
+			(next) => {
+				const punishment = new db.models.punishment({
+					type,
+					value,
+					reason,
+					active: true,
+					expiresAt,
+					punishedAt: Date.now(),
+					punishedBy
+				});
+				punishment.save((err, punishment) => {
+					if (err) return next(err);
+					next(null, punishment);
+				});
+			},
+
+			(punishment, next) => {
+				cache.hset('punishments', punishment._id, cache.schemas.punishment(punishment, punishment._id), (err) => {
+					next(err, punishment);
+				});
+			},
+
+			(punishment, next) => {
+				// DISCORD MESSAGE
+				next(null, punishment);
+			}
+		], (err, punishment) => {
+			cb(err, punishment);
+		});
+	},
+
+	removePunishmentFromCache: function(punishmentId, cb) {
+		if (lockdown) return cb('Lockdown');
+		async.waterfall([
+			(next) => {
+				const punishment = new db.models.punishment({
+					type,
+					value,
+					reason,
+					active: true,
+					expiresAt,
+					punishedAt: Date.now(),
+					punishedBy
+				});
+				punishment.save((err, punishment) => {
+					console.log(err);
+					if (err) return next(err);
+					next(null, punishment);
+				});
+			},
+
+			(punishment, next) => {
+				cache.hset('punishments', punishment._id, punishment, next);
+			},
+
+			(punishment, next) => {
+				// DISCORD MESSAGE
+				next();
+			}
+		], (err) => {
+			cb(err);
+		});
+	},
+
+	_lockdown: () => {
+		lockdown = true;
+	}
+};

+ 21 - 9
backend/logic/songs.js

@@ -7,6 +7,9 @@ const utils = require('./utils');
 const async = require('async');
 const mongoose = require('mongoose');
 
+let initialized = false;
+let lockdown = false;
+
 module.exports = {
 
 	/**
@@ -42,10 +45,14 @@ module.exports = {
 				}, next);
 			}
 		], (err) => {
+			if (lockdown) return this._lockdown();
 			if (err) {
-				console.log(`FAILED TO INITIALIZE SONGS. ABORTING. "${err.message}"`);
-				process.exit();
-			} else cb();
+				err = utils.getError(err);
+				cb(err);
+			} else {
+				initialized = true;
+				cb();
+			}
 		});
 	},
 
@@ -56,6 +63,7 @@ module.exports = {
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
 	getSong: function(id, cb) {
+		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 
 			(next) => {
@@ -84,20 +92,18 @@ module.exports = {
 	/**
 	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
 	 *
-	 * @param {String} songId - the id of the song we are trying to get
+	 * @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.waterfall([
-
 			(next) => {
-				db.models.song.findOne({songId}, next);
+				db.models.song.findOne({ songId }, next);
 			}
-
 		], (err, song) => {
 			if (err && err !== true) return cb(err);
-
-			cb(null, song);
+			else return cb(null, song);
 		});
 	},
 
@@ -108,6 +114,7 @@ module.exports = {
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
 	updateSong: (songId, cb) => {
+		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 
 			(next) => {
@@ -137,6 +144,7 @@ module.exports = {
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
 	deleteSong: (songId, cb) => {
+		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 
 			(next) => {
@@ -152,5 +160,9 @@ module.exports = {
 
 			cb(null);
 		});
+	},
+
+	_lockdown: () => {
+		lockdown = true;
 	}
 };

+ 40 - 17
backend/logic/stations.js

@@ -9,16 +9,24 @@ const songs = require('./songs');
 const notifications = require('./notifications');
 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);
@@ -27,6 +35,7 @@ cache.sub('station.queueUpdate', (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);
@@ -75,14 +84,19 @@ module.exports = {
 				}, next);
 			}
 		], (err) => {
+			if (lockdown) return this._lockdown();
 			if (err) {
-				console.log(`FAILED TO INITIALIZE STATIONS. ABORTING. "${err.message}"`);
-				process.exit();
-			} else cb();
+				err = utils.getError(err);
+				cb(err);
+			} else {
+				initialized = true;
+				cb();
+			}
 		});
 	},
 
 	initializeStation: function(stationId, cb) {
+		if (lockdown) return;
 		if (typeof cb !== 'function') cb = ()=>{};
 		let _this = this;
 		async.waterfall([
@@ -91,11 +105,10 @@ module.exports = {
 			},
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true);
-				if (station.paused) {
-					notifications.unschedule(`stations.nextSong?id${station._id}`);
-					return next(true, station);
-				}
+				notifications.unschedule(`stations.nextSong?id=${station._id}`);
+				if (subscription) notifications.remove(subscription);
+				subscription = 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) => {
@@ -112,7 +125,7 @@ module.exports = {
 						next(err, station);
 					});
 				} else {
-					notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft);
+					notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
 					next(null, station);
 				}
 			}
@@ -123,10 +136,10 @@ module.exports = {
 	},
 
 	calculateSongForStation: function(station, cb) {
+		if (lockdown) return;
 		let _this = this;
 		let songList = [];
 		async.waterfall([
-
 			(next) => {
 				let genresDone = [];
 				station.genres.forEach((genre) => {
@@ -181,9 +194,9 @@ module.exports = {
 
 	// 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.waterfall([
-
 			(next) => {
 				cache.hget('stations', stationId, next);
 			},
@@ -196,7 +209,7 @@ module.exports = {
 			(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);
@@ -212,11 +225,12 @@ module.exports = {
 
 	// 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.waterfall([
 
 			(next) => {
-				db.models.station.findOne({name: stationName}, next);
+				db.models.station.findOne({ name: stationName }, next);
 			},
 
 			(station, next) => {
@@ -237,6 +251,7 @@ module.exports = {
 	},
 
 	updateStation: function(stationId, cb) {
+		if (lockdown) return;
 		let _this = this;
 		async.waterfall([
 
@@ -259,10 +274,10 @@ module.exports = {
 	},
 
 	calculateOfficialPlaylistList: (stationId, songList, cb) => {
+		if (lockdown) return;
 		let lessInfoPlaylist = [];
-
 		async.each(songList, (song, next) => {
-			songs.getSongFromId(song, (err, song) => {
+			songs.getSong(song, (err, song) => {
 				if (!err && song) {
 					let newSong = {
 						songId: song.songId,
@@ -283,9 +298,11 @@ module.exports = {
 	},
 
 	skipStation: function(stationId) {
-		console.log("SKIP!", stationId);
+		if (lockdown) return;
+		logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
 		let _this = this;
 		return (cb) => {
+			if (lockdown) return;
 			if (typeof cb !== 'function') cb = ()=>{};
 
 			async.waterfall([
@@ -377,6 +394,7 @@ module.exports = {
 							songId: song.songId,
 							title: song.title,
 							duration: song.duration,
+							skipDuration: 0,
 							likes: -1,
 							dislikes: -1
 						};
@@ -444,7 +462,7 @@ module.exports = {
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
 						utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
 						if (!station.paused) {
-							notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000);
+							notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
 						}
 					} else {
 						utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`));
@@ -463,8 +481,13 @@ module.exports = {
 		songId: '60ItHLz5WEA',
 		title: 'Faded - Alan Walker',
 		duration: 212,
+		skipDuration: 0,
 		likes: -1,
 		dislikes: -1
+	},
+
+	_lockdown: () => {
+		lockdown = true;
 	}
 
 };

+ 20 - 4
backend/logic/tasks.js

@@ -3,6 +3,7 @@
 const cache = require("./cache");
 const logger = require("./logger");
 const Stations = require("./stations");
+const notifications = require("./notifications");
 const async = require("async");
 let utils;
 let tasks = {};
@@ -17,7 +18,7 @@ let testTask = (callback) => {
 };
 
 let checkStationSkipTask = (callback) => {
-	logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`);
+	logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
 	async.waterfall([
 		(next) => {
 			cache.hgetall('stations', next);
@@ -29,7 +30,7 @@ let checkStationSkipTask = (callback) => {
 				if (timeElapsed <= station.currentSong.duration) return next2();
 				else {
 					logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
-					Stations.skipStation(station._id);
+					Stations.initializeStation(station._id);
 					next2();
 				}
 			}, () => {
@@ -42,7 +43,7 @@ let checkStationSkipTask = (callback) => {
 };
 
 let sessionClearingTask = (callback) => {
-	logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`);
+	logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
 	async.waterfall([
 		(next) => {
 			cache.hgetall('sessions', next);
@@ -90,6 +91,9 @@ let sessionClearingTask = (callback) => {
 	});
 };
 
+let initialized = false;
+let lockdown = false;
+
 module.exports = {
 	init: function(cb) {
 		utils = require('./utils');
@@ -97,9 +101,13 @@ module.exports = {
 		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;
 		tasks[name] = {
 			name,
 			fn,
@@ -110,12 +118,13 @@ module.exports = {
 		if (!paused) this.handleTask(tasks[name]);
 	},
 	pauseTask: (name) => {
-		tasks[name].timer.pause();
+		if (tasks[name].timer) tasks[name].timer.pause();
 	},
 	resumeTask: (name) => {
 		tasks[name].timer.resume();
 	},
 	handleTask: function(task) {
+		if (lockdown) return;
 		if (task.timer) task.timer.pause();
 
 		task.fn(() => {
@@ -124,5 +133,12 @@ module.exports = {
 				this.handleTask(task);
 			}, task.timeout, false);
 		});
+	},
+	_lockdown: function() {
+		for (let key in tasks) {
+			this.pauseTask(key);
+		}
+		tasks = {};
+		lockdown = true;
 	}
 };

+ 30 - 4
backend/logic/utils.js

@@ -163,9 +163,22 @@ module.exports = {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
 				cache.hget('sessions', session.sessionId, (err, session) => {
-					if (!err && session && session.userId === userId) {
-						sockets.push(ns.connected[id]);
-					}
+					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
+					next();
+				});
+			}, () => {
+				cb(sockets);
+			});
+		}
+	},
+	socketsFromIP: function(ip, cb) {
+		let ns = 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) => {
+					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
 					next();
 				});
 			}, () => {
@@ -173,6 +186,19 @@ module.exports = {
 			});
 		}
 	},
+	socketsFromUserWithoutCache: function(userId, cb) {
+		let ns = io.io.of("/");
+		let sockets = [];
+		if (ns) {
+			async.each(Object.keys(ns.connected), (id, next) => {
+				let session = ns.connected[id].session;
+				if (session.userId === userId) sockets.push(ns.connected[id]);
+				next();
+			}, () => {
+				cb(sockets);
+			});
+		}
+	},
 	socketLeaveRooms: function(socketid) {
 		let socket = this.socketFromSession(socketid);
 		let rooms = socket.rooms;
@@ -295,7 +321,7 @@ module.exports = {
 		}
 	},
 	getPlaylistFromYouTube: (url, cb) => {
-		
+
 		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
 		let playlistId = regex.exec(url)[1];

+ 1 - 0
backend/package.json

@@ -18,6 +18,7 @@
     "connect-mongo": "^1.3.2",
     "cookie-parser": "^1.4.3",
     "cors": "^2.8.1",
+    "discord.js": "^11.0.0",
     "express": "^4.14.0",
     "express-session": "^1.14.0",
     "mailgun-js": "^0.8.0",

+ 0 - 30
docker-compose-production.yml

@@ -1,30 +0,0 @@
-version: '2'
-services:
-  backend:
-    build: ./backend
-    ports:
-    - "8081:8081"
-    volumes:
-    - ./backend:/opt/app
-    links:
-    - mongo
-    - redis
-    environment:
-    - NGINX_PORT=81
-  frontend:
-    build: ./frontend
-    ports:
-    - "81:81"
-    volumes:
-    - ./frontend:/opt/app
-  mongo:
-    image: mongo
-    ports:
-    - "27017:27017"
-  mongoclient:
-    image: mongoclient/mongoclient
-    ports:
-    - "3000:3000"
-  redis:
-    image: redis
-    command: "--notify-keyspace-events Ex"

+ 9 - 6
docker-compose.yml

@@ -3,29 +3,32 @@ services:
   backend:
     build: ./backend
     ports:
-    - "8080:8080"
+    - "${BACKEND_PORT}:8080"
     volumes:
     - ./backend:/opt/app
     - ./log:/opt/log
     links:
     - mongo
     - redis
-    environment:
-    - NGINX_PORT=80
   frontend:
     build: ./frontend
     ports:
-    - "80:80"
+    - "${FRONTEND_PORT}:80"
     volumes:
     - ./frontend:/opt/app
   mongo:
     image: mongo
     ports:
-    - "27017:27017"
+    - "27018:27018"
+    command: "--auth"
   mongoclient:
     image: mongoclient/mongoclient
     ports:
     - "3000:3000"
   redis:
     image: redis
-    command: "--notify-keyspace-events Ex"
+    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
+    volumes:
+    - .redis:/data
+    ports:
+    - "6371:6371"

+ 148 - 0
fallback.html

@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>Musare</title>
+
+        <link href="https://fonts.googleapis.com/css?family=Lato:100" rel="stylesheet" type="text/css">
+        <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
+        <style media="screen"></style>
+        <style>
+            html, body {
+                height: 100%;
+            }
+            body {
+                margin: 0;
+                padding: 0;
+                width: 100%;
+                display: table;
+                font-weight: 100;
+                font-family: 'Lato';
+            }
+            .container {
+                text-align: center;
+                display: table-cell;
+                vertical-align: middle;
+            }
+            .content {
+                text-align: center;
+                display: inline-block;
+                padding-left: 15px;
+                padding-right: 15px;
+            }
+            .title {
+                font-size: 96px;
+            }
+            p {
+              font-size: 28px;
+              font-weight: 700;
+            }
+            .social {
+                width: 100%;
+                text-align:center;
+                display: block;
+            }
+            .social #discord {
+                display: inline-block;
+                vertical-align: middle;
+                color: #03a9f4;
+                font-size: 22px;
+                background-color: transparent;
+                height: 40px;
+                line-height: 42px;
+                width: 40px;
+                margin: 10px 5px;
+                transition: all ease-in-out 0.5s
+            }
+            .social #discord .st0 {
+                fill:#03a9f4;
+                transition: all ease-in-out 0.5s
+            }
+            .social #discord:hover .st0 {
+                fill:#0279b1;
+                transition: all ease-in-out 0.5s
+            }
+            .social .fa {
+                display: inline-block;
+                vertical-align: middle;
+                color: #03a9f4;
+                font-size: 28px;
+                background-color: transparent;
+                height: 40px;
+                line-height: 42px;
+                width: 40px;
+                margin: 10px 5px;
+                transition: all ease-in-out 0.5s
+            }
+            .social .fa:hover {
+                color: #0279b1;
+            }
+
+            .social .social-icon .fa {
+                font-size:20px;
+            	position:absolute;
+            	left:9px;
+            	top:10px;
+            }
+
+            .socialIcon {
+                position: relative;
+            }
+
+            .socialIcon .icon-purpose {
+        		visibility: hidden;
+        		width: 120px;
+        		font-size: 18px;
+        		background-color: rgba(3, 169, 244,0.8);
+        		color: #fff;
+        		text-align: center;
+        		border-radius: 6px;
+        		padding: 5px;
+        		position: absolute;
+        		z-index: 1;
+                bottom: 150%;
+                left: 50%;
+                margin-left: -65px;
+        		opacity: 0;
+                margin-bottom: 10px;
+            	transition: opacity 0.5s;
+        		display: none;
+        	}
+
+        	.socialIcon .icon-purpose::after {
+        		content: "";
+        	    position: absolute;
+                top: 100%;
+                left: 50%;
+                margin-left: -5px;
+        	    border-width: 5px;
+        	    border-style: solid;
+        	    border-color: rgba(3, 169, 244,0.8) transparent transparent transparent;
+        	}
+
+        	.socialIcon:hover .icon-purpose {
+        		visibility: visible;
+        		opacity: 1;
+        		display: block;
+        	}
+        </style>
+    </head>
+    <body>
+        <div class="container">
+            <div class="content">
+                <img src="https://preview.ibb.co/eAo1y5/logo.png" alt="Logo" style="width: 80%;" />
+                <div class="title">We are offline!</div>
+                <p>Visit Twitter or Discord via the links below to check when we are back online.</p>
+                <span class="social">
+                    <a class="socialIcon" href="https://twitter.com/MusareApp" target="_blank">
+                        <i class="fa fa-twitter" aria-hidden="true"></i>
+                        <span class="icon-purpose">Twitter</span>
+                    </a>
+                    <a class="socialIcon" href="https://discord.gg/Y5NxYGP" target="_blank">
+                        <svg id="discord" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><path class="st0" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path class="st0" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>
+                        <span class="icon-purpose">Discord</span>
+                    </a>
+                </span>
+            </div>
+        </div>
+    </body>
+</html>

+ 35 - 8
frontend/App.vue

@@ -1,5 +1,6 @@
 <template>
-	<div>
+	<banned v-if="banned"></banned>
+	<div v-else>
 		<h1 v-if="!socketConnected" class="alert">Could not connect to the server.</h1>
 		<router-view></router-view>
 		<toast></toast>
@@ -12,6 +13,7 @@
 <script>
 	import { Toast } from 'vue-roaster';
 
+	import Banned from './components/pages/Banned.vue';
 	import WhatIsNew from './components/Modals/WhatIsNew.vue';
 	import LoginModal from './components/Modals/Login.vue';
 	import RegisterModal from './components/Modals/Register.vue';
@@ -23,6 +25,8 @@
 		replace: false,
 		data() {
 			return {
+				banned: false,
+				ban: {},
 				register: {
 					email: '',
 					username: '',
@@ -39,7 +43,9 @@
 				isRegisterActive: false,
 				isLoginActive: false,
 				serverDomain: '',
-				socketConnected: true
+				socketConnected: true,
+				userIdMap: {},
+				currentlyGettingUsernameFrom: {}
 			}
 		},
 		methods: {
@@ -48,17 +54,35 @@
 				_this.socket.emit('users.logout', result => {
 					if (result.status === 'success') {
 						document.cookie = 'SID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
-						_this.$router.go('/');
 						location.reload();
 					} else Toast.methods.addToast(result.message, 4000);
 				});
 			},
 			'submitOnEnter': (cb, event) => {
 				if (event.which == 13) cb();
+			},
+			getUsernameFromId: function(userId) {
+			    if (typeof this.userIdMap[userId] !== 'string' && !this.currentlyGettingUsernameFrom[userId]) {
+					this.currentlyGettingUsernameFrom[userId] = true;
+			        io.getSocket(socket => {
+			            socket.emit('users.getUsernameFromId', userId, (data) => {
+			                if (data.status === 'success') this.$set(`userIdMap.${userId}`, data.data);
+							this.currentlyGettingUsernameFrom[userId] = false;
+						});
+					});
+				}
 			}
 		},
 		ready: function () {
 			let _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;
@@ -83,6 +107,12 @@
 				err = err.replace(new RegExp('<', 'g'), '&lt;').replace(new RegExp('>', 'g'), '&gt;');
 				Toast.methods.addToast(err, 20000);
 			}
+			io.getSocket(true, socket => {
+				socket.on('keep.event:user.session.removed', () => {
+					location.reload();
+				});
+			});
+
 		},
 		events: {
 			'register': function (recaptchaId) {
@@ -127,12 +157,9 @@
 							date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
 							let secure = (cookie.secure) ? 'secure=true; ' : '';
 							let domain = '';
-							if (cookie.domain !== 'localhost') {
-								domain = ` domain=${cookie.domain};`;
-							}
+							if (cookie.domain !== 'localhost') domain = ` domain=${cookie.domain};`;
 							document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
 							Toast.methods.addToast(`You have been successfully logged in`, 2000);
-							_this.$router.go('/');
 							location.reload();
 						});
 					} else Toast.methods.addToast(result.message, 2000);
@@ -152,7 +179,7 @@
 				this.$broadcast('closeModal');
 			}
 		},
-		components: { Toast, WhatIsNew, LoginModal, RegisterModal }
+		components: { Toast, WhatIsNew, LoginModal, RegisterModal, Banned }
 	}
 </script>
 

+ 22 - 1
frontend/auth.js

@@ -1,4 +1,5 @@
 let callbacks = [];
+let bannedCallbacks = [];
 
 export default {
 
@@ -7,12 +8,29 @@ export default {
 	username: '',
 	userId: '',
 	role: 'default',
+	banned: null,
+	ban: {},
 
 	getStatus: function (cb) {
 		if (this.ready) cb(this.authenticated, this.role, this.username, this.userId);
 		else callbacks.push(cb);
 	},
 
+	setBanned: function (ban) {
+		let _this = this;
+		_this.banned = true;
+		_this.ban = ban;
+		bannedCallbacks.forEach(callback => {
+			callback(true, _this.ban);
+		});
+	},
+
+	isBanned: function (cb) {
+		if (this.ready) return cb(false);
+		if (!this.ready && this.banned === true) return cb(true, this.ban);
+		bannedCallbacks.push(cb);
+	},
+
 	data: function (authenticated, role, username, userId) {
 		this.authenticated = authenticated;
 		this.role = role;
@@ -22,6 +40,9 @@ export default {
 		callbacks.forEach(callback => {
 			callback(authenticated, role, username, userId);
 		});
+		bannedCallbacks.forEach(callback => {
+			callback(false);
+		});
 		callbacks = [];
 	}
-}
+}

+ 5 - 2
frontend/components/Admin/News.vue

@@ -145,7 +145,7 @@
 				if (this.creating.title === '') return Toast.methods.addToast('Field (Title) cannot be empty', 3000);
 				if (this.creating.description === '') return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
 				if (
-					bugs.length <= 0 && features.length <= 0 && 
+					bugs.length <= 0 && features.length <= 0 &&
 					improvements.length <= 0 && upcoming.length <= 0
 				) return Toast.methods.addToast('You must have at least one News Item', 3000);
 
@@ -184,7 +184,10 @@
 
 				if (this.creating[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
 
-				if (change) this.creating[type].push(change);
+				if (change) {
+					$(`#new-${type}`).val('');
+					this.creating[type].push(change);
+				}
 				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
 			},
 			removeChange: function (type, index) {

+ 114 - 0
frontend/components/Admin/Punishments.vue

@@ -0,0 +1,114 @@
+<template>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+			<tr>
+				<td>Type</td>
+				<td>Value</td>
+				<td>Reason</td>
+				<td>Status</td>
+				<td>Options</td>
+			</tr>
+			</thead>
+			<tbody>
+			<tr v-for='(index, punishment) in punishments | orderBy "expiresAt" -1' track-by='$index'>
+				<td v-if='punishment.type === "banUserId"'>User ID</td>
+				<td v-else>IP Address</td>
+				<td>{{ punishment.value }}</td>
+				<td>{{ punishment.reason }}</td>
+				<td>{{ (punishment.active && new Date(punishment.expiresAt).getTime() > Date.now()) ? 'Active' : 'Inactive' }}</td>
+				<td>
+					<button class='button is-primary' @click='view(punishment)'>View</button>
+				</td>
+			</tr>
+			</tbody>
+		</table>
+		<div class='card is-fullwidth'>
+			<header class='card-header'>
+				<p class='card-header-title'>Ban an IP</p>
+			</header>
+			<div class='card-content'>
+				<div class='content'>
+					<label class='label'>Expires In</label>
+					<select v-model='ipBan.expiresAt'>
+						<option value='1h'>1 Hour</option>
+						<option value='12h'>12 Hours</option>
+						<option value='1d'>1 Day</option>
+						<option value='1w'>1 Week</option>
+						<option value='1m'>1 Month</option>
+						<option value='3m'>3 Months</option>
+						<option value='6m'>6 Months</option>
+						<option value='1y'>1 Year</option>
+					</select>
+					<label class='label'>IP</label>
+					<p class='control is-expanded'>
+						<input class='input' type='text' placeholder='IP address (xxx.xxx.xxx.xxx)' v-model='ipBan.ip'>
+					</p>
+					<label class='label'>Reason</label>
+					<p class='control is-expanded'>
+						<input class='input' type='text' placeholder='Reason' v-model='ipBan.reason'>
+					</p>
+				</div>
+			</div>
+			<footer class='card-footer'>
+				<a class='card-footer-item' @click='banIP()' href='#'>Ban IP</a>
+			</footer>
+		</div>
+	</div>
+	<view-punishment v-show='modals.viewPunishment'></view-punishment>
+</template>
+
+<script>
+	import ViewPunishment from '../Modals/ViewPunishment.vue';
+	import { Toast } from 'vue-roaster';
+	import io from '../../io';
+
+	export default {
+		components: { ViewPunishment },
+		data() {
+			return {
+				punishments: [],
+				modals: { viewPunishment: false },
+				ipBan: {
+					expiresAt: '1h'
+				}
+			}
+		},
+		methods: {
+			toggleModal: function () {
+				this.modals.viewPunishment = !this.modals.viewPunishment;
+			},
+			view: function (punishment) {
+				this.$broadcast('viewPunishment', punishment);
+			},
+			banIP: function() {
+				let _this = this;
+				_this.socket.emit('punishments.banIP', _this.ipBan.ip, _this.ipBan.reason, _this.ipBan.expiresAt, res => {
+					Toast.methods.addToast(res.message, 6000);
+				});
+			},
+			init: function () {
+				let _this = this;
+				_this.socket.emit('punishments.index', result => {
+					if (result.status === 'success') _this.punishments = result.data;
+				});
+				//_this.socket.emit('apis.joinAdminRoom', 'punishments', () => {});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			io.getSocket(socket => {
+				_this.socket = socket;
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => _this.init());
+			});
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	body { font-family: 'Roboto', sans-serif; }
+
+	td { vertical-align: middle; }
+	select { margin-bottom: 10px; }
+</style>

+ 1 - 1
frontend/components/Admin/Stations.vue

@@ -114,7 +114,7 @@
 			},
 			createStation: function () {
 				let _this = this;
-				let {newStation: {name, displayName, description, genres, blacklistedGenres}} = this;
+				let { newStation: { name, displayName, description, genres, blacklistedGenres } } = this;
 
 				if (name == undefined) return Toast.methods.addToast('Field (Name) cannot be empty', 3000);
 				if (displayName == undefined) return Toast.methods.addToast('Field (Display Name) cannot be empty', 3000);

+ 11 - 1
frontend/components/MainFooter.vue

@@ -33,5 +33,15 @@
 		flex-direction: column;
 	}
 
-	.icon:visited { color: #4a4a4a !important; }
+	.icon:hover { color: #90298C !important; }
+
+	.nightMode {
+		.footer {
+			background-color: rgb(51, 51, 51);
+			.content {
+				color: #e6e6e6;
+			}
+		}
+
+	}
 </style>

+ 42 - 0
frontend/components/MainHeader.vue

@@ -114,4 +114,46 @@
 		display: flex;
 		text-decoration: none;
 	}
+	.nightMode {
+		.nav {
+			background-color: #012332;
+			height: 64px;
+
+			.nav-menu.is-active {
+				.nav-item {
+					color: #333;
+
+					&:hover {
+						color: #333;
+					}
+				}
+			}
+
+			.nav-toggle {
+				height: 64px;
+
+				&.is-active span {
+					background-color: #333;
+				}
+			}
+
+			.is-brand {
+				font-size: 2.1rem !important;
+				line-height: 64px !important;
+				padding: 0 20px;
+			}
+
+			.nav-item {
+				font-size: 15px;
+				color: $white;
+
+				&:hover {
+					color: $white;
+				}
+			}
+			.admin strong {
+				color: #03a9f4;
+			}
+		}
+	}
 </style>

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

@@ -142,4 +142,4 @@
 
 		img { width: 55px; }
 	}
-</style>
+</style>

+ 51 - 7
frontend/components/Modals/EditSong.vue

@@ -91,7 +91,7 @@
 				<p class='control'>
 					<input class='input' type='text' v-model='editing.song.skipDuration'>
 				</p>
-				<article class="message">
+				<article class="message" v-if="editing.type === 'songs'">
 					<div class="message-body">
 						<span class="reports-length">
 							{{ reports.length }}
@@ -155,6 +155,7 @@
 
 <script>
 	import io from '../../io';
+	import validation from '../../validation';
 	import { Toast } from 'vue-roaster';
 	import Modal from './Modal.vue';
 
@@ -170,8 +171,9 @@
 				reports: 0,
 				video: {
 					player: null,
-					paused: false,
-					playerReady: false
+					paused: true,
+					playerReady: false,
+					autoPlayed: false
 				},
 				spotify: {
 					title: '',
@@ -183,6 +185,41 @@
 		methods: {
 			save: function (song, close) {
 				let _this = this;
+
+				if (!song.title) return Toast.methods.addToast('Please fill in all fields', 8000);
+				if (!song.thumbnail) return Toast.methods.addToast('Please fill in all fields', 8000);
+
+
+				// Title
+				if (!validation.isLength(song.title, 1, 64)) return Toast.methods.addToast('Title must have between 1 and 64 characters.', 8000);
+				if (!validation.regex.ascii.test(song.title)) return Toast.methods.addToast('Invalid title format. Only ascii characters are allowed.', 8000);
+
+
+				// Artists
+				if (song.artists.length < 1 || song.artists.length > 10) return Toast.methods.addToast('Invalid artists. You must have at least 1 artist and a maximum of 10 artists.', 8000);
+				let error;
+				song.artists.forEach((artist) => {
+					if (!validation.isLength(artist, 1, 32)) return error = 'Artist must have between 1 and 32 characters.';
+					if (!validation.regex.ascii.test(artist)) return error = 'Invalid artist format. Only ascii characters are allowed.';
+					if (artist === 'NONE') return error = 'Invalid artist format. Artists are not allowed to be named "NONE".';
+				});
+				if (error) return Toast.methods.addToast(error, 8000);
+
+
+				// Genres
+				error = undefined;
+				song.genres.forEach((genre) => {
+					if (!validation.isLength(genre, 1, 16)) return error = 'Genre must have between 1 and 16 characters.';
+					if (!validation.regex.az09_.test(genre)) return error = 'Invalid genre format. Only ascii characters are allowed.';
+				});
+				if (error) return Toast.methods.addToast(error, 8000);
+
+
+				// Thumbnail
+				if (!validation.isLength(song.thumbnail, 8, 256)) return Toast.methods.addToast('Thumbnail must have between 8 and 256 characters.', 8000);
+				if (song.thumbnail.indexOf('https://') !== 0) return Toast.methods.addToast('Thumbnail must start with "https://".', 8000);
+
+
 				this.socket.emit(`${_this.editing.type}.update`, song._id, song, res => {
 					Toast.methods.addToast(res.message, 4000);
 					if (res.status === 'success') {
@@ -273,7 +310,7 @@
 				height: 315,
 				width: 560,
 				videoId: this.editing.song.songId,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
+				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0, autoplay: 1 },
 				startSeconds: _this.editing.song.skipDuration,
 				events: {
 					'onReady': () => {
@@ -286,6 +323,11 @@
 					},
 					'onStateChange': event => {
 						if (event.data === 1) {
+							if (!_this.video.autoPlayed) {
+								_this.video.autoPlayed = true;
+								return _this.video.player.stopVideo();
+							}
+
 							_this.video.paused = false;
 							let youtubeDuration = _this.video.player.getDuration();
 							youtubeDuration -= _this.editing.song.skipDuration;
@@ -331,9 +373,11 @@
 					song: newSong,
 					type
 				};
-				_this.socket.emit('reports.getReportsForSong', song.songId, res => {
-					if (res.status === 'success') _this.reports = res.data;
-				});
+				if (type === 'songs') {
+					_this.socket.emit('reports.getReportsForSong', song.songId, res => {
+						if (res.status === 'success') _this.reports = res.data;
+					});
+				}
 				this.$parent.toggleModal();
 			},
 			stopVideo: function () {

+ 10 - 0
frontend/components/Modals/EditStation.vue

@@ -24,12 +24,22 @@
 						</select>
 					</span>
 				</p>
+				<br><br>
 				<p class='control'>
 					<label class="checkbox party-mode-inner">
 						<input type="checkbox" v-model="editing.partyMode">
 						&nbsp;Party mode
 					</label>
 				</p>
+				<small>With party mode enabled, people can add songs to a queue that plays. With party mode disabled you can play a private playlist on loop.</small><br>
+				<div v-if="$parent.station.partyMode">
+					<br>
+					<br>
+					<label class='label'>Queue lock</label>
+					<small v-if="$parent.station.partyMode">With the queue locked, only owners (you) can add songs to the queue.</small><br>
+					<button class='button is-danger' v-if='!$parent.station.locked' @click="$parent.toggleLock()">Lock the queue</button>
+					<button class='button is-success' v-if='$parent.station.locked' @click="$parent.toggleLock()">Unlock the queue</button>
+				</div>
 			</div>
 			<div slot='footer'>
 				<button class='button is-success' @click='update()'>Update Settings</button>

+ 38 - 1
frontend/components/Modals/EditUser.vue

@@ -19,6 +19,23 @@
 					</span>
 					<a class="button is-info" @click='updateRole()'>Update Role</a>
 				</p>
+				<hr>
+				<p class="control has-addons">
+					<span class="select">
+						<select v-model='ban.expiresAt'>
+							<option value='1h'>1 Hour</option>
+							<option value='12h'>12 Hours</option>
+							<option value='1d'>1 Day</option>
+							<option value='1w'>1 Week</option>
+							<option value='1m'>1 Month</option>
+							<option value='3m'>3 Months</option>
+							<option value='6m'>6 Months</option>
+							<option value='1y'>1 Year</option>
+						</select>
+					</span>
+					<input class='input is-expanded' type='text' placeholder='Ban reason' v-model='ban.reason' autofocus>
+					<a class="button is-error" @click='banUser()'>Ban user</a>
+				</p>
 			</div>
 			<div slot='footer'>
 				<!--button class='button is-warning'>
@@ -27,6 +44,9 @@
 				<button class='button is-warning'>
 					<span>&nbsp;Send Password Reset Email</span>
 				</button-->
+				<button class='button is-warning' @click='removeSessions()'>
+					<span>&nbsp;Remove all sessions</span>
+				</button>
 				<button class='button is-danger' @click='$parent.toggleModal()'>
 					<span>&nbsp;Close</span>
 				</button>
@@ -45,7 +65,10 @@
 		components: { Modal },
 		data() {
 			return {
-				editing: {}
+				editing: {},
+				ban: {
+					expiresAt: '1h'
+				}
 			}
 		},
 		methods: {
@@ -78,6 +101,20 @@
 							this.editing._id === this.$parent.$parent.$parent.userId
 					) location.reload();
 				});
+			},
+			banUser: function () {
+				const reason = this.ban.reason;
+				if (!validation.isLength(reason, 1, 64)) return Toast.methods.addToast('Reason must have between 1 and 64 characters.', 8000);
+				if (!validation.regex.ascii.test(reason)) return Toast.methods.addToast('Invalid reason format. Only ascii characters are allowed.', 8000);
+
+				this.socket.emit(`users.banUserById`, this.editing._id, this.ban.reason, this.ban.expiresAt, res => {
+					Toast.methods.addToast(res.message, 4000);
+				});
+			},
+			removeSessions: function () {
+				this.socket.emit(`users.removeSessions`, this.editing._id, res => {
+					Toast.methods.addToast(res.message, 4000);
+				});
 			}
 		},
 		ready: function () {

+ 4 - 1
frontend/components/Modals/Login.vue

@@ -20,7 +20,7 @@
 			</section>
 			<footer class='modal-card-foot'>
 				<a class='button is-primary' href='#' @click='submitModal("login")'>Submit</a>
-				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"'>
+				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"' @click="githubRedirect()">
 					<div class='icon'>
 						<img class='invert' src='/assets/social/github.svg'/>
 					</div>
@@ -46,6 +46,9 @@
 			resetPassword: function () {
 				this.toggleModal();
 				this.$router.go('/reset_password');
+			},
+			githubRedirect: function() {
+			    localStorage.setItem('github_redirect', this.$route.path)
 			}
 		},
 		events: {

+ 28 - 7
frontend/components/Modals/Playlists/Edit.vue

@@ -1,6 +1,15 @@
 <template>
 	<modal title='Edit Playlist'>
 		<div slot='body'>
+			<nav class="level">
+				<div class="level-item has-text-centered">
+					<div>
+						<p class="heading">Total Length</p>
+						<p class="title">{{ totalLength() }}</p>
+					</div>
+				</div>
+			</nav>
+			<hr />
 			<aside class='menu' v-if='playlist.songs && playlist.songs.length > 0'>
 				<ul class='menu-list'>
 					<li v-for='song in playlist.songs' track-by='$index'>
@@ -77,13 +86,25 @@
 		components: { Modal },
 		data() {
 			return {
-				playlist: {},
+				playlist: {songs: []},
 				songQueryResults: [],
 				songQuery: '',
 				importQuery: ''
 			}
 		},
 		methods: {
+			formatTime: function (length) {
+				let duration = moment.duration(length, 'seconds');
+				if (length <= 0) return '0 seconds';
+				else return ((duration.hours() > 0 ? (duration.hours > 1 ? (duration.hours() < 10 ? ('0' + duration.hours() + ' hours ') : (duration.hours() + ' hours ')) : ('0' + duration.hours() + ' hour ')) : '') + (duration.minutes() > 0 ? (duration.minutes() > 1 ? (duration.minutes() < 10 ? ('0' + duration.minutes() + ' minutes ') : (duration.minutes() + ' minutes ')) : ('0' + duration.minutes() + ' minute ')) : '') + (duration.seconds() > 0 ? (duration.seconds() > 1 ? (duration.seconds() < 10 ? ('0' + duration.seconds() + ' seconds ') : (duration.seconds() + ' seconds ')) : ('0' + duration.seconds() + ' second ')) : ''));
+			},
+			totalLength: function() {
+			    let length = 0;
+			    this.playlist.songs.forEach((song) => {
+			        length += song.duration;
+				});
+			    return this.formatTime(length);
+			},
 			searchForSongs: function () {
 				let _this = this;
 				let query = _this.songQuery;
@@ -168,22 +189,22 @@
 			io.getSocket((socket) => {
 				_this.socket = socket;
 				_this.socket.emit('playlists.getPlaylist', _this.$parent.playlistBeingEdited, res => {
-					if (res.status == 'success') _this.playlist = res.data; _this.playlist.oldId = res.data._id;
+					if (res.status === 'success') _this.playlist = res.data; _this.playlist.oldId = res.data._id;
 				});
-				_this.socket.on('event:playlist.addSong', (data) => {
+				_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) => {
+				_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.socket.on('event:playlist.updateDisplayName', (data) => {
+				_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) => {
+				_this.socket.on('event:playlist.moveSongToBottom', data => {
 					if (_this.playlist._id === data.playlistId) {
 						let songIndex;
 						_this.playlist.songs.forEach((song, index) => {
@@ -242,4 +263,4 @@
 	}
 
 	h5 { padding: 20px 0; }
-</style>
+</style>

+ 4 - 1
frontend/components/Modals/Register.vue

@@ -25,7 +25,7 @@
 			</section>
 			<footer class='modal-card-foot'>
 				<a class='button is-primary' href='#' @click='submitModal()'>Submit</a>
-				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"'>
+				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"' @click="githubRedirect()">
 					<div class='icon'>
 						<img class='invert' src='/assets/social/github.svg'/>
 					</div>
@@ -62,6 +62,9 @@
 			submitModal: function () {
 				this.$dispatch('register', this.recaptcha.id);
 				this.toggleModal();
+			},
+			githubRedirect: function() {
+				localStorage.setItem('github_redirect', this.$route.path)
 			}
 		},
 		events: {

+ 64 - 0
frontend/components/Modals/ViewPunishment.vue

@@ -0,0 +1,64 @@
+<template>
+	<div>
+		<modal title='View Punishment'>
+			<div slot='body'>
+				<article class="message">
+					<div class="message-body">
+						<strong>Type: </strong>{{ punishment.type }}<br/>
+						<strong>Value: </strong>{{ punishment.value }}<br/>
+						<strong>Reason: </strong>{{ punishment.reason }}<br/>
+						<strong>Active: </strong>{{ punishment.active }}<br/>
+						<strong>Expires at: </strong>{{ moment(punishment.expiresAt).format('MMMM Do YYYY, h:mm:ss a'); }} ({{ moment(punishment.expiresAt).fromNow() }})<br/>
+						<strong>Punished at: </strong>{{ moment(punishment.punishedAt).format('MMMM Do YYYY, h:mm:ss a') }} ({{ moment(punishment.punishedAt).fromNow() }})<br/>
+						<strong>Punished by: </strong>{{ punishment.punishedBy }}<br/>
+					</div>
+				</article>
+			</div>
+			<div slot='footer'>
+				<button class='button is-danger' @click='$parent.toggleModal()'>
+					<span>&nbsp;Close</span>
+				</button>
+			</div>
+		</modal>
+	</div>
+</template>
+
+<script>
+	import io from '../../io';
+	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
+	import validation from '../../validation';
+
+	export default {
+		components: { Modal },
+		data() {
+			return {
+				punishment: {},
+				ban: {},
+				moment
+			}
+		},
+		methods: {},
+		ready: function () {
+			let _this = this;
+			io.getSocket(socket => _this.socket = socket );
+		},
+		events: {
+			closeModal: function () {
+				this.$parent.modals.viewPunishment = false;
+			},
+			viewPunishment: function (punishment) {
+				this.punishment = {
+					type: punishment.type,
+					value: punishment.value,
+					reason: punishment.reason,
+					active: punishment.active,
+					expiresAt: punishment.expiresAt,
+					punishedAt: punishment.punishedAt,
+					punishedBy: punishment.punishedBy
+				};
+				this.$parent.toggleModal();
+			}
+		}
+	}
+</script>

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

@@ -51,20 +51,24 @@
 		},
 		ready: function () {
 			let _this = this;
-			io.getSocket(true, (socket) => {
+			io.getSocket(true, socket => {
 				_this.socket = socket;
 				_this.socket.emit('news.newest', res => {
 					_this.news = res.data;
-					if (_this.news) {
+					if (_this.news && localStorage.getItem('firstVisited')) {
 						if (localStorage.getItem('whatIsNew')) {
 							if (parseInt(localStorage.getItem('whatIsNew')) < res.data.createdAt) {
 								this.toggleModal();
 								localStorage.setItem('whatIsNew', res.data.createdAt);
 							}
 						} else {
-							this.toggleModal();
+							if (parseInt(localStorage.getItem('firstVisited')) < res.data.createdAt) {
+								this.toggleModal();
+							}
 							localStorage.setItem('whatIsNew', res.data.createdAt);
 						}
+					} else {
+						if (!localStorage.getItem('firstVisited')) localStorage.setItem('firstVisited', Date.now());
 					}
 				});
 			});

+ 57 - 12
frontend/components/Sidebars/SongsList.vue

@@ -5,7 +5,7 @@
 			<div class='title' v-else>Playlist</div>
 
 			<article class="media" v-if="!$parent.noSong">
-				<figure class="media-left">
+				<figure class="media-left" v-if="$parent.currentSong.thumbnail">
 					<p class="image is-64x64">
 						<img :src="$parent.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
 					</p>
@@ -27,30 +27,52 @@
 
 			<article class="media" v-for='song in $parent.songsList'>
 				<div class="media-content">
-					<div class="content">
-						<p>
-							<strong>{{ song.title }}</strong>
-							<br>
+					<div class="content" style="display: block;padding-top: 10px;">
+							<strong class="songTitle">{{ song.title }}</strong>
 							<small>{{ song.artists.join(', ') }}</small>
-						</p>
+							<div v-if="this.$parent.$parent.type === 'community' && this.$parent.$parent.station.partyMode === true">
+								<small>Requested by <b>{{this.$parent.$parent.$parent.getUsernameFromId(song.requestedBy)}} {{this.userIdMap[song.requestedBy]}}</b></small>
+								<i class="material-icons" style="vertical-align: middle;" @click="removeFromQueue(song.songId)" v-if="isOwnerOnly() || isAdminOnly()">delete_forever</i>
+							</div>
 					</div>
 				</div>
 				<div class="media-right">
 					{{ $parent.$parent.formatTime(song.duration) }}
 				</div>
 			</article>
-			<a class='button add-to-queue' href='#' @click='$parent.modals.addSongToQueue = !$parent.modals.addSongToQueue' v-if="$parent.type === 'community' && $parent.$parent.loggedIn">Add Song to Queue</a>
+			<div v-if="$parent.type === 'community' && $parent.$parent.loggedIn && $parent.station.partyMode === true">
+				<button class='button add-to-queue' @click='$parent.modals.addSongToQueue = !$parent.modals.addSongToQueue' v-if="($parent.station.locked && isOwnerOnly()) || !$parent.station.locked || ($parent.station.locked && isAdminOnly() && dismissedWarning)">Add Song to Queue</button>
+				<button class='button add-to-queue add-to-queue-warning' @click='dismissedWarning = true' v-if="$parent.station.locked && isAdminOnly() && !isOwnerOnly() && !dismissedWarning">THIS STATION'S QUEUE IS LOCKED.</button>
+				<button class='button add-to-queue add-to-queue-disabled' v-if="$parent.station.locked && !isAdminOnly() && !isOwnerOnly()">THIS STATION'S QUEUE IS LOCKED.</button>
+			</div>
 		</div>
 	</div>
 </template>
 
 <script>
 	import io from '../../io';
+	import { Toast } from 'vue-roaster';
 
 	export default {
 		data: function () {
 			return {
-
+				dismissedWarning: false,
+				userIdMap: this.$parent.$parent.userIdMap
+			}
+		},
+		methods: {
+			isOwnerOnly: function () {
+				return this.$parent.$parent.loggedIn && this.$parent.$parent.userId === this.$parent.station.owner;
+			},
+			isAdminOnly: function() {
+				return this.$parent.$parent.loggedIn && this.$parent.$parent.role === 'admin';
+			},
+			removeFromQueue: function(songId) {
+				socket.emit('stations.removeFromQueue', this.$parent.station._id, songId, res => {
+					if (res.status === 'success') {
+						Toast.methods.addToast('Successfully removed song from the queue.', 4000);
+					} else Toast.methods.addToast(res.message, 8000);
+				});
 			}
 		},
 		ready: function () {
@@ -75,7 +97,7 @@
 		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
 	}
 
-	.inner-wrapper {	
+	.inner-wrapper {
 		top: 64px;
 		position: relative;
 		overflow: auto;
@@ -106,7 +128,7 @@
 	}
 
 	.content p strong { word-break: break-word; }
-	
+
 	.content p small { word-break: break-word; }
 
 	.add-to-queue {
@@ -119,8 +141,31 @@
 		border: 0;
 		&:active, &:focus { border: 0; }
 	}
-	
+
+	.add-to-queue.add-to-queue-warning {
+		background-color: red;
+	}
+
+	.add-to-queue.add-to-queue-disabled {
+		background-color: gray;
+	}
+	.add-to-queue.add-to-queue-disabled:focus {
+		background-color: gray;
+	}
+
 	.add-to-queue:focus { background: #029ce3; }
 
 	.media-right { line-height: 64px; }
-</style>
+
+	.songTitle {
+		word-wrap: break-word;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 2;
+		line-height: 20px;
+		max-height: 40px;
+	}
+
+</style>

+ 45 - 10
frontend/components/Station/CommunityHeader.vue

@@ -245,6 +245,25 @@
 		@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 {
@@ -286,20 +305,36 @@
 	}
 
 	.sidebar-item .icon-purpose {
-	    visibility: hidden;
-	    width: 150px;
+		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 0;
+		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;
-	    z-index: 1;
-	    left: 105%;
+	    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;
+		visibility: visible;
+		opacity: 1;
+		display: block;
 	}
 </style>

+ 44 - 12
frontend/components/Station/OfficialHeader.vue

@@ -97,6 +97,12 @@
 					<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 && !$parent.simpleSong' class="sidebar-item" href='#' @click='$parent.modals.report = !$parent.modals.report'>
+					<span class='icon'>
+						<i class='material-icons'>report</i>
+					</span>
+					<span class="icon-purpose">Report a song</span>
+				</a>
 				<a v-if='$parent.$parent.loggedIn && !$parent.noSong' class="sidebar-item" href='#' @click='$parent.modals.addSongToPlaylist = true'>
 					<span class='icon'>
 						<i class='material-icons'>playlist_add</i>
@@ -117,13 +123,6 @@
 				</span>
 				<span class="icon-purpose">Display users in the station</span>
 			</a>
-			<hr>
-			<a v-if='$parent.$parent.loggedIn && !$parent.noSong && !$parent.simpleSong' class="sidebar-item" href='#' @click='$parent.modals.report = !$parent.modals.report'>
-				<span class='icon'>
-					<i class='material-icons'>report</i>
-				</span>
-				<span class="icon-purpose">Report a song</span>
-			</a>
 		</div>
 	</div>
 </template>
@@ -252,12 +251,29 @@
 		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);
-		overflow-y: auto;
-		overflow-x: hidden;
 
 		@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 {
@@ -300,19 +316,35 @@
 
 	.sidebar-item .icon-purpose {
 		visibility: hidden;
-		width: 150px;
+		width: 160px;
 		font-size: 12px;
 		background-color: rgba(3, 169, 244,0.8);
 		color: #fff;
 		text-align: center;
 		border-radius: 6px;
-		padding: 5px 0;
+		padding: 5px;
 		position: absolute;
 		z-index: 1;
-		left: 105%;
+		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>

+ 183 - 14
frontend/components/Station/Station.vue

@@ -13,10 +13,11 @@
 	<playlist-sidebar v-if='sidebars.playlist'></playlist-sidebar>
 	<users-sidebar v-if='sidebars.users'></users-sidebar>
 
-	<div class="station">
+	<div class='progress' v-show='!ready'></div>
+	<div class='station' v-show="ready">
 		<div v-show="noSong" class="no-song">
 			<h1>No song is currently playing</h1>
-			<h4 v-if='type === "community" && station.partyMode'>
+			<h4 v-if='type === "community" && station.partyMode && (!station.locked || (station.locked && $parent.loggedIn && $parent.userId === station.owner))'>
 				<a href='#' class='no-song' @click='modals.addSongToQueue = true'>Add a song to the queue</a>
 			</h4>
 			<h4 v-if='type === "community" && !station.partyMode && $parent.userId === station.owner && !station.privatePlaylist'>
@@ -33,6 +34,52 @@
 					<div class="seeker-bar light-blue" style="width: 0%;"></div>
 				</div>
 			</div>
+			<div class="desktop-only column is-3-desktop card playlistCard experimental">
+				<div class='title' v-if='type === "community"'>Queue</div>
+				<div class='title' v-else>Playlist</div>
+				<article class="media" v-if="!noSong">
+					<figure class="media-left">
+						<p class="image is-64x64">
+							<img :src="currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+						</p>
+					</figure>
+					<div class="media-content">
+						<div class="content">
+							<p>
+								Current Song:
+								<br>
+								<strong>{{ currentSong.title }}</strong>
+								<br>
+								<small>{{ currentSong.artists }}</small>
+							</p>
+						</div>
+					</div>
+					<div class="media-right">
+						{{ formatTime(currentSong.duration) }}
+					</div>
+				</article>
+				<p v-if="noSong" class="center">There is currently no song playing.</p>
+
+				<article class="media" v-for='song in songsList'>
+					<div class="media-content">
+						<div class="content">
+								<strong class="songTitle">{{ song.title }}</strong>
+								<br>
+								<small>{{ song.artists.join(', ') }}</small>
+								<br>
+								<div v-if="station.partyMode">
+									<br>
+									<small>Requested by <b>{{this.$parent.$parent.getUsernameFromId(song.requestedBy)}} {{this.userIdMap[song.requestedBy]}}</b></small>
+									<button class="button" @click="removeFromQueue(song.songId)" v-if="isOwnerOnly() || isAdminOnly()">REMOVE</button>
+								</div>
+						</div>
+					</div>
+					<div class="media-right">
+						{{ $parent.formatTime(song.duration) }}
+					</div>
+				</article>
+				<a class='button add-to-queue' href='#' @click='modals.addSongToQueue = !modals.addSongToQueue' v-if="type === 'community' && $parent.loggedIn">Add Song to Queue</a>
+			</div>
 		</div>
 		<div class="desktop-only columns is-mobile" v-show="!noSong">
 			<div class="column is-8-desktop is-offset-2-desktop is-12-mobile">
@@ -66,6 +113,9 @@
 							</div>
 						</div>
 					</div>
+					<div class="column is-3-desktop experimental" v-if="!simpleSong">
+						<img class="image" :src="currentSong.thumbnail" alt="Song Thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
+					</div>
 				</div>
 			</div>
 		</div>
@@ -130,6 +180,7 @@
 	export default {
 		data() {
 			return {
+				ready: false,
 				type: '',
 				playerReady: false,
 				previousSong: null,
@@ -164,10 +215,24 @@
 				automaticallyRequestedSongId: null,
 				systemDifference: 0,
 				users: [],
-				userCount: 0
+				userCount: 0,
+				userIdMap: this.$parent.userIdMap
 			}
 		},
 		methods: {
+			isOwnerOnly: function () {
+				return this.$parent.loggedIn && this.$parent.userId === this.station.owner;
+			},
+			isAdminOnly: function() {
+				return this.$parent.loggedIn && this.$parent.role === 'admin';
+			},
+			removeFromQueue: function(songId) {
+				socket.emit('stations.removeFromQueue', this.station._id, songId, res => {
+					if (res.status === 'success') {
+						Toast.methods.addToast('Successfully removed song from the queue.', 4000);
+					} else Toast.methods.addToast(res.message, 8000);
+				});
+			},
 			editPlaylist: function (id) {
 				this.playlistBeingEdited = id;
 				this.modals.editPlaylist = !this.modals.editPlaylist;
@@ -274,6 +339,14 @@
 				if (songDuration <= duration) local.player.pauseVideo();
 				if ((!local.paused) && duration <= songDuration) local.timeElapsed = local.formatTime(duration);
 			},
+			toggleLock: function () {
+				let _this = this;
+				socket.emit('stations.toggleLock', this.station._id, res => {
+					if (res.status === 'success') {
+						Toast.methods.addToast('Successfully toggled the queue lock.', 4000);
+					} else Toast.methods.addToast(res.message, 8000);
+				});
+			},
 			changeVolume: function() {
 				let local = this;
 				let volume = $("#volumeSlider").val();
@@ -377,16 +450,28 @@
 
 						_this.socket.emit('playlists.getFirstSong', _this.privatePlaylistQueueSelected, data => {
 							if (data.status === 'success') {
-								console.log(data.song);
-								let songId = data.song._id;
-								_this.automaticallyRequestedSongId = data.song.songId;
-								_this.socket.emit('stations.addToQueue', _this.station._id, data.song.songId, data => {
-									if (data.status === 'success') {
-										_this.socket.emit('playlists.moveSongToBottom', _this.privatePlaylistQueueSelected, songId, data => {
-											if (data.status === 'success') {}
-										});
-									}
-								});
+							    if (data.song.duration < 15 * 60) {
+									console.log(data.song);
+									let songId = data.song._id;
+									_this.automaticallyRequestedSongId = data.song.songId;
+									_this.socket.emit('stations.addToQueue', _this.station._id, data.song.songId, data2 => {
+										if (data2.status === 'success') {
+											_this.socket.emit('playlists.moveSongToBottom', _this.privatePlaylistQueueSelected, data.song.songId, data3 => {
+												if (data3.status === 'success') {
+												}
+											});
+										}
+									});
+								} else {
+									Toast.methods.addToast(`Top song in playlist was too long to be added.`, 3000);
+									_this.socket.emit('playlists.moveSongToBottom', _this.privatePlaylistQueueSelected, data.song.songId, data3 => {
+										if (data3.status === 'success') {
+										    setTimeout(() => {
+										        this.addFirstPrivatePlaylistSongToQueue();
+											}, 3000);
+										}
+									});
+								}
 							}
 						});
 					}
@@ -402,6 +487,7 @@
 							displayName: res.data.displayName,
 							description: res.data.description,
 							privacy: res.data.privacy,
+							locked: res.data.locked,
 							partyMode: res.data.partyMode,
 							owner: res.data.owner,
 							privatePlaylist: res.data.privatePlaylist
@@ -421,7 +507,7 @@
 							}
 							_this.youtubeReady();
 							_this.playVideo();
-							_this.socket.emit('songs.getOwnSongRatings', res.data.currentSong._id, data => {
+							_this.socket.emit('songs.getOwnSongRatings', res.data.currentSong.songId, data => {
 								if (_this.currentSong.songId === data.songId) {
 									_this.liked = data.liked;
 									_this.disliked = data.disliked;
@@ -489,6 +575,8 @@
 					if (res.status === 'error') {
 						_this.$router.go('/404');
 						Toast.methods.addToast(res.message, 3000);
+					} else {
+						_this.ready = true;
 					}
 				});
 				_this.socket.on('event:songs.next', data => {
@@ -616,6 +704,10 @@
 				_this.socket.on('event:userCount.updated', userCount => {
 					_this.userCount = userCount;
 				});
+
+				_this.socket.on('event:queueLockToggled', locked => {
+					_this.station.locked = locked;
+				});
 			});
 
 
@@ -736,6 +828,63 @@
 			text-align: center;
 		}
 
+		.playlistCard {
+			margin: 10px;
+			position: relative;
+			padding-bottom: calc(31.25% + 7px);
+			height: 0;
+			overflow-y: scroll;
+
+			.title {
+				background-color: rgb(3, 169, 244);
+				text-align: center;
+				padding: 10px;
+				color: white;
+				font-weight: 600;
+			}
+
+			.media { padding: 0 25px; }
+
+			.media-content .content {
+				min-height: 64px;
+				max-height: 64px;
+				display: flex;
+				align-items: center;
+			}
+
+			.content p strong { word-break: break-word; }
+
+			.content p small { word-break: break-word; }
+
+			.add-to-queue {
+				width: 100%;
+				margin-top: 25px;
+				height: 40px;
+				border-radius: 0;
+				background: rgb(3, 169, 244);
+				color: #fff !important;
+				border: 0;
+				&:active, &:focus { border: 0; }
+			}
+
+			.add-to-queue:focus { background: #029ce3; }
+
+			.media-right { line-height: 64px; }
+
+			.songTitle {
+				word-wrap: break-word;
+				overflow: hidden;
+				text-overflow: ellipsis;
+				display: -webkit-box;
+				-webkit-box-orient: vertical;
+				-webkit-line-clamp: 2;
+				line-height: 20px;
+				max-height: 40px;
+				width: 100%;
+			}
+
+		}
+
 		input[type=range] {
 			-webkit-appearance: none;
 			width: 100%;
@@ -1002,4 +1151,24 @@
 	.behind:focus {
 		z-index: 0;
 	}
+
+	.progress {
+		width: 50px;
+		animation: rotate 0.8s infinite linear;
+		border: 8px solid #03A9F4;
+		border-right-color: transparent;
+		height: 50px;
+		position: absolute;
+		top: 50%;
+		left: 50%;
+	}
+
+	@keyframes rotate {
+		0% { transform: rotate(0deg); }
+		100% { transform: rotate(360deg); }
+	}
+
+	.experimental {
+		display: none !important;
+	}
 </style>

+ 12 - 8
frontend/components/User/Settings.vue

@@ -32,12 +32,10 @@
 			</p>
 		</div>
 
-
 		<label class="label" v-if="!password">Add password</label>
 		<div class="control is-grouped" v-if="!password">
 			<button class="button is-success" @click="requestPassword()" v-if="passwordStep === 1">Request password email</button><br>
 
-
 			<p class="control is-expanded has-icon has-icon-right" v-if="passwordStep === 2">
 				<input class="input" type="text" placeholder="Code" v-model="passwordCode">
 			</p>
@@ -45,7 +43,6 @@
 				<button class="button is-success" @click="verifyCode()">Verify code</button>
 			</p>
 
-
 			<p class="control is-expanded has-icon has-icon-right" v-if="passwordStep === 3">
 				<input class="input" type="password" placeholder="New password" v-model="setNewPassword">
 			</p>
@@ -55,7 +52,6 @@
 		</div>
 		<a href="#" v-if="passwordStep === 1 && !password" @click="passwordStep = 2">Skip this step</a>
 
-
 		<a class="button is-github" v-if="!github" :href='$parent.serverDomain + "/auth/github/link"'>
 			<div class='icon'>
 				<img class='invert' src='/assets/social/github.svg'/>
@@ -65,6 +61,9 @@
 
 		<button class="button is-danger" @click="unlinkPassword()" v-if="password && github">Remove logging in with password</button>
 		<button class="button is-danger" @click="unlinkGitHub()" v-if="password && github">Remove logging in with GitHub</button>
+
+		<br>
+		<button class="button is-warning" @click="removeSessions()" style="margin-top: 30px;">Log out everywhere</button>
 	</div>
 	<main-footer></main-footer>
 </template>
@@ -108,16 +107,16 @@
 				_this.socket.on('event:user.username.changed', username => {
 					_this.$parent.username = username;
 				});
-				_this.socket.on('event:user.linkPassword', () => {console.log(1);
+				_this.socket.on('event:user.linkPassword', () => {
 					_this.password = true;
 				});
-				_this.socket.on('event:user.linkGitHub', () => {console.log(2);
+				_this.socket.on('event:user.linkGitHub', () => {
 					_this.github = true;
 				});
-				_this.socket.on('event:user.unlinkPassword', () => {console.log(3);
+				_this.socket.on('event:user.unlinkPassword', () => {
 					_this.password = false;
 				});
-				_this.socket.on('event:user.unlinkGitHub', () => {console.log(4);
+				_this.socket.on('event:user.unlinkGitHub', () => {
 					_this.github = false;
 				});
 			});
@@ -192,6 +191,11 @@
 				this.socket.emit('users.unlinkGitHub', res => {
 					Toast.methods.addToast(res.message, 8000);
 				});
+			},
+			removeSessions: function () {
+				this.socket.emit(`users.removeSessions`, this.$parent.userId, res => {
+					Toast.methods.addToast(res.message, 4000);
+				});
 			}
 		},
 		components: { MainHeader, MainFooter, LoginModal }

+ 13 - 1
frontend/components/pages/Admin.vue

@@ -45,6 +45,12 @@
 						<span>&nbsp;Statistics</span>
 					</a>
 				</li>
+				<li :class='{ "is-active": currentTab == "punishments" }' @click='showTab("punishments")'>
+					<a v-link="{ path: '/admin/punishments' }">
+						<i class="material-icons">gavel</i>
+						<span>&nbsp;Punishments</span>
+					</a>
+				</li>
 			</ul>
 		</div>
 
@@ -55,6 +61,7 @@
 		<news v-if='currentTab == "news"'></news>
 		<users v-if='currentTab == "users"'></users>
 		<statistics v-if='currentTab == "statistics"'></statistics>
+		<punishments v-if='currentTab == "punishments"'></punishments>
 	</div>
 </template>
 
@@ -69,6 +76,7 @@
 	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: {
@@ -80,7 +88,8 @@
 			Reports,
 			News,
 			Users,
-			Statistics
+			Statistics,
+			Punishments
 		},
 		ready() {
 			switch(window.location.pathname) {
@@ -105,6 +114,9 @@
 				case '/admin/statistics':
 					this.currentTab = 'statistics';
 					break;
+				case '/admin/punishments':
+					this.currentTab = 'punishments';
+					break;
 				default:
 					this.currentTab = 'queueSongs';
 			}

+ 45 - 0
frontend/components/pages/Banned.vue

@@ -0,0 +1,45 @@
+<template>
+	<div class="container">
+		<i class="material-icons">not_interested</i>
+		<h4>
+			You are banned
+			for
+			<strong>{{ moment($parent.ban.expiresAt).fromNow(true) }}</strong>
+		</h4>
+		<h5 class="reason">
+			<strong>Reason: </strong>
+			{{ $parent.ban.reason }}
+		</h5>
+	</div>
+</template>
+<script>
+	export default {
+		data() {
+	        return {
+				moment
+			}
+	    }
+	}
+</script>
+
+<style lang='scss' scoped>
+	.container {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		flex-direction: column;
+		height: 100vh;
+		max-width: 1000px;
+		padding: 0 20px;
+	}
+
+	.reason {
+		text-align: justify;
+	}
+
+	i.material-icons {
+		cursor: default;
+		font-size: 65px;
+		color: tomato;
+	}
+</style>

+ 108 - 7
frontend/components/pages/Home.vue

@@ -1,9 +1,9 @@
 <template>
-	<div class="app">
+	<div class="app" :class="{'nightMode': nightMode}">
 		<main-header></main-header>
 		<div class="group">
 			<div class="group-title">Official Stations</div>
-			<div class="card station-card" v-for="station in stations.official" v-link="{ path: '/' + station.name }" @click="this.$dispatch('joinStation', station._id)">
+			<div class="card station-card" v-for="station in stations.official" v-link="{ path: '/' + station.name }" @click="this.$dispatch('joinStation', station._id)" :class="{'isPrivate': station.privacy === 'private'}">
 				<div class="card-image">
 					<figure class="image is-square">
 						<img :src="station.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
@@ -24,7 +24,7 @@
 						<i class='material-icons' title="How many users there are in the station.">people</i>
 						<span class="users-count" title="How many users there are in the station.">&nbsp;{{station.userCount}}</span>
 
-						<i class="material-icons right" v-if="station.privacy !== 'public'" title="This station is not visible to other users.">lock</i>
+						<i class="material-icons right-icon" v-if="station.privacy !== 'public'" title="This station is not visible to other users.">lock</i>
 					</div>
 				</div>
 				<a @click="this.$dispatch('joinStation', station._id)" href='#' class='absolute-a' v-link="{ path: '/' + station.name }"></a>
@@ -36,7 +36,7 @@
 				<a @click='modals.createCommunityStation = !modals.createCommunityStation' v-if="$parent.loggedIn" href='#'>
 				<i class="material-icons community-button">add_circle_outline</i></a>
 			</div>
-			<div class="card station-card" v-for="station in stations.community" v-link="{ path: '/community/' + station.name }" @click="this.$dispatch('joinStation', station._id)">
+			<div class="card station-card" v-for="station in stations.community" v-link="{ path: '/community/' + station.name }" @click="this.$dispatch('joinStation', station._id)" :class="{'isPrivate': station.privacy === 'private','isMine': isOwner(station)}">
 				<div class="card-image">
 					<figure class="image is-square">
 						<img :src="station.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
@@ -56,8 +56,8 @@
 						<i class='material-icons' title="How many users there are in the station.">people</i>
 						<span class="users-count" title="How many users there are in the station.">&nbsp;{{station.userCount}}</span>
 
-						<i class="material-icons right" v-if="station.privacy !== 'public'" title="This station is not visible to other users.">lock</i>
-						<i class="material-icons right" v-if="isOwner(station)" title="This is your station.">home</i>
+						<i class="material-icons right-icon" v-if="station.privacy !== 'public'" title="This station is not visible to other users.">lock</i>
+						<i class="material-icons right-icon" v-if="isOwner(station)" title="This is your station.">home</i>
 					</div>
 				</div>
 				<a @click="this.$dispatch('joinStation', station._id)" href='#' class='absolute-a' v-link="{ path: '/community/' + station.name }"></a>
@@ -87,7 +87,8 @@
 				},
 				modals: {
 					createCommunityStation: false
-				}
+				},
+				nightMode: false
 			}
 		},
 		ready() {
@@ -200,13 +201,26 @@
 	}
 
 	.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;
 		}
+
+		.right-icon {
+			float: right;
+		}
 	}
 
 	.users-count {
@@ -224,11 +238,39 @@
 	.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;
+			}
 		}
 	}
 
+	.station-card: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: .25s ease color;
@@ -288,5 +330,64 @@
 	.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;
+	}
+
+	.nightMode {
+		background-color: rgb(51, 51, 51);
+		color: #e6e6e6;
+
+		.community-button {
+			cursor: pointer;
+			transition: .25s ease color;
+			font-size: 30px;
+			color: #e6e6e6;
+		}
+
+		.community-button:hover { color: #03a9f4; }
+
+		.station-card {
+			margin: 10px;
+			cursor: pointer;
+			height: 475px;
+			background-color: rgb(51, 51, 51);
+			color: #e6e6e6;
+
+			.card-content {
+				max-height: 159px;
+				color: #e6e6e6;
+
+				.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;
+					color: #e6e6e6;
+				}
+			}
+		}
+
+		.station-card:hover {
+			box-shadow: 0 2px 3px rgba(10, 10, 10, 0.3), 0 0 10px rgba(10, 10, 10, 0.3);
+		}
+
+		.isPrivate {
+			background-color: #d01657;
+		}
+
+		.isMine {
+			background-color: #0777ab;
+		}
 	}
 </style>

+ 6 - 0
frontend/components/pages/Team.vue

@@ -64,6 +64,7 @@
 					</header>
 					<div class='card-content'>
 						<div class='content'>
+							<span class="custom-tag pink">lead-designer</span>
 							<span class="custom-tag light-blue">developer</span>
 							<ul>
 								<li>
@@ -269,6 +270,11 @@
 		color: white;
 	}
 
+	.custom-tag.pink {
+		background-color: #ff99dd;
+		color: white;
+	}
+
 	.custom-tag.light-blue {
 		background-color: #00baf4;
 		color: white;

+ 2 - 2
frontend/io.js

@@ -49,7 +49,7 @@ export default {
 
 	removeAllListeners: function () {
 		Object.keys(this.socket._callbacks).forEach((id) => {
-			if (id.indexOf("$event:") !== -1) {
+			if (id.indexOf("$event:") !== -1 && id.indexOf("$event:keep.") === -1) {
 				delete this.socket._callbacks[id];
 			}
 		});
@@ -94,4 +94,4 @@ export default {
 		callbacks = [];
 		callbacksPersist = [];
 	}
-}
+}

+ 3 - 0
frontend/main.js

@@ -35,6 +35,9 @@ lofig.get('serverDomain', function(res) {
 		socket.on("ready", (status, role, username, userId) => {
 			auth.data(status, role, username, userId);
 		});
+		socket.on('keep.event:banned', ban => {
+			auth.setBanned(ban);
+		});
 	});
 });
 

+ 4 - 3
windows-start.cmd

@@ -1,8 +1,9 @@
 start "Redis" "startRedis.cmd"
 start "Mongo" "startmongo.cmd"
+cd frontend
+start "Frontend" npm run development-watch
+cd ..
+SLEEP 20
 cd backend
 start "Backend" "nodemon"
 cd ..
-cd frontend
-start "Frotend" npm run development-watch
-cd ..