Browse Source

Merge pull request #1 from odiffey/experimental

Experimental
Owen Diffey 5 years ago
parent
commit
2c1747a940
100 changed files with 12346 additions and 5104 deletions
  1. 0 20
      .editorconfig
  2. 18 0
      .env.example
  3. 19 6
      .gitignore
  4. 40 0
      .travis.yml
  5. 213 94
      README.md
  6. 2 1
      backend/Dockerfile
  7. 28 11
      backend/config/template.json
  8. 83 0
      backend/core.js
  9. 173 59
      backend/index.js
  10. 57 10
      backend/logic/actions/apis.js
  11. 10 7
      backend/logic/actions/hooks/adminRequired.js
  12. 9 6
      backend/logic/actions/hooks/loginRequired.js
  13. 11 8
      backend/logic/actions/hooks/ownerRequired.js
  14. 2 1
      backend/logic/actions/index.js
  15. 18 16
      backend/logic/actions/news.js
  16. 70 63
      backend/logic/actions/playlists.js
  17. 121 0
      backend/logic/actions/punishments.js
  18. 70 27
      backend/logic/actions/queueSongs.js
  19. 27 18
      backend/logic/actions/reports.js
  20. 241 100
      backend/logic/actions/songs.js
  21. 371 124
      backend/logic/actions/stations.js
  22. 331 71
      backend/logic/actions/users.js
  23. 34 21
      backend/logic/api.js
  24. 220 211
      backend/logic/app.js
  25. 103 77
      backend/logic/cache/index.js
  26. 5 0
      backend/logic/cache/schemas/punishment.js
  27. 1 0
      backend/logic/cache/schemas/session.js
  28. 219 45
      backend/logic/db/index.js
  29. 9 0
      backend/logic/db/schemas/punishment.js
  30. 2 1
      backend/logic/db/schemas/queueSong.js
  31. 4 1
      backend/logic/db/schemas/report.js
  32. 2 1
      backend/logic/db/schemas/song.js
  33. 1 7
      backend/logic/db/schemas/user.js
  34. 91 0
      backend/logic/discord.js
  35. 169 92
      backend/logic/io.js
  36. 158 152
      backend/logic/logger.js
  37. 30 16
      backend/logic/mail/index.js
  38. 4 1
      backend/logic/mail/schemas/passwordRequest.js
  39. 4 1
      backend/logic/mail/schemas/resetPasswordRequest.js
  40. 4 1
      backend/logic/mail/schemas/verifyEmail.js
  41. 115 37
      backend/logic/notifications.js
  42. 81 64
      backend/logic/playlists.js
  43. 243 0
      backend/logic/punishments.js
  44. 84 64
      backend/logic/songs.js
  45. 95 0
      backend/logic/spotify.js
  46. 201 164
      backend/logic/stations.js
  47. 151 0
      backend/logic/tasks.js
  48. 291 121
      backend/logic/utils.js
  49. 24 27
      backend/package.json
  50. 0 30
      docker-compose-production.yml
  51. 26 7
      docker-compose.yml
  52. 148 0
      fallback.html
  53. 9 0
      frontend/.babelrc
  54. 2 0
      frontend/.eslintignore
  55. 28 8
      frontend/.eslintrc
  56. 3 0
      frontend/.prettierignore
  57. 5 0
      frontend/.prettierrc
  58. 21 0
      frontend/.snyk
  59. 246 204
      frontend/App.vue
  60. 11 5
      frontend/Dockerfile
  61. 93 0
      frontend/api/auth.js
  62. 28 8
      frontend/auth.js
  63. 9 0
      frontend/bootstrap.sh
  64. BIN
      frontend/build/assets/notes-transparent.png
  65. 0 12
      frontend/build/browserconfig.xml
  66. 0 10
      frontend/build/config/template.json
  67. 0 12
      frontend/build/index.css
  68. 0 50
      frontend/build/index.html
  69. 0 1
      frontend/build/vendor/jquery.min.js
  70. 23 17
      frontend/components/404.vue
  71. 501 0
      frontend/components/Admin/EditStation.vue
  72. 327 199
      frontend/components/Admin/News.vue
  73. 191 0
      frontend/components/Admin/Punishments.vue
  74. 218 127
      frontend/components/Admin/QueueSongs.vue
  75. 130 92
      frontend/components/Admin/Reports.vue
  76. 223 128
      frontend/components/Admin/Songs.vue
  77. 338 187
      frontend/components/Admin/Stations.vue
  78. 274 224
      frontend/components/Admin/Statistics.vue
  79. 113 85
      frontend/components/Admin/Users.vue
  80. 142 22
      frontend/components/MainFooter.vue
  81. 134 79
      frontend/components/MainHeader.vue
  82. 150 0
      frontend/components/Modals/AddSongToPlaylist.vue
  83. 189 110
      frontend/components/Modals/AddSongToQueue.vue
  84. 127 59
      frontend/components/Modals/CreateCommunityStation.vue
  85. 306 167
      frontend/components/Modals/EditNews.vue
  86. 1718 404
      frontend/components/Modals/EditSong.vue
  87. 314 173
      frontend/components/Modals/EditStation.vue
  88. 191 58
      frontend/components/Modals/EditUser.vue
  89. 71 19
      frontend/components/Modals/IssuesModal.vue
  90. 123 48
      frontend/components/Modals/Login.vue
  91. 81 0
      frontend/components/Modals/MobileAlert.vue
  92. 31 25
      frontend/components/Modals/Modal.vue
  93. 98 62
      frontend/components/Modals/Playlists/Create.vue
  94. 363 192
      frontend/components/Modals/Playlists/Edit.vue
  95. 161 62
      frontend/components/Modals/Register.vue
  96. 241 175
      frontend/components/Modals/Report.vue
  97. 108 0
      frontend/components/Modals/ViewPunishment.vue
  98. 148 83
      frontend/components/Modals/WhatIsNew.vue
  99. 181 131
      frontend/components/Sidebars/Playlist.vue
  100. 243 83
      frontend/components/Sidebars/SongsList.vue

+ 0 - 20
.editorconfig

@@ -1,20 +0,0 @@
-root = true
-
-[*]
-charset = utf-8
-indent_style = tab
-
-[frontend/nginx.conf]
-charset = utf-8
-indent_style = space
-indent_size = 4
-
-[docker-compose.yml]
-charset = utf-8
-indent_style = space
-indent_size = 2
-
-end_of_line = lf
-insert_final_newline = true
-trim_trailing_whitespace = true
-continuation_indent_size = 4

+ 18 - 0
.env.example

@@ -0,0 +1,18 @@
+REDIS_PASSWORD=PASSWORD
+
+BACKEND_PORT=8080
+FRONTEND_PORT=80
+
+MONGO_PORT=27017
+MONGO_ROOT_PASSWORD=PASSWORD_HERE
+MONGO_USER_USERNAME=musare
+MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+
+MONGOCLIENT_PORT=3000
+
+REDIS_PORT=6379
+
+COMPOSE_PROJECT_NAME=musare
+
+FRONTEND_MODE=dev
+SNYK_TOKEN=

+ 19 - 6
.gitignore

@@ -2,21 +2,34 @@ Thumbs.db
 .DS_Store
 *.swp
 .idea/
+.vscode/
 .vagrant/
 
+.env
 startRedis.cmd
 startMongo.cmd
 .database
-dump.rdb
+.db
+.redis
+*.rdb
 npm-debug.log
 
-# Back End
+# Backend
 backend/node_modules/
 backend/config/default.json
 
-# Front End
+# Frontend
+frontend/yarn-error.log
+frontend/bundle-stats.json
+frontend/bundle-report.html
 frontend/node_modules/
-frontend/build/bundle.js
-frontend/build/config/default.json
+frontend/dist/build/
+!frontend/dist/lofig.min.js
+frontend/dist/index.html
+frontend/dist/config/default.json
 
-npm
+npm
+node_modules
+
+# Logs
+log/

+ 40 - 0
.travis.yml

@@ -0,0 +1,40 @@
+# .travis.yml
+
+
+language: minimal
+sudo: required
+services:
+  - docker
+
+env:
+  global:
+    - REDIS_PASSWORD=PASSWORD
+    - BACKEND_PORT=8080
+    - FRONTEND_PORT=80
+    - MONGO_PORT=27017
+    - MONGO_ROOT_PASSWORD=PASSWORD_HERE
+    - MONGO_USER_USERNAME=musare
+    - MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+    - MONGOCLIENT_PORT=3000
+    - REDIS_PORT=6379
+    - COMPOSE_PROJECT_NAME=musare
+    - FRONTEND_MODE=prod
+
+before_install:
+  # create config files from template
+  - cp backend/config/template.json backend/config/default.json
+  - cp frontend/dist/config/template.json frontend/dist/config/default.json
+
+jobs:
+  include:
+    - stage: frontend
+      script:
+        - docker-compose build frontend # build frontend
+        - docker-compose up -d frontend # start frontend
+        - docker-compose exec frontend /bin/bash -c "cd app && yarn lint" # using eslint to check for formatting/linting issues
+    - stage: backend
+      script:
+        - docker-compose up -d mongo # start mongo (users automatically setup)
+        - docker-compose up -d redis # start redis
+        - docker-compose build backend # build backend
+        - docker-compose up -d backend # start backend

+ 213 - 94
README.md

@@ -1,119 +1,222 @@
+
+  
 # MusareNode
-This is a rewrite of the original [Musare](https://github.com/Musare/MusareMeteor)
-in NodeJS, Express, SocketIO and VueJS. Everything is ran in it's own docker container, but you can also run it without Docker.
 
-The site is available at [https://musare.com](https://musare.com).
+Based off of the original [Musare](https://github.com/Musare/MusareMeteor), which utilized Meteor.
+
+MusareNode now uses NodeJS, Express, SocketIO and VueJS - among other technologies. We have also implemented the ability to host Musare in [Docker Containers](https://www.docker.com/).
+
+The master branch is available at [musare.com](https://musare.com)
+You can also find the staging branch at [musare.dev](https://musare.dev)
+
+## Contact
+
+Get in touch with us via email at [core@musare.com](mailto:core@musare.com) or join our [Discord Guild](https://discord.gg/Y5NxYGP).
+
+You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Twitter](https://twitter.com/MusareApp).
 
 ### Our Stack
 
-   * NodeJS
-   * MongoDB
-   * Redis
-   * Nginx (not required)
-   * VueJS
+- NodeJS
+- MongoDB
+- Redis
+- Nginx (not required)
+- VueJS
 
 ### Frontend
-The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
-[vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
-served over Nginx or express. The Nginx server not only serves the frontend, but
-also serves as a load balancer for requests going to the backend.
+
+The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated, [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
 
 ### Backend
-The backend is a scalable NodeJS / Redis / MongoDB app. Each backend
-server handles a group of SocketIO connections. User sessions are stored
-in a central Redis server. All data is stored in a central MongoDB server.
-The Redis and MongoDB servers are replicated to several secondary nodes,
-which can become the primary node if the current primary node goes down.
 
-We currently only have 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+The backend is a scalable NodeJS / Redis / MongoDB app. Each backend server handles a group of SocketIO connections. User sessions are stored in a central Redis server. All data is stored in a central MongoDB server. The Redis and MongoDB servers are replicated to several secondary nodes, which can become the primary node if the current primary node goes down.
+
+We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
 
 ## Requirements
-Option 1: (not recommended for Windows users)
- * [Docker](https://www.docker.com/)
- 
-Option 2:
- * [NodeJS](https://nodejs.org/en/download/)
- 	* nodemon: `npm install -g nodemon`
- 	* [node-gyp](https://github.com/nodejs/node-gyp#installation)
- * [MongoDB](https://www.mongodb.com/download-center)
- * [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
+Installing with Docker: (not recommended for Windows users)
+
+- [Docker](https://www.docker.com/)
+
+Standard Installation:
+
+- [NodeJS](https://nodejs.org/en/download/)
+  _ nodemon: `yarn global add nodemon`
+  _ [node-gyp](https://github.com/nodejs/node-gyp#installation): `yarn global add node-gyp`
+- [Yarn (Windows)](https://yarnpkg.com/lang/en/docs/install/#windows-stable) [Yarn (Unix)](https://yarnpkg.com/lang/en/docs/install/#debian-stable) ([npm](https://www.npmjs.com/) can also be used)
+- [MongoDB](https://www.mongodb.com/download-center) Currently version 4.0
+- [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
 
 ## Getting Started
+
 Once you've installed the required tools:
 
-1. `git clone https://github.com/MusareNode/MusareNode.git`
+1. `git clone https://github.com/Musare/MusareNode.git`
 
 2. `cd MusareNode`
 
 3. `cp backend/config/template.json backend/config/default.json`
 
-	Values:  
-   	The `secret` key can be whatever. It's used by express's session module.  
-   	The `domain` should be the url where the site will be accessible from, usually `http://localhost` for non-Docker.  
-   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.  
-   	The `serverPort` should be the port where the backend will listen on, usually `8080` for non-Docker.  
-   	`isDocker` if you are using Docker or not.  
-   	The `apis.youtube.key` value can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started).  
-   	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.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 `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
-   	The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
-   	 
+|Property|Description|
+|--|--|
+|`mode`|Should be either `development` or `production`. No more explanation needed.|
+|`secret`|Whatever you want - used by express's session module.|
+|`domain`|Should be the url where the site will be accessible from,usually `http://localhost` for non-Docker.|
+|`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
+|`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
+|`isDocker`|Self-explanatory. Are you using Docker?|
+|`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
+|`apis.youtube.key`|Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key.|
+|`apis.recaptcha.secret`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
+|`apis.github`|Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.|
+|`apis.discord.token`|Token for the Discord bot.|
+|`apis.discord.loggingServer`|Server ID of the Discord logging server.|
+|`apis.discord.loggingChannel`|ID of the channel to be used in the Discord logging server.|
+|`apis.mailgun`|Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it.|
+|`apis.spotify`|Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.|
+|`apis.discogs`|Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it.|
+|`redis.url`|Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.|
+|`redis.password`|Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.|
+|`mongo.url`|Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.|
+|`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
+|`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
+
 4. `cp frontend/build/config/template.json frontend/build/config/default.json`
 
-	Values:  
-   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.   
-   	The `recaptcha.key` value can be obtained by setting up a [ReCaptcha Site](https://www.google.com/recaptcha/admin).  
-   	The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
-   	The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
+|Property|Description|
+|--|--|
+|`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
+|`frontendDomain`|Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker.|
+|`frontendPort`|Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker.|
+|`recaptcha.key`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
+|`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
+|`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
+|`siteSettings.logo`|Path to the logo image, by default it is `/assets/wordmark.png`.|
+|`siteSettings.siteName`|Should be the name of the site.|
+|`siteSettings.socialLinks`|`github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed.|
+
+5. Simply `cp .env.example .env` to setup your environment variables.
+
+6. To setup [snyk](https://snyk.io/) (which is what we use for our precommit git-hooks), you will need to:
+- Setup an account
+- Go to [settings](https://app.snyk.io/account)
+- Copy the API token and set it as your `SNYK_TOKEN` environment variable.
+
+We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
 
-Now you have different paths here.
+### Installing with Docker
 
-####Docker
+_Configuration_
+
+To configure docker configure the `.env` file to match your settings in `backend/config/default.json`.  
+The configurable ports will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. 
+`COMPOSE_PROJECT_NAME` should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine.
+`FRONTEND_MODE` should be either `dev` or `prod` (self-explanatory).
 
 1. Build the backend and frontend Docker images (from the main folder)
 
    `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. Set the password for the admin/root user.
+
+      In `.env` set the environment variable of `MONGO_ROOT_PASSWORD`.
+
+   2. Set the password for the musare user (the one the backend will use).
+
+      In `.env` set the environment variable of `MONGO_USER_USERNAME` and `MONGO_USER_PASSWORD`.
+
+   2. Start the database (in detached mode), which will generate the correct MongoDB users.
+
+      `docker-compose up -d mongo`
+
+
+3) Start redis and the mongo client in the background, as we usually don't need to monitor these for errors
 
-   `docker-compose up -d mongo mongoclient redis`
+   `docker-compose up -d 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:
 
-   * Docker for Windows / Mac: This is just `localhost`
-   
-   * Docker ToolBox: The output of `docker-machine ip default`
-   
-####Non-docker
+   - Docker for Windows / Mac: This is just `localhost`
+
+   - Docker ToolBox: The output of `docker-machine ip default`
+
+If you are using linting extensions in IDEs/want to run `yarn lint`, you need to install the following locally (outside of Docker):
+```
+yarn global add eslint
+yarn add eslint-config-airbnb-base
+```
+
+### Standard Installation
+
+Steps 1-4 are things you only have to do once. The steps to start servers follow.
+
+1.  In the main folder, create a folder called `.database`
+
+2.  Create a file called `startMongo.cmd` in the main folder with the contents:
 
-1. In the main folder, create a folder called `.database`
+        "C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
 
-2. Create a file called `startMongo.cmd` in the main folder with the contents:
+    Make sure to adjust your paths accordingly.
 
-		"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.
+3.  Set up the MongoDB database
 
-4. Create a file called `startRedis.cmd` in the main folder with the contents:
+    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" "--requirepass" "PASSWORD"
+
+    And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+
+### Non-docker start servers
+
+**Automatic**
+
+1.  If you are on Windows you can run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
+
+**Manual**
+
+1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
+
+2. In a command prompt with the pwd of frontend, run `yarn run dev`
+
+3. In a command prompt with the pwd of backend, run `nodemon`
 
-		"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf"
-		
-	And again, make sure that the paths lead to the proper config and executable.
-   
 ## Extra
 
 Below is a list of helpful tips / solutions we've collected while developing MusareNode.
@@ -133,24 +236,25 @@ of the following commands to give Docker Toolbox access to those files.
 
    `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" sharedfolder add default --name "d/Projects/MusareNode" --hostpath "D:\Projects\MusareNode" --automount`
 
-2. Now start the machine back up and ssh into it
+1. Now start the machine back up and ssh into it
 
    `docker-machine start default && docker-machine ssh default`
-   
-3. Tell boot2docker to mount our volume at startup, by appending to its startup script
-	```bash
-	sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
 
-	mkdir -p /d/Projects/MusareNode
-	mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
-	EOF
-	```
+1. Tell boot2docker to mount our volume at startup, by appending to its startup script
+
+   ```bash
+   sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
 
-4. Restart the docker machine so that it uses the new shared folder
+   mkdir -p /d/Projects/MusareNode
+   mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
+   EOF
+   ```
+
+1. Restart the docker machine so that it uses the new shared folder
 
    `docker-machine restart default`
-   
-5. You now should be good to go!
+
+1. You now should be good to go!
 
 ### Fixing the "couldn't connect to docker daemon" error
 
@@ -166,31 +270,46 @@ Run this command in your shell. You will have to do this command for every shell
 
 2. Install nodemon globally
 
-   `npm install nodemon -g`
+   `yarn global add nodemon`
 
 3. Install webpack globally
 
-   `npm install webpack -g`
+   `yarn global add webpack`
 
 4. Install node-gyp globally (first check out https://github.com/nodejs/node-gyp#installation)
 
-   `npm install node-gyp -g`.
+   `yarn global add node-gyp`.
 
-5. In both `frontend` and `backend` folders, do `npm install`.
+5. Run `yarn run bootstrap` to install dependencies and dev-dependencies for both the frontend and backend.
 
-6. `nodemon backend/index.js`
+6. Either execute `yarn run dev:frontend` and `yarn run dev:backend` separately, or in parallel with `yarn dev`.
 
 ### Calling Toasts
 
 You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
 
 ```js
-import { Toast } from 'vue-roaster';
-Toast.methods.addToast('', 0);
+import { Toast } from "vue-roaster";
+Toast.methods.addToast("", 0);
 ```
 
-## Contact
+### Set user role
+
+When setting up you will need to grant yourself the admin role, using the following commands:
+
+```
+docker-compose exec mongo mongo admin
 
-There are multiple ways to contact us. You can send an email to [musaremusic@gmail.com](musaremusic@gmail.com) or [krisvos130@gmail.com](krisvos130@gmail.com).
+use musare
+db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
+db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
+```
+
+### Adding a package
 
-You can also message us on [Facebook](https://www.facebook.com/MusareMusic), [Twitter](https://twitter.com/MusareApp) or on our [Discord](https://discord.gg/Y5NxYGP).
+We use lerna to add an additional package to either the frontend or the backend.
+
+For example, this is how we would to add the `webpack-bundle-analyser` package as a dev-dependency to the frontend:
+```
+npx lerna add webpack-bundle-analyser --scope=musare-frontend --dev
+```

+ 2 - 1
backend/Dockerfile

@@ -3,6 +3,7 @@ FROM node
 RUN apt-get update
 
 RUN npm install -g nodemon
+RUN npm install -g snyk
 
 RUN mkdir -p /opt
 WORKDIR /opt
@@ -12,4 +13,4 @@ RUN npm install
 
 EXPOSE 80
 
-CMD npm run development
+CMD npm run docker:dev

+ 28 - 11
backend/config/template.json

@@ -1,9 +1,12 @@
 {
-	"secret": "",
-	"domain": "",
-	"serverDomain": "",
+	"mode": "development",
+	"secret": "default",
+	"domain": "http://localhost",
+	"frontendPort": 80,
+	"serverDomain": "http://localhost:8080",
   	"serverPort": 8080,
-  	"isDocker": true,
+	"isDocker": true,
+	"fancyConsole": true,
 	"apis": {
 		"youtube": {
 			"key": ""
@@ -17,12 +20,24 @@
 			"redirect_uri": ""
 		},
 		"discord": {
-			"client": "",
-			"secret": ""
+			"token": "",
+			"loggingChannel": "",
+			"loggingServer": ""
 		},
 		"mailgun": {
 			"key": "",
-			"domain": ""
+			"domain": "",
+		  	"enabled": false
+		},
+		"spotify": {
+			"client": "",
+			"secret": "",
+			"enabled": false
+		},
+		"discogs": {
+			"client": "",
+			"secret": "",
+			"enabled": false
 		}
 	},
 	"cors": {
@@ -33,13 +48,15 @@
 		]
 	},
   	"redis": {
-	  	"url": "redis://redis:6379/0"
+	  	"url": "redis://redis:6379/0",
+	    "password": "PASSWORD"
 	},
   	"mongo": {
-	  	"url": "mongodb://mongo:27017/musare"
+	  	"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
 	},
   	"cookie": {
-	  	"domain": "",
-	  	"secure": false
+	  	"domain": "localhost",
+		"secure": false,
+		"SIDname": "SID"  
 	}
 }

+ 83 - 0
backend/core.js

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

+ 173 - 59
backend/index.js

@@ -1,82 +1,196 @@
 'use strict';
 
+const util = require("util");
+
 process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 
-const async = require('async');
-
-const db = require('./logic/db');
-const app = require('./logic/app');
-const mail = require('./logic/mail');
-const api = require('./logic/api');
-const io = require('./logic/io');
-const stations = require('./logic/stations');
-const songs = require('./logic/songs');
-const playlists = require('./logic/playlists');
-const cache = require('./logic/cache');
-const notifications = require('./logic/notifications');
-const logger = require('./logic/logger');
-const config = require('config');
+const config = require("config");
 
 process.on('uncaughtException', err => {
-	//console.log(`ERROR: ${err.message}`);
-	console.log(`ERROR: ${err.stack}`);
+	if (err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
+	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 
-async.waterfall([
-
-	// setup our Redis cache
-	(next) => {
-		cache.init(config.get('redis').url, () => {
-			next();
-		});
-	},
+const fancyConsole = config.get("fancyConsole");
+
+class ModuleManager {
+	constructor() {
+		this.modules = {};
+		this.modulesInitialized = 0;
+		this.totalModules = 0;
+		this.modulesLeft = [];
+		this.i = 0;
+		this.lockdown = false;
+		this.fancyConsole = fancyConsole;
+	}
 
-	// setup our MongoDB database
-	(next) => db.init(config.get("mongo").url, next),
+	addModule(moduleName) {
+		console.log("add module", moduleName);
+		const moduleClass = new require(`./logic/${moduleName}`);
+		this.modules[moduleName] = new moduleClass(moduleName, this);
+		this.totalModules++;
+		this.modulesLeft.push(moduleName);
+	}
 
-	// setup the express server
-	(next) => app.init(next),
+	initialize() {
+		if (!this.modules["logger"]) return console.error("There is no logger module");
+		this.logger = this.modules["logger"];
+		if (this.fancyConsole) {
+			this.replaceConsoleWithLogger();
+			this.logger.reservedLines = Object.keys(this.modules).length + 5;
+		}
+		
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			if (this.lockdown) break;
+
+			module._onInitialize().then(() => {
+				this.moduleInitialized(moduleName);
+			});
+
+			let dependenciesInitializedPromises = [];
+			
+			module.dependsOn.forEach(dependencyName => {
+				let dependency = this.modules[dependencyName];
+				dependenciesInitializedPromises.push(dependency._onInitialize());
+			});
+
+			module.lastTime = Date.now();
+
+			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+				if (this.lockdown) return;
+				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+				module._initialize();
+			});
+		}
+	}
 
-	// setup the mail
-	(next) => mail.init(next),
+	async printStatus() {
+		try { await Promise.race([this.logger._onInitialize, this.logger._isInitialized]); } catch { return; }
+		if (!this.fancyConsole) return;
+		
+		let colors = this.logger.colors;
 
-	// setup the socket.io server (all client / server communication is done over this)
-	(next) => io.init(next),
+		const rows = process.stdout.rows;
 
-	// setup the notifications
-	(next) => notifications.init(config.get('redis').url, next),
+		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+		process.stdout.clearScreenDown();
 
-	// setup the stations
-	(next) => stations.init(next),
+		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
 
-	// setup the songs
-	(next) => songs.init(next),
+		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
 
-	// setup the playlists
-	(next) => playlists.init(next),
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
 
-	// setup the API
-	(next) => api.init(next),
+			let tabs = Array(tabsAmount).fill(`\t`).join("");
 
-	// setup the logger
-	(next) => logger.init(next),
+			let timing = module.timeDifferences.map((timeDifference) => {
+				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
+			}).join(", ");
 
-	// setup the frontend for local setups
-	(next) => {
-		if (!config.get("isDocker")) {
-			const express = require('express');
-			const app = express();
-			const server = app.listen(80);
-			app.use(express.static(__dirname + "/../frontend/build/"));
+			let stateColor;
+			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
+			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
+			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
+			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
+			else stateColor = colors.FgYellow;
+			
+			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
 		}
-		next();
 	}
-], (err) => {
-	if (err && err !== true) {
-		console.error('An error occurred while initializing the backend server');
-		console.error(err);
-		process.exit();
-	} else {
-		console.info('Backend server has been successfully started');
+
+	moduleInitialized(moduleName) {
+		this.modulesInitialized++;
+		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
+
+		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
+
+		if (this.modulesLeft.length === 0) this.allModulesInitialized();
+	}
+
+	allModulesInitialized() {
+		this.logger.success("MODULE_MANAGER", "All modules have started!");
+		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
+	}
+
+	aModuleFailed(failedModule) {
+		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
+		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
+
+		this._lockdown();
+	}
+
+	replaceConsoleWithLogger() {
+		this.oldConsole = {
+			log: console.log,
+			debug: console.debug,
+			info: console.info,
+			warn: console.warn,
+			error: console.error
+		};
+		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
 	}
+
+	replaceLoggerWithConsole() {
+		console.log = this.oldConsole.log;
+		console.debug = this.oldConsole.debug;
+		console.info = this.oldConsole.info;
+		console.warn = this.oldConsole.warn;
+		console.error = this.oldConsole.error;
+	}
+
+	_lockdown() {
+		this.lockdown = true;
+		
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			if (module.lockdownImmune) continue;
+			module._lockdown();
+		}
+	}
+}
+
+const moduleManager = new ModuleManager();
+
+module.exports = moduleManager;
+
+moduleManager.addModule("cache");
+moduleManager.addModule("db");
+moduleManager.addModule("mail");
+moduleManager.addModule("api");
+moduleManager.addModule("app");
+moduleManager.addModule("discord");
+moduleManager.addModule("io");
+moduleManager.addModule("logger");
+moduleManager.addModule("notifications");
+moduleManager.addModule("playlists");
+moduleManager.addModule("punishments");
+moduleManager.addModule("songs");
+moduleManager.addModule("spotify");
+moduleManager.addModule("stations");
+moduleManager.addModule("tasks");
+moduleManager.addModule("utils");
+
+moduleManager.initialize();
+
+process.stdin.on("data", function (data) {
+    if(data.toString() === "lockdown\r\n"){
+        console.log("Locking down.");
+       	moduleManager._lockdown();
+    }
 });
+
+
+if (fancyConsole) {
+	const rows = process.stdout.rows;
+
+	for(let i = 0; i < rows; i++) {
+		process.stdout.write("\n");
+	}
+}

+ 57 - 10
backend/logic/actions/apis.js

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

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

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

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

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

+ 11 - 8
backend/logic/actions/hooks/ownerRequired.js

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

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

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

+ 70 - 63
backend/logic/actions/playlists.js

@@ -1,20 +1,22 @@
 'use strict';
 
-const db = require('../db');
-const io = require('../io');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
-const hooks = require('./hooks');
 const async = require('async');
-const playlists = require('../playlists');
-const songs = require('../songs');
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const playlists = moduleManager.modules["playlists"];
+const songs = moduleManager.modules["songs"];
 
 cache.sub('playlist.create', playlistId => {
 	playlists.getPlaylist(playlistId, (err, playlist) => {
 		if (!err) {
 			utils.socketsFromUser(playlist.createdBy, (sockets) => {
-				sockets.forEach((socket) => {
+				sockets.forEach(socket => {
 					socket.emit('event:playlist.create', playlist);
 				});
 			});
@@ -23,48 +25,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 });
 		});
 	});
@@ -90,9 +92,9 @@ let lib = {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
 				next(null, playlist.songs[0]);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -116,9 +118,9 @@ let lib = {
 			(next) => {
 				db.models.playlist.find({ createdBy: userId }, next);
 			}
-		], (err, playlists) => {
+		], async (err, playlists) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${userId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -155,15 +157,17 @@ let lib = {
 				}, next);
 			}
 
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
 			cache.pub('playlist.create', playlist._id);
 			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${userId}".`);
-			cb({ 'status': 'success', 'message': 'Successfully created playlist' });
+			cb({ status: 'success', message: 'Successfully created playlist', data: {
+				_id: playlist._id
+			} });
 		});
 	}),
 
@@ -185,9 +189,9 @@ let lib = {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -212,15 +216,15 @@ let lib = {
 	update: hooks.loginRequired((session, playlistId, playlist, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.update({ _id: playlistId, createdBy: userId }, playlist, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: userId }, playlist, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next)
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -270,7 +274,7 @@ let lib = {
 				});
 			},
 			(newSong, next) => {
-				db.models.playlist.update({ _id: playlistId }, { $push: { songs: newSong } }, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
 					if (err) return next(err);
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 						next(err, playlist, newSong);
@@ -278,15 +282,16 @@ let lib = {
 				});
 			}
 		],
-		(err, playlist, newSong) => {
+		async (err, playlist, newSong) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
+			} else {
+				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 });
 		});
 	}),
 
@@ -326,14 +331,15 @@ let lib = {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
+			} else {
+				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 });
 		});
 	}),
 
@@ -360,21 +366,22 @@ let lib = {
 
 			(playlist, next) => {
 				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
 			},
 
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
+			} else {
+				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 });
 		});
 	}),
 
@@ -389,15 +396,15 @@ let lib = {
 	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.update({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -434,14 +441,14 @@ let lib = {
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 						songs: {
 							$each: [song],
@@ -454,9 +461,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -493,14 +500,14 @@ let lib = {
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 						songs: song
 					}
@@ -510,9 +517,9 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -535,9 +542,9 @@ let lib = {
 			(next) => {
 				playlists.deletePlaylist(playlistId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
@@ -549,4 +556,4 @@ let lib = {
 
 };
 
-module.exports = lib;
+module.exports = lib;

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

@@ -0,0 +1,121 @@
+'use strict';
+
+const async = require('async');
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const logger = moduleManager.modules["logger"];
+const utils = moduleManager.modules["utils"];
+const cache = moduleManager.modules["cache"];
+const db = moduleManager.modules["db"];
+const punishments = moduleManager.modules["punishments"];
+
+cache.sub('ip.ban', data => {
+	utils.emitToRoom('admin.punishments', 'event:admin.punishment.added', data.punishment);
+	utils.socketsFromIP(data.ip, sockets => {
+		sockets.forEach(socket => {
+			socket.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);
+			}
+		], async (err, punishments) => {
+			if (err) {
+				err = await 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)
+			}
+		], async (err, punishment) => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("BAN_IP", `User ${userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
+				cb({ status: 'failure', message: err });
+			}
+			logger.success("BAN_IP", `User ${userId} has successfully banned IP address ${value} with the reason ${reason}.`);
+			cache.pub('ip.ban', { ip: value, punishment });
+			return cb({
+				status: 'success',
+				message: 'Successfully banned IP address.'
+			});
+		});
+	}),
+
+};

+ 70 - 27
backend/logic/actions/queueSongs.js

@@ -1,17 +1,20 @@
 'use strict';
 
-const db = require('../db');
-const utils = require('../utils');
-const logger = require('../logger');
-const notifications = require('../notifications');
-const cache = require('../cache');
-const async = require('async');
 const config = require('config');
+const async = require('async');
 const request = require('request');
+
 const hooks = require('./hooks');
 
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const cache = moduleManager.modules["cache"];
+
 cache.sub('queue.newSong', songId => {
-	db.models.queueSong.findOne({songId}, (err, song) => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
 	});
 });
@@ -21,12 +24,12 @@ cache.sub('queue.removedSong', songId => {
 });
 
 cache.sub('queue.update', songId => {
-	db.models.queueSong.findOne({songId}, (err, song) => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
 	});
 });
 
-module.exports = {
+let lib = {
 
 	/**
 	 * Gets all queuesongs
@@ -39,9 +42,9 @@ module.exports = {
 			(next) => {
 				db.models.queueSong.find({}, next);
 			}
-		], (err, songs) => {
+		], async (err, songs) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_INDEX", `Indexing queuesongs failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			} else {
@@ -91,11 +94,11 @@ module.exports = {
 				let $set = {};
 				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
 				if (!updated) return next('No properties changed');
-				db.models.queueSong.update({_id: songId}, {$set}, next);
+				db.models.queueSong.updateOne({_id: songId}, {$set}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await  utils.getError(err);
 				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -116,11 +119,11 @@ module.exports = {
 	remove: hooks.adminRequired((session, songId, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.queueSong.remove({_id: songId}, next);
+				db.models.queueSong.deleteOne({_id: songId}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -155,24 +158,23 @@ module.exports = {
 			(song, next) => {
 				if (song) return next('This song has already been added.');
 				//TODO Add err object as first param of callback
-				console.log(52, songId);
 				utils.getSongFromYouTube(songId, (song) => {
 					song.artists = [];
 					song.genres = [];
 					song.skipDuration = 0;
-					song.thumbnail = 'empty';
+					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
 					song.explicit = false;
 					song.requestedBy = userId;
 					song.requestedAt = requestedAt;
 					next(null, song);
 				});
 			},
-			(newSong, next) => {
-				//TODO Add err object as first param of callback
-				utils.getSongFromSpotify(newSong, (song) => {
-					next(null, song);
+			/*(newSong, next) => {
+				utils.getSongFromSpotify(newSong, (err, song) => {
+					if (!song) next(null, newSong);
+					else next(err, song);
 				});
-			},
+			},*/
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
 				song.save((err, song) => {
@@ -192,9 +194,9 @@ module.exports = {
 					}
 				});
 			}
-		], (err, newSong) => {
+		], async (err, newSong) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -202,5 +204,46 @@ module.exports = {
 			logger.success("QUEUE_ADD", `User "${userId}" successfully added queuesong "${songId}".`);
 			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
 		});
+	}),
+
+	/**
+	 * Adds a set of songs to the queue
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} url - the url of the the YouTube playlist
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	addSetToQueue: hooks.loginRequired((session, url, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				utils.getPlaylistFromYouTube(url, songs => {
+					next(null, songs);
+				});
+			},
+			(songs, next) => {
+				let processed = 0;
+				function checkDone() {
+					if (processed === songs.length) next();
+				}
+				for (let s = 0; s < songs.length; s++) {
+					lib.add(session, songs[s].contentDetails.videoId, () => {
+						processed++;
+						checkDone();
+					});
+				}
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err});
+			} else {
+				logger.success("QUEUE_IMPORT", `Successfully imported a YouTube playlist to the queue for user "${userId}".`);
+				cb({ status: 'success', message: 'Playlist has been successfully imported.' });
+			}
+		});
 	})
-};
+};
+
+module.exports = lib;

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

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

+ 241 - 100
backend/logic/actions/songs.js

@@ -1,27 +1,30 @@
 'use strict';
 
-const db = require('../db');
-const io = require('../io');
-const songs = require('../songs');
-const cache = require('../cache');
 const async = require('async');
-const utils = require('../utils');
-const logger = require('../logger');
+
 const hooks = require('./hooks');
 const queueSongs = require('./queueSongs');
 
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const songs = moduleManager.modules["songs"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 cache.sub('song.removed', songId => {
 	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
 });
 
 cache.sub('song.added', songId => {
-	db.models.song.findOne({songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
 	});
 });
 
 cache.sub('song.updated', songId => {
-	db.models.song.findOne({songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
 	});
 });
@@ -73,11 +76,11 @@ module.exports = {
 	length: hooks.adminRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.count({}, next);
+				db.models.song.countDocuments({}, next);
 			}
-		], (err, count) => {
+		], async (err, count) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -98,17 +101,45 @@ module.exports = {
 			(next) => {
 				db.models.song.find({}).limit(15 * set).exec(next);
 			}
-		], (err, songs) => {
+		], async (err, songs) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("SONGS_GET_SET", `Got set from songs successfully.`);
+			logger.stationIssue(songs.length, true);
+			logger.stationIssue(Math.max(songs.length - 15, 0), true);
 			cb(songs.splice(Math.max(songs.length - 15, 0)));
 		});
 	}),
 
+	/**
+	 * Gets a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	getSong: hooks.adminRequired((session, songId, cb) => {
+		console.log(songId);
+
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({ songId }).exec(next);
+			}
+		], async (err, song) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("SONGS_GET_SONG", `Failed to get song ${songId}. "${err}"`);
+				return cb({ status: 'failure', message: err });
+			} else {
+				logger.success("SONGS_GET_SONG", `Got song ${songId} successfully.`);
+				cb({ status: "success", data: song });
+			}
+		});
+	}),
+
 	/**
 	 * Updates a song
 	 *
@@ -120,15 +151,15 @@ module.exports = {
 	update: hooks.adminRequired((session, songId, song, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.update({_id: songId}, song, next);
+				db.models.song.updateOne({_id: songId}, song, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				songs.updateSong(songId, next);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -148,19 +179,19 @@ module.exports = {
 	remove: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.remove({songId}, next);
+				db.models.song.deleteOne({_id: songId}, next);
 			},
 
 			(res, next) => {//TODO Check if res gets returned from above
 				cache.hdel('songs', songId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
+				err = await utils.getError(err);
+				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("SONGS_UPDATE", `Successfully updated song "${songId}".`);
+			logger.success("SONGS_UPDATE", `Successfully remove song "${songId}".`);
 			cache.pub('song.removed', songId);
 			cb({status: 'success', message: 'Song has been successfully updated'});
 		});
@@ -176,12 +207,6 @@ module.exports = {
 	 */
 	add: hooks.adminRequired((session, song, cb, userId) => {
 		async.waterfall([
-			(next) => {
-				queueSongs.remove(session, song._id, () => {
-					next();
-				});
-			},
-
 			(next) => {
 				db.models.song.findOne({songId: song.songId}, next);
 			},
@@ -196,10 +221,16 @@ module.exports = {
 				newSong.acceptedBy = userId;
 				newSong.acceptedAt = Date.now();
 				newSong.save(next);
-			}
-		], (err) => {
+			},
+
+			(res, next) => {
+				queueSongs.remove(session, song._id, () => {
+					next();
+				});
+			},
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_ADD", `User "${userId}" failed to add song. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -219,23 +250,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);
+			}
+		], async (err, song) => {
+			if (err) {
+				err = await 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.updateOne({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
+					if (!err) {
+						db.models.user.countDocuments({"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.countDocuments({"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 +298,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);
+			}
+		], async (err, song) => {
+			if (err) {
+				err = await 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.updateOne({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
+					if (!err) {
+						db.models.user.countDocuments({"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.countDocuments({"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 +346,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);
+			}
+		], async (err, song) => {
+			if (err) {
+				err = await 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.updateOne({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+					if (!err) {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
+							if (err) return cb({
+								status: 'failure',
+								message: 'Something went wrong while undisliking this song.'
+							});
+							db.models.user.countDocuments({"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 +415,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);
+			}
+		], async (err, song) => {
+			if (err) {
+				err = await 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.updateOne({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+					if (!err) {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
+							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
+								if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
+								db.models.song.updateOne({_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 +463,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);
 			}
+		], async (err, song) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("SONGS_GET_OWN_RATINGS", `User "${userId}" failed to get ratings for ${songId}. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			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)
+					});
+				}
+			});
 		});
 	})
-};
+};

+ 371 - 124
backend/logic/actions/stations.js

@@ -5,15 +5,18 @@ const async   = require('async'),
 	  config  = require('config'),
 	  _		  =  require('underscore')._;
 
-const io = require('../io');
-const db = require('../db');
-const cache = require('../cache');
-const notifications = require('../notifications');
-const utils = require('../utils');
-const logger = require('../logger');
-const stations = require('../stations');
-const songs = require('../songs');
 const hooks = require('./hooks');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const notifications = moduleManager.modules["notifications"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const stations = moduleManager.modules["stations"];
+const songs = moduleManager.modules["songs"];
+
 let userList = {};
 let usersPerStation = {};
 let usersPerStationCount = {};
@@ -29,39 +32,40 @@ setInterval(() => {
 	usersPerStationCount = {};
 
 	async.each(Object.keys(userList), function(socketId, next) {
-		let socket = utils.socketFromSession(socketId);
-		let stationId = userList[socketId];
-		if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
-			if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-			if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-			delete userList[socketId];
-			return next();
-		}
-		if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
-		usersPerStationCount[stationId]++;
-		if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
-
-		async.waterfall([
-			(next) => {
-				if (!socket.session || !socket.session.sessionId) return next('No session found.');
-				cache.hget('sessions', socket.session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found.');
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
-				next(null, user.username);
-			}
-		], (err, username) => {
-			if (!err) {
-				usersPerStation[stationId].push(username);
+		utils.socketFromSession(socketId).then((socket) => {
+			let stationId = userList[socketId];
+			if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
+				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
+				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
+				delete userList[socketId];
+				return next();
 			}
-			next();
+			if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
+			usersPerStationCount[stationId]++;
+			if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
+
+			async.waterfall([
+				(next) => {
+					if (!socket.session || !socket.session.sessionId) return next('No session found.');
+					cache.hget('sessions', socket.session.sessionId, next);
+				},
+
+				(session, next) => {
+					if (!session) return next('Session not found.');
+					db.models.user.findOne({_id: session.userId}, next);
+				},
+
+				(user, next) => {
+					if (!user) return next('User not found.');
+					if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
+					next(null, user.username);
+				}
+			], (err, username) => {
+				if (!err) {
+					usersPerStation[stationId].push(username);
+				}
+				next();
+			});
 		});
 		//TODO Code to show users
 	}, (err) => {
@@ -78,12 +82,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);
 		});
 
@@ -99,10 +103,10 @@ cache.sub('station.updateUsers', stationId => {
 cache.sub('station.updateUserCount', stationId => {
 	let count = usersPerStationCount[stationId] || 0;
 	utils.emitToRoom(`station.${stationId}`, "event:userCount.updated", count);
-	stations.getStation(stationId, (err, station) => {
+	stations.getStation(stationId, async (err, station) => {
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
@@ -121,6 +125,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);
 });
@@ -130,7 +138,9 @@ cache.sub('privatePlaylist.selected', data => {
 });
 
 cache.sub('station.pause', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:stations.pause");
+	stations.getStation(stationId, (err, station) => {
+		utils.emitToRoom(`station.${stationId}`, "event:stations.pause", { pausedAt: station.pausedAt });
+	});
 });
 
 cache.sub('station.resume', stationId => {
@@ -150,18 +160,19 @@ cache.sub('station.voteSkipSong', stationId => {
 });
 
 cache.sub('station.remove', stationId => {
+	utils.emitToRoom(`station.${stationId}`, 'event:stations.remove');
 	utils.emitToRoom('admin.stations', 'event:admin.station.removed', stationId);
 });
 
 cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, (err, station) => {
+	stations.initializeStation(stationId, async (err, station) => {
 		station.userCount = usersPerStationCount[stationId] || 0;
 		if (err) console.error(err);
 		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
 		// TODO If community, check if on whitelist
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
@@ -234,13 +245,13 @@ module.exports = {
 					next(null, resultStations);
 				});
 			}
-		], (err, stations) => {
+		], async (err, stations) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("STATIONS_INDEX", `Indexing stations successful.`);
+			logger.success("STATIONS_INDEX", `Indexing stations successful.`, false);
 			return cb({'status': 'success', 'stations': stations});
 		});
 	},
@@ -262,13 +273,13 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next(null, station);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_FIND_BY_NAME", `Finding station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			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});
 		});
 	},
@@ -288,26 +299,27 @@ 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) => {
 				if (!playlist) return next('Playlist not found.');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
+				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})
 		});
 	},
 
@@ -345,9 +357,9 @@ module.exports = {
 						if (station.owner === session.userId) return next(true);
 						next('An error occurred while joining the station.');
 					}
-				], (err) => {
+				], async (err) => {
 					if (err === true) return next(null, station);
-					next(utils.getError(err));
+					next(await utils.getError(err));
 				});
 			},
 
@@ -360,9 +372,11 @@ module.exports = {
 					startedAt: station.startedAt,
 					paused: station.paused,
 					timePaused: station.timePaused,
+					pausedAt: station.pausedAt,
 					description: station.description,
 					displayName: station.displayName,
 					privacy: station.privacy,
+					locked: station.locked,
 					partyMode: station.partyMode,
 					owner: station.owner,
 					privatePlaylist: station.privatePlaylist
@@ -377,7 +391,7 @@ module.exports = {
 				if (!data.currentSong || !data.currentSong.title) return next(null, data);
 				utils.socketJoinSongRoom(session.socketId, `song.${data.currentSong.songId}`);
 				data.currentSong.skipVotes = data.currentSong.skipVotes.length;
-				songs.getSong(data.currentSong.songId, (err, song) => {
+				songs.getSongFromId(data.currentSong.songId, (err, song) => {
 					if (!err && song) {
 						data.currentSong.likes = song.likes;
 						data.currentSong.dislikes = song.dislikes;
@@ -388,9 +402,9 @@ module.exports = {
 					next(null, data);
 				});
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -399,6 +413,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.updateOne({ _id: stationId }, { $set: { locked: !station.locked} }, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], async (err, station) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("STATIONS_UPDATE_LOCKED_STATUS", `Toggling the queue lock for station "${stationId}" failed. "${err}"`);
+				return cb({ status: 'failure', message: err });
+			} else {
+				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
 	 *
@@ -415,13 +462,20 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found.');
+				utils.canUserBeInStation(station, userId, (canBe) => {
+					if (canBe) return next(null, station);
+					return next('Insufficient permissions.');
+				});
+			},
+
+			(station, next) => {
 				if (!station.currentSong) return next('There is currently no song to skip.');
 				if (station.currentSong.skipVotes.indexOf(userId) !== -1) return next('You have already voted to skip this song.');
 				next(null, station);
 			},
 
 			(station, next) => {
-				db.models.station.update({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
+				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
 			},
 
 			(res, next) => {
@@ -432,9 +486,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next(null, station);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -462,9 +516,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -492,14 +546,10 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				next();
-			},
-
-			(next) => {
-				cache.client.hincrby('station.userCounts', stationId, -1, next);
 			}
-		], (err, userCount) => {
+		], async (err, userCount) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -521,15 +571,15 @@ module.exports = {
 	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {name: newName}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -549,15 +599,15 @@ module.exports = {
 	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {displayName: newDisplayName}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -577,15 +627,15 @@ module.exports = {
 	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {description: newDescription}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DESCRIPTION", `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -605,15 +655,15 @@ module.exports = {
 	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {privacy: newPrivacy}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_PRIVACY", `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -622,6 +672,62 @@ module.exports = {
 		});
 	}),
 
+	/**
+	 * Updates a station's genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newGenres - the new station genres
+	 * @param cb
+	 */
+	updateGenres: hooks.ownerRequired((session, stationId, newGenres, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.station.updateOne({_id: stationId}, {$set: {genres: newGenres}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("STATIONS_UPDATE_GENRES", `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_UPDATE_GENRES", `Updated station "${stationId}" genres to "${newGenres}" successfully.`);
+			return cb({'status': 'success', 'message': 'Successfully updated the genres.'});
+		});
+	}),
+
+	/**
+	 * Updates a station's blacklisted genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newBlacklistedGenres - the new station blacklisted genres
+	 * @param cb
+	 */
+	updateBlacklistedGenres: hooks.ownerRequired((session, stationId, newBlacklistedGenres, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.station.updateOne({_id: stationId}, {$set: {blacklistedGenres: newBlacklistedGenres}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`);
+			return cb({'status': 'success', 'message': 'Successfully updated the blacklisted genres.'});
+		});
+	}),
+
 	/**
 	 * Updates a station's party mode
 	 *
@@ -639,15 +745,15 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.partyMode === newPartyMode) return next('The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.'));
-				db.models.station.update({_id: stationId}, {$set: {partyMode: newPartyMode}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_PARTY_MODE", `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -674,15 +780,15 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.paused) return next('That station was already paused.');
-				db.models.station.update({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -710,15 +816,15 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station.paused) return next('That station is not paused.');
 				station.timePaused += (Date.now() - station.pausedAt);
-				db.models.station.update({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
 			},
 
-			(next) => {
+			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -738,15 +844,15 @@ module.exports = {
 	remove: hooks.ownerRequired((session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.remove({ _id: stationId }, err => next(err));
+				db.models.station.deleteOne({ _id: stationId }, err => next(err));
 			},
 
 			(next) => {
 				cache.hdel('stations', stationId, err => next(err));
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -765,7 +871,6 @@ module.exports = {
 	 * @param userId
 	 */
 	create: hooks.loginRequired((session, data, cb, userId) => {
-		console.log(data);
 		data.name = data.name.toLowerCase();
 		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth", "reset_password"];
 		async.waterfall([
@@ -812,10 +917,9 @@ module.exports = {
 					}, next);
 				}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				console.log(err);
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -842,7 +946,25 @@ 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);
+					return next('Insufficient permissions.');
+				});
+			},
+
+			(station, next) => {
 				if (station.currentSong && station.currentSong.songId === songId) return next('That song is currently playing.');
 				async.each(station.queue, (queueSong, next) => {
 					if (queueSong.songId === songId) return next('That song is already in the queue.');
@@ -854,8 +976,7 @@ module.exports = {
 
 			(station, next) => {
 				songs.getSong(songId, (err, song) => {
-					if (!err && song) return next(null, song);
-					console.log(53, songId);
+					if (!err && song) return next(null, song, station);
 					utils.getSongFromYouTube(songId, (song) => {
 						song.artists = [];
 						song.skipDuration = 0;
@@ -863,22 +984,66 @@ module.exports = {
 						song.dislikes = -1;
 						song.thumbnail = "empty";
 						song.explicit = false;
-						next(null, song);
+						next(null, song, station);
 					});
 				});
 			},
 
-			(song, next) => {
+			(song, station, next) => {
+				let queue = station.queue;
 				song.requestedBy = userId;
-				db.models.station.update({_id: stationId}, {$push: {queue: song}}, next);
+				queue.push(song);
+
+				let totalDuration = 0;
+				queue.forEach((song) => {
+					totalDuration += song.duration;
+				});
+				if (totalDuration >= 3600 * 3) return next('The max length of the queue is 3 hours.');
+				next(null, song, station);
+			},
+
+			(song, station, next) => {
+				let queue = station.queue;
+				if (queue.length === 0) return next(null, song, station);
+				let totalDuration = 0;
+				const userId = queue[queue.length - 1].requestedBy;
+				station.queue.forEach((song) => {
+					if (userId === song.requestedBy) {
+						totalDuration += song.duration;
+					}
+				});
+
+				if(totalDuration >= 900) return next('The max length of songs per user is 15 minutes.');
+				next(null, song, station);
+			},
+
+			(song, station, next) => {
+				let queue = station.queue;
+				if (queue.length === 0) return next(null, song);
+				let totalSongs = 0;
+				const userId = queue[queue.length - 1].requestedBy;
+				queue.forEach((song) => {
+					if (userId === song.requestedBy) {
+						totalSongs++;
+					}
+				});
+
+				if (totalSongs <= 2) return next(null, song);
+				if (totalSongs > 3) return next('The max amount of songs per user is 3, and only 2 in a row is allowed.');
+				if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return next('The max amount of songs per user is 3, and only 2 in a row is allowed.');
+				next(null, song);
+			},
+
+			(song, next) => {
+				db.models.station.updateOne({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_ADD_SONG_TO_QUEUE", `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -917,15 +1082,15 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
+				db.models.station.updateOne({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
 			},
 
-			(next) => {
+			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE_SONG_TO_QUEUE", `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -942,7 +1107,7 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param cb
 	 */
-	getQueue: hooks.adminRequired((session, stationId, cb) => {
+	getQueue: (session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
 				stations.getStation(stationId, next);
@@ -952,17 +1117,24 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (station.type !== 'community') return next('Station is not a community station.');
 				next(null, station);
+			},
+
+			(station, next) => {
+				utils.canUserBeInStation(station, session.userId, (canBe) => {
+					if (canBe) return next(null, station);
+					return next('Insufficient permissions.');
+				});
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("STATIONS_GET_QUEUE", `Got queue for station "${stationId}" successfully.`);
 			return cb({'status': 'success', 'message': 'Successfully got queue.', queue: station.queue});
 		});
-	}),
+	},
 
 	/**
 	 * Selects a private playlist for a station
@@ -989,22 +1161,97 @@ module.exports = {
 			(playlist, next) => {
 				if (!playlist) return next('Playlist not found.');
 				let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
-				db.models.station.update({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selected private playlist "${playlistId}" for station "${stationId}" successfully.`);
+			notifications.unschedule(`stations.nextSong?id${stationId}`);
 			if (!station.partyMode) stations.skipStation(stationId)();
 			cache.pub('privatePlaylist.selected', {playlistId, stationId});
 			return cb({'status': 'success', 'message': 'Successfully selected playlist.'});
 		});
 	}),
+
+	favoriteStation: hooks.loginRequired((session, stationId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				async.waterfall([
+					(next) => {
+						if (station.privacy !== 'private') return next(true);
+						if (!session.userId) return next("You're not allowed to favorite this station.");
+						next();
+					},
+
+					(next) => {
+						db.models.user.findOne({ _id: userId }, next);
+					},
+
+					(user, next) => {
+						if (!user) return next("You're not allowed to favorite this station.");
+						if (user.role === 'admin') return next(true);
+						if (station.type === 'official') return next("You're not allowed to favorite this station.");
+						if (station.owner === session.userId) return next(true);
+						next("You're not allowed to favorite this station.");
+					}
+				], (err) => {
+					if (err === true) return next(null);
+					next(utils.getError(err));
+				});
+			},
+
+			(next) => {
+				db.models.user.updateOne({ _id: userId }, { $addToSet: { favoriteStations: stationId } }, next);
+			},
+
+			(res, next) => {
+				if (res.nModified === 0) return next("The station was already favorited.");
+				next();
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
+			cache.pub('user.favoritedStation', { userId, stationId });
+			return cb({'status': 'success', 'message': 'Succesfully favorited station.'});
+		});
+	}),
+
+	unfavoriteStation: hooks.loginRequired((session, stationId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.updateOne({ _id: userId }, { $pull: { favoriteStations: stationId } }, next);
+			},
+
+			(res, next) => {
+				if (res.nModified === 0) return next("The station wasn't favorited.");
+				next();
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
+			cache.pub('user.unfavoritedStation', { userId, stationId });
+			return cb({'status': 'success', 'message': 'Succesfully unfavorited station.'});
+		});
+	}),
 };

+ 331 - 71
backend/logic/actions/users.js

@@ -4,14 +4,18 @@ const async = require('async');
 const config = require('config');
 const request = require('request');
 const bcrypt = require('bcrypt');
+const sha256 = require('sha256');
 
-const db = require('../db');
-const mail = require('../mail');
-const cache = require('../cache');
-const utils = require('../utils');
 const hooks = require('./hooks');
-const sha256 = require('sha256');
-const logger = require('../logger');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const mail = moduleManager.modules["mail"];
+const cache = moduleManager.modules["cache"];
+const punishments = moduleManager.modules["punishments"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 cache.sub('user.updateUsername', user => {
 	utils.socketsFromUser(user._id, sockets => {
@@ -21,8 +25,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 +42,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 +50,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 +58,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 +65,31 @@ 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);
+		});
+	});
+});
+
+cache.sub('user.favoritedStation', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.favoritedStation', data.stationId);
+		});
+	});
+});
+
+cache.sub('user.unfavoritedStation', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.unfavoritedStation', data.stationId);
+		});
+	});
+});
+
 module.exports = {
 
 	/**
@@ -70,9 +103,9 @@ module.exports = {
 			(next) => {
 				db.models.user.find({}).exec(next);
 			}
-		], (err, users) => {
+		], async (err, users) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			} else {
@@ -133,16 +166,21 @@ module.exports = {
 			},
 
 			(user, next) => {
-				let sessionId = utils.guid();
+				utils.guid().then((sessionId) => {
+					next(null, user, sessionId);
+				});
+			},
+
+			(user, sessionId, next) => {
 				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
 					if (err) return next(err);
 					next(null, sessionId);
 				});
 			}
 
-		], (err, sessionId) => {
+		], async (err, sessionId) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -162,12 +200,17 @@ module.exports = {
 	 * @param {Object} recaptcha - the recaptcha data
 	 * @param {Function} cb - gets called with the result
 	 */
-	register: function(session, username, email, password, recaptcha, cb) {
+	register: async function(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 
 			// verify the request with google recaptcha
+			(next) => {
+				if (!db.passwordValid(password)) return next('Invalid password. Check if it meets all the requirements.');
+				return next();
+			},
+
 			(next) => {
 				request({
 					url: 'https://www.google.com/recaptcha/api/siteverify',
@@ -206,10 +249,16 @@ module.exports = {
 				bcrypt.hash(sha256(password), salt, next)
 			},
 
-			// save the new user to the database
 			(hash, next) => {
+				utils.generateRandomString(12).then((_id) => {
+					next(null, hash, _id);
+				});
+			},
+
+			// save the new user to the database
+			(hash, _id, next) => {
 				db.models.user.create({
-					_id: utils.generateRandomString(12),//TODO Check if exists
+					_id,
 					username,
 					email: {
 						address: email,
@@ -231,9 +280,9 @@ module.exports = {
 				});
 			}
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -271,19 +320,78 @@ module.exports = {
 			(session, next) => {
 				cache.hdel('sessions', session.sessionId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
-				cb({status: 'failure', message: err});
+				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);
+				});
+			}
+
+		], async err => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("REMOVE_SESSIONS_FOR_USER", `Couldn't remove all sessions for user "${userId}". "${err}"`);
+				return cb({ status: 'failure', message: err });
+			} else {
+				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)
 	 *
@@ -301,9 +409,9 @@ module.exports = {
 				if (!account) return next('User not found.');
 				next(null, account);
 			}
-		], (err, account) => {
+		], async (err, account) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -325,6 +433,39 @@ 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) => {
+		db.models.user.findById(userId).then(user => {
+			if (user) {
+				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+				return cb({
+					status: 'success',
+					data: user.username
+				});
+			} else {
+				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. User not found.`);
+				cb({
+					status: 'failure',
+					message: "Couldn't find the user."
+				});
+			}
+			
+		}).catch(async err => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
+				cb({ status: 'failure', message: err });
+			}
+		});
+	},
+
 	//TODO Fix security issues
 	/**
 	 * Gets user info from session
@@ -351,9 +492,9 @@ module.exports = {
 				if (!user) return next('User not found.');
 				next(null, user);
 			}
-		], (err, user) => {
+		], async (err, user) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -412,11 +553,11 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.update({ _id: updatingUserId }, {$set: {username: newUsername}}, next);
+				db.models.user.updateOne({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_USERNAME", `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -439,9 +580,9 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	updateEmail: hooks.loginRequired((session, updatingUserId, newEmail, cb, userId) => {
+	updateEmail: hooks.loginRequired(async (session, updatingUserId, newEmail, cb, userId) => {
 		newEmail = newEmail.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 			(next) => {
 				if (updatingUserId === userId) return next(null, true);
@@ -470,7 +611,7 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, next);
+				db.models.user.updateOne({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -482,9 +623,9 @@ module.exports = {
 					next();
 				});
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_EMAIL", `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -517,12 +658,12 @@ module.exports = {
 				else return next();
 			},
 			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {role: newRole}}, next);
+				db.models.user.updateOne({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
 			}
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_ROLE", `User "${userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -554,6 +695,11 @@ module.exports = {
 				next();
 			},
 
+			(next) => {
+				if (!db.passwordValid(newPassword)) return next('Invalid password. Check if it meets all the requirements.');
+				return next();
+			},
+
 			(next) => {
 				bcrypt.genSalt(10, next);
 			},
@@ -564,16 +710,16 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
+				db.models.user.updateOne({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${err}'.`);
 				return cb({ status: 'failure', message: err });
 			}
 
-			logger.error("UPDATE_PASSWORD", `User '${userId}' updated their password.`);
+			logger.success("UPDATE_PASSWORD", `User '${userId}' updated their password.`);
 			cb({
 				status: 'success',
 				message: 'Password successfully updated.'
@@ -589,8 +735,8 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	requestPassword: hooks.loginRequired((session, cb, userId) => {
-		let code = utils.generateRandomString(8);
+	requestPassword: hooks.loginRequired(async (session, cb, userId) => {
+		let code = await utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
 				db.models.user.findOne({_id: userId}, next);
@@ -605,15 +751,15 @@ module.exports = {
 			(user, next) => {
 				let expires = new Date();
 				expires.setDate(expires.getDate() + 1);
-				db.models.user.findOneAndUpdate({"email.address": user.email.address}, {$set: {"services.password": {set: {code: code, expires}}}}, next);
+				db.models.user.findOneAndUpdate({"email.address": user.email.address}, {$set: {"services.password": {set: {code: code, expires}}}}, {runValidators: true}, next);
 			},
 
 			(user, next) => {
 				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REQUEST_PASSWORD", `UserId '${userId}' failed to request password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -646,9 +792,9 @@ module.exports = {
 				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
 				next(null);
 			}
-		], (err) => {
+		], async(err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -683,6 +829,11 @@ module.exports = {
 				next();
 			},
 
+			(next) => {
+				if (!db.passwordValid(newPassword)) return next('Invalid password. Check if it meets all the requirements.');
+				return next();
+			},
+
 			(next) => {
 				bcrypt.genSalt(10, next);
 			},
@@ -693,11 +844,11 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, next);
+				db.models.user.updateOne({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -727,11 +878,11 @@ module.exports = {
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				if (!user.services.github || !user.services.github.id) return next('You can\'t remove password login without having GitHub login.');
-				db.models.user.update({_id: userId}, {$unset: {"services.password": ''}}, next);
+				db.models.user.updateOne({_id: userId}, {$unset: {"services.password": ''}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${userId}'. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -761,11 +912,11 @@ module.exports = {
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				if (!user.services.password || !user.services.password.password) return next('You can\'t remove GitHub login without having password login.');
-				db.models.user.update({_id: userId}, {$unset: {"services.github": ''}}, next);
+				db.models.user.updateOne({_id: userId}, {$unset: {"services.github": ''}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${userId}'. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -786,8 +937,8 @@ module.exports = {
 	 * @param {String} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestPasswordReset: (session, email, cb) => {
-		let code = utils.generateRandomString(8);
+	requestPasswordReset: async (session, email, cb) => {
+		let code = await utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
 				if (!email || typeof email !== 'string') return next('Invalid email.');
@@ -804,15 +955,15 @@ module.exports = {
 			(user, next) => {
 				let expires = new Date();
 				expires.setDate(expires.getDate() + 1);
-				db.models.user.findOneAndUpdate({"email.address": email}, {$set: {"services.password.reset": {code: code, expires}}}, next);
+				db.models.user.findOneAndUpdate({"email.address": email}, {$set: {"services.password.reset": {code: code, expires}}}, {runValidators: true}, next);
 			},
 
 			(user, next) => {
 				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -844,9 +995,9 @@ module.exports = {
 				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
 				next(null);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -880,6 +1031,11 @@ module.exports = {
 				next();
 			},
 
+			(next) => {
+				if (!db.passwordValid(newPassword)) return next('Invalid password. Check if it meets all the requirements.');
+				return next();
+			},
+
 			(next) => {
 				bcrypt.genSalt(10, next);
 			},
@@ -890,11 +1046,11 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, next);
+				db.models.user.updateOne({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -905,5 +1061,109 @@ 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();
+			},
+		], async (err) => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("BAN_USER_BY_ID", `User ${userId} failed to ban user ${value} with the reason ${reason}. '${err}'`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("BAN_USER_BY_ID", `User ${userId} has successfully banned user ${value} with the reason ${reason}.`);
+				cb({
+					status: 'success',
+					message: 'Successfully banned user.'
+				});
+			}
+		});
+	}),
+
+	getFavoriteStations: hooks.loginRequired((session, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ _id: userId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next("User not found.");
+				next(null, user);
+			}
+		], async (err, user) => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("GET_FAVORITE_STATIONS", `User ${userId} failed to get favorite stations. '${err}'`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("GET_FAVORITE_STATIONS", `User ${userId} got favorite stations.`);
+				cb({
+					status: 'success',
+					favoriteStations: user.favoriteStations
+				});
+			}
+		});
+	})
 };

+ 34 - 21
backend/logic/api.js

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

+ 220 - 211
backend/logic/app.js

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

+ 103 - 77
backend/logic/cache/index.js

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

+ 1 - 0
backend/logic/cache/schemas/session.js

@@ -4,6 +4,7 @@ module.exports = (sessionId, userId) => {
 	return {
 		sessionId: sessionId,
 		userId: userId,
+		refreshDate: Date.now(),
 		created: Date.now()
 	};
 };

+ 219 - 45
backend/logic/db/index.js

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

+ 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 }
+};

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

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

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

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

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

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

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

@@ -1,5 +1,4 @@
 module.exports = {
-	_id: { type: String, required: true, index: true, unique: true, min: 12, max: 12 },
 	username: { type: String, required: true },
 	role: { type: String, default: 'default', required: true },
 	email: {
@@ -24,16 +23,11 @@ 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 }
 	},
 	liked: [{ type: String }],
 	disliked: [{ type: String }],
+	favoriteStations: [{ type: String }],
 	createdAt: { type: Date, default: Date.now() }
 };

+ 91 - 0
backend/logic/discord.js

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

+ 169 - 92
backend/logic/io.js

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

+ 158 - 152
backend/logic/logger.js

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

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

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

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

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

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

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

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

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

+ 115 - 37
backend/logic/notifications.js

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

+ 81 - 64
backend/logic/playlists.js

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

+ 243 - 0
backend/logic/punishments.js

@@ -0,0 +1,243 @@
+'use strict';
+
+const coreClass = require("../core");
+
+const async = require('async');
+const mongoose = require('mongoose');
+
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
+
+		this.dependsOn = ["cache", "db", "utils"];
+	}
+
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules['cache'];
+			this.db = this.moduleManager.modules['db'];
+			this.io = this.moduleManager.modules['io'];
+			this.utils = this.moduleManager.modules['utils'];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('punishments', next);
+				},
+	
+				(punishments, next) => {
+					this.setStage(3);
+					if (!punishments) return next();
+					let punishmentIds = Object.keys(punishments);
+					async.each(punishmentIds, (punishmentId, next) => {
+						this.db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
+							if (err) next(err);
+							else if (!punishment) this.cache.hdel('punishments', punishmentId, next);
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.punishment.find({}, next);
+				},
+	
+				(punishments, next) => {
+					this.setStage(5);
+					async.each(punishments, (punishment, next) => {
+						if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
+						this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
+		});
+	}
+
+	/**
+	 * Gets all punishments in the cache that are active, and removes those that have expired
+	 *
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
+	async getPunishments(cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let punishmentsToRemove = [];
+		async.waterfall([
+			(next) => {
+				this.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) => {
+						this.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
+	 */
+	async getPunishment(id, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		async.waterfall([
+
+			(next) => {
+				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
+				this.cache.hget('punishments', id, next);
+			},
+
+			(punishment, next) => {
+				if (punishment) return next(true, punishment);
+				this.db.models.punishment.findOne({_id: id}, next);
+			},
+
+			(punishment, next) => {
+				if (punishment) {
+					this.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
+	 */
+	async getPunishmentsFromUserId(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		async.waterfall([
+			(next) => {
+				this.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);
+		});
+	}
+
+	async addPunishment(type, value, reason, expiresAt, punishedBy, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		async.waterfall([
+			(next) => {
+				const punishment = new this.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) => {
+				this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), (err) => {
+					next(err, punishment);
+				});
+			},
+
+			(punishment, next) => {
+				// DISCORD MESSAGE
+				next(null, punishment);
+			}
+		], (err, punishment) => {
+			cb(err, punishment);
+		});
+	}
+
+	async removePunishmentFromCache(punishmentId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		async.waterfall([
+			(next) => {
+				const punishment = new this.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) => {
+				this.cache.hset('punishments', punishment._id, punishment, next);
+			},
+
+			(punishment, next) => {
+				// DISCORD MESSAGE
+				next();
+			}
+		], (err) => {
+			cb(err);
+		});
+	}
+}
+

+ 84 - 64
backend/logic/songs.js

@@ -1,53 +1,69 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
+const coreClass = require("../core");
+
 const async = require('async');
 const mongoose = require('mongoose');
 
-module.exports = {
 
-	/**
-	 * Initializes the songs module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('songs', next);
-			},
 
-			(songs, next) => {
-				if (!songs) return next();
-				let songIds = Object.keys(songs);
-				async.each(songIds, (songId, next) => {
-					db.models.song.findOne({songId}, (err, song) => {
-						if (err) next(err);
-						else if (!song) cache.hdel('songs', songId, next);
-						else next();
-					});
-				}, next);
-			},
 
-			(next) => {
-				db.models.song.find({}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(songs, next) => {
-				async.each(songs, (song, next) => {
-					cache.hset('songs', song.songId, cache.schemas.song(song), next);
-				}, next);
-			}
-		], (err) => {
-			if (err) {
-				console.log(`FAILED TO INITIALIZE SONGS. ABORTING. "${err.message}"`);
-				process.exit();
-			} else cb();
+		this.dependsOn = ["utils", "cache", "db"];
+	}
+
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db = this.moduleManager.modules["db"];
+			this.io = this.moduleManager.modules["io"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('songs', next);
+				},
+	
+				(songs, next) => {
+					this.setStage(3);
+					if (!songs) return next();
+					let songIds = Object.keys(songs);
+					async.each(songIds, (songId, next) => {
+						this.db.models.song.findOne({songId}, (err, song) => {
+							if (err) next(err);
+							else if (!song) this.cache.hdel('songs', songId, next);
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.song.find({}, next);
+				},
+	
+				(songs, next) => {
+					this.setStage(5);
+					async.each(songs, (song, next) => {
+						this.cache.hset('songs', song.songId, this.cache.schemas.song(song), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -55,22 +71,23 @@ module.exports = {
 	 * @param {String} id - the id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getSong: function(id, cb) {
-		async.waterfall([
+	async getSong(id, cb) {
+		try { await this._validateHook(); } catch { return; }
 
+		async.waterfall([
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				cache.hget('songs', id, next);
+				this.cache.hget('songs', id, next);
 			},
 
 			(song, next) => {
 				if (song) return next(true, song);
-				db.models.song.findOne({_id: id}, next);
+				this.db.models.song.findOne({_id: id}, next);
 			},
 
 			(song, next) => {
 				if (song) {
-					cache.hset('songs', id, song, next);
+					this.cache.hset('songs', id, song, next);
 				} else next('Song not found.');
 			},
 
@@ -79,27 +96,26 @@ module.exports = {
 
 			cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
 	 *
-	 * @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) {
-		async.waterfall([
+	async getSongFromId(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
+		async.waterfall([
 			(next) => {
-				db.models.song.findOne({songId}, next);
+				this.db.models.song.findOne({ songId }, next);
 			}
-
 		], (err, song) => {
 			if (err && err !== true) return cb(err);
-
-			cb(null, song);
+			else return cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Gets a song from id from Mongo and updates the cache with it
@@ -107,28 +123,30 @@ module.exports = {
 	 * @param {String} songId - the id of the song we are trying to update
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	updateSong: (songId, cb) => {
+	async updateSong(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.song.findOne({_id: songId}, next);
+				this.db.models.song.findOne({_id: songId}, next);
 			},
 
 			(song, next) => {
 				if (!song) {
-					cache.hdel('songs', songId);
+					this.cache.hdel('songs', songId);
 					return next('Song not found.');
 				}
 
-				cache.hset('songs', songId, song, next);
+				this.cache.hset('songs', songId, song, next);
 			}
 
 		], (err, song) => {
-			if (err && err !== true) cb(err);
+			if (err && err !== true) return cb(err);
 
 			cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Deletes song from id from Mongo and cache
@@ -136,15 +154,17 @@ module.exports = {
 	 * @param {String} songId - the id of the song we are trying to delete
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	deleteSong: (songId, cb) => {
+	async deleteSong(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.song.remove({ songId }, next);
+				this.db.models.song.deleteOne({ songId }, next);
 			},
 
 			(next) => {
-				cache.hdel('songs', songId, next);
+				this.cache.hdel('songs', songId, next);
 			}
 
 		], (err) => {
@@ -153,4 +173,4 @@ module.exports = {
 			cb(null);
 		});
 	}
-};
+}

+ 95 - 0
backend/logic/spotify.js

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

+ 201 - 164
backend/logic/stations.js

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

+ 151 - 0
backend/logic/tasks.js

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

+ 291 - 121
backend/logic/utils.js

@@ -1,11 +1,10 @@
 'use strict';
 
-const moment  = require('moment'),
-	  io      = require('./io'),
-	  config  = require('config'),
+const coreClass = require("../core");
+
+const config  = require('config'),
 	  async	  = require('async'),
-	  request = require('request'),
-	  cache   = require('./cache');
+	  request = require('request');
 
 class Timer {
 	constructor(callback, delay, paused) {
@@ -13,7 +12,7 @@ class Timer {
 		this.timerId = undefined;
 		this.start = undefined;
 		this.paused = paused;
-		this.remaining = moment.duration(delay, "hh:mm:ss").asSeconds() * 1000;
+		this.remaining = delay;
 		this.timeWhenPaused = 0;
 		this.timePaused = Date.now();
 
@@ -54,135 +53,234 @@ class Timer {
 			return Date.now() - this.timePaused;
 		}
 	}
-}
-
-function convertTime (duration) {
-	let a = duration.match(/\d+/g);
+} 
 
-	if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
-		a = [0, a[0], 0];
-	}
+let youtubeRequestCallbacks = [];
+let youtubeRequestsPending = 0;
+let youtubeRequestsActive = false;
 
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
-		a = [a[0], 0, a[1]];
-	}
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
-		a = [a[0], 0, 0];
-	}
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+			
+			this.io = this.moduleManager.modules["io"];
+			this.db = this.moduleManager.modules["db"];
+			this.spotify = this.moduleManager.modules["spotify"];
+			this.cache = this.moduleManager.modules["cache"];
 
-	duration = 0;
+			this.Timer = Timer;
 
-	if (a.length == 3) {
-		duration = duration + parseInt(a[0]) * 3600;
-		duration = duration + parseInt(a[1]) * 60;
-		duration = duration + parseInt(a[2]);
+			resolve();
+		});
 	}
 
-	if (a.length == 2) {
-		duration = duration + parseInt(a[0]) * 60;
-		duration = duration + parseInt(a[1]);
+	async parseCookies(cookieString) {
+		try { await this._validateHook(); } catch { return; }
+		let cookies = {};
+		if (cookieString) cookieString.split("; ").map((cookie) => {
+			(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
+		});
+		return cookies;
 	}
 
-	if (a.length == 1) {
-		duration = duration + parseInt(a[0]);
+	async cookiesToString(cookies) {
+		try { await this._validateHook(); } catch { return; }
+		let newCookie = [];
+		for (let prop in cookie) {
+			newCookie.push(prop + "=" + cookie[prop]);
+		}
+		return newCookie.join("; ");
 	}
 
-	let hours = Math.floor(duration / 3600);
-	let minutes = Math.floor(duration % 3600 / 60);
-	let seconds = Math.floor(duration % 3600 % 60);
-
-	return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
-}
+	async removeCookie(cookieString, cookieName) {
+		try { await this._validateHook(); } catch { return; }
+		var cookies = this.parseCookies(cookieString);
+		delete cookies[cookieName];
+		return this.toString(cookies);
+	}
 
-let youtubeRequestCallbacks = [];
-let youtubeRequestsPending = 0;
-let youtubeRequestsActive = false;
+	async htmlEntities(str) {
+		try { await this._validateHook(); } catch { return; }
+		return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+	}
 
-module.exports = {
-	htmlEntities: str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
-	generateRandomString: function(len) {
+	async generateRandomString(len) {
+		try { await this._validateHook(); } catch { return; }
 		let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
 		let result = [];
 		for (let i = 0; i < len; i++) {
-			result.push(chars[this.getRandomNumber(0, chars.length - 1)]);
+			result.push(chars[await this.getRandomNumber(0, chars.length - 1)]);
 		}
 		return result.join("");
-	},
-	getSocketFromId: function(socketId) {
+	}
+
+	async getSocketFromId(socketId) {
+		try { await this._validateHook(); } catch { return; }
 		return globals.io.sockets.sockets[socketId];
-	},
-	getRandomNumber: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min,
-	convertTime,
-	Timer,
-	guid: () => [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join(''),
-	cookies: {
-		parseCookies: cookieString => {
-			let cookies = {};
-			if (cookieString) cookieString.split("; ").map((cookie) => {
-				(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
-			});
-			return cookies;
-		},
-		toString: cookies => {
-			let newCookie = [];
-			for (let prop in cookie) {
-				newCookie.push(prop + "=" + cookie[prop]);
-			}
-			return newCookie.join("; ");
-		},
-		removeCookie: (cookieString, cookieName) => {
-			var cookies = this.parseCookies(cookieString);
-			delete cookies[cookieName];
-			return this.toString(cookies);
+	}
+
+	async getRandomNumber(min, max) {
+		try { await this._validateHook(); } catch { return; }
+		return Math.floor(Math.random() * (max - min + 1)) + min
+	}
+
+	async convertTime(duration) {
+		try { await this._validateHook(); } catch { return; }
+		let a = duration.match(/\d+/g);
+	
+		if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
+			a = [0, a[0], 0];
+		}
+	
+		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
+			a = [a[0], 0, a[1]];
+		}
+		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
+			a = [a[0], 0, 0];
 		}
-	},
-	socketFromSession: function(socketId) {
-		let ns = io.io.of("/");
+	
+		duration = 0;
+	
+		if (a.length == 3) {
+			duration = duration + parseInt(a[0]) * 3600;
+			duration = duration + parseInt(a[1]) * 60;
+			duration = duration + parseInt(a[2]);
+		}
+	
+		if (a.length == 2) {
+			duration = duration + parseInt(a[0]) * 60;
+			duration = duration + parseInt(a[1]);
+		}
+	
+		if (a.length == 1) {
+			duration = duration + parseInt(a[0]);
+		}
+	
+		let hours = Math.floor(duration / 3600);
+		let minutes = Math.floor(duration % 3600 / 60);
+		let seconds = Math.floor(duration % 3600 % 60);
+	
+		return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
+	}
+
+	async guid () {
+		try { await this._validateHook(); } catch { return; }
+		return [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join('');
+	}
+
+	async socketFromSession(socketId) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		if (ns) {
 			return ns.connected[socketId];
 		}
-	},
-	socketsFromUser: function(userId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromSessionId(sessionId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				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 (session.sessionId === sessionId) sockets.push(session.sessionId);
+				next();
+			}, () => {
+				cb(sockets);
+			});
+		}
+	}
+
+	async socketsFromUser(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
+		let sockets = [];
+		if (ns) {
+			async.each(Object.keys(ns.connected), (id, next) => {
+				let session = ns.connected[id].session;
+				this.cache.hget('sessions', session.sessionId, (err, session) => {
+					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
+					next();
+				});
+			}, () => {
+				cb(sockets);
+			});
+		}
+	}
+
+	async socketsFromIP(ip, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
+		let sockets = [];
+		if (ns) {
+			async.each(Object.keys(ns.connected), (id, next) => {
+				let session = ns.connected[id].session;
+				this.cache.hget('sessions', session.sessionId, (err, session) => {
+					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
 					next();
 				});
 			}, () => {
 				cb(sockets);
 			});
 		}
-	},
-	socketLeaveRooms: function(socketid) {
-		let socket = this.socketFromSession(socketid);
+	}
+
+	async socketsFromUserWithoutCache(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let ns = this.io.io.of("/");
+		let sockets = [];
+		if (ns) {
+			async.each(Object.keys(ns.connected), (id, next) => {
+				let session = ns.connected[id].session;
+				if (session.userId === userId) sockets.push(ns.connected[id]);
+				next();
+			}, () => {
+				cb(sockets);
+			});
+		}
+	}
+
+	async socketLeaveRooms(socketid) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketid);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			socket.leave(room);
 		}
-	},
-	socketJoinRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
+	}
+
+	async socketJoinRoom(socketId, room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			socket.leave(room);
 		}
 		socket.join(room);
-	},
-	socketJoinSongRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
+	}
+
+	async socketJoinSongRoom(socketId, room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			if (room.indexOf('song.') !== -1) socket.leave(rooms);
 		}
 		socket.join(room);
-	},
-	socketsJoinSongRoom: function(sockets, room) {
+	}
+
+	async socketsJoinSongRoom(sockets, room) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let rooms = socket.rooms;
@@ -191,8 +289,11 @@ module.exports = {
 			}
 			socket.join(room);
 		}
-	},
-	socketsLeaveSongRooms: function(sockets) {
+	}
+
+	async socketsLeaveSongRooms(sockets) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let rooms = socket.rooms;
@@ -200,30 +301,34 @@ module.exports = {
 				if (room.indexOf('song.') !== -1) socket.leave(room);
 			}
 		}
-	},
-	emitToRoom: function(room) {
-		let sockets = io.io.sockets.sockets;
+	}
+
+	async emitToRoom(room, ...args) {
+		try { await this._validateHook(); } catch { return; }
+
+		let sockets = this.io.io.sockets.sockets;
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) {
-				let args = [];
-				for (let i = 1; i < Object.keys(arguments).length; i++) {
-					args.push(arguments[i]);
-				}
 				socket.emit.apply(socket, args);
 			}
 		}
-	},
-	getRoomSockets: function(room) {
-		let sockets = io.io.sockets.sockets;
+	}
+
+	async getRoomSockets(room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let sockets = this.io.io.sockets.sockets;
 		let roomSockets = [];
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) roomSockets.push(socket);
 		}
 		return roomSockets;
-	},
-	getSongFromYouTube: (songId, cb) => {
+	}
+
+	async getSongFromYouTube(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 		youtubeRequestCallbacks.push({cb: (test) => {
 			youtubeRequestsActive = true;
@@ -248,7 +353,7 @@ module.exports = {
 				body = JSON.parse(body);
 
 				//TODO Clean up duration converter
-				let dur = body.items[0].contentDetails.duration;
+  				let dur = body.items[0].contentDetails.duration;
 				dur = dur.replace('PT', '');
 				let duration = 0;
 				dur = dur.replace(/([\d]*)H/, (v, v2) => {
@@ -279,9 +384,11 @@ module.exports = {
 		if (!youtubeRequestsActive) {
 			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
 		}
-	},
-	getPlaylistFromYouTube: (url, cb) => {
-		
+	}
+
+	async getPlaylistFromYouTube(url, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
 		let playlistId = regex.exec(url)[1];
@@ -312,18 +419,30 @@ module.exports = {
 			});
 		}
 		getPage(null, []);
-	},
-	getSongFromSpotify: (song, cb) => {
+	}
+
+	async getSongFromSpotify(song, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (!config.get("apis.spotify.enabled")) return cb("Spotify is not enabled", null);
+
 		const spotifyParams = [
 			`q=${encodeURIComponent(song.title)}`,
 			`type=track`
 		].join('&');
 
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+		const token = await this.spotify.getToken();
+		const options = {
+			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		};
 
+		request(options, (err, res, body) => {
 			if (err) console.error(err);
-
 			body = JSON.parse(body);
+			if (body.error) console.error(body.error);
 
 			durationArtistLoop:
 			for (let i in body) {
@@ -348,18 +467,33 @@ module.exports = {
 				}
 			}
 
-			cb(song);
+			cb(null, song);
 		});
-	},
-	getSongsFromSpotify: (title, artist, cb) => {
+	}
+
+	async getSongsFromSpotify(title, artist, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (!config.get("apis.spotify.enabled")) return cb([]);
+
 		const spotifyParams = [
 			`q=${encodeURIComponent(title)}`,
 			`type=track`
 		].join('&');
+		
+		const token = await this.spotify.getToken();
+		const options = {
+			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		};
 
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+		request(options, (err, res, body) => {
 			if (err) return console.error(err);
 			body = JSON.parse(body);
+			if (body.error) return console.error(body.error);
+
 			let songs = [];
 
 			for (let i in body) {
@@ -387,8 +521,11 @@ module.exports = {
 
 			cb(songs);
 		});
-	},
-	shuffle: (array) => {
+	}
+
+	async shuffle(array) {
+		try { await this._validateHook(); } catch { return; }
+
 		let currentIndex = array.length, temporaryValue, randomIndex;
 
 		// While there remain elements to shuffle...
@@ -405,11 +542,44 @@ module.exports = {
 		}
 
 		return array;
-	},
-	getError: (err) => {
+	}
+
+	async getError(err) {
+		try { await this._validateHook(); } catch { return; }
+
 		let error = 'An error occurred.';
 		if (typeof err === "string") error = err;
-		else if (err.message) error = err.message;
+		else if (err.message) {
+			if (err.message !== 'Validation failed') error = err.message;
+			else error = err.errors[Object.keys(err.errors)].message;
+		}
 		return error;
 	}
-};
+
+	async canUserBeInStation(station, userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		async.waterfall([
+			(next) => {
+				if (station.privacy !== 'private') return next(true);
+				if (!userId) return next(false);
+				next();
+			},
+
+			(next) => {
+				this.db.models.user.findOne({_id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next(false);
+				if (user.role === 'admin') return next(true);
+				if (station.type === 'official') return next(false);
+				if (station.owner === userId) return next(true);
+				next(false);
+			}
+		], (err) => {
+			if (err === true) return cb(true);
+			return cb(false);
+		});
+	}
+}

+ 24 - 27
backend/package.json

@@ -1,38 +1,35 @@
 {
   "name": "musare-backend",
-  "version": "0.0.1",
+  "private": true,
+  "version": "2.1.0",
   "description": "A modern, open-source, collaborative music app https://musare.com",
-  "main": "app.js",
+  "main": "index.js",
   "author": "Musare Team",
+  "license": "GPL-3.0",
   "repository": "https://github.com/Musare/MusareNode",
   "scripts": {
-    "development": "nodemon -L /opt/app",
-    "production": "node /opt/app"
+    "dev": "nodemon",
+    "docker:dev": "nodemon -L /opt/app",
+    "docker:prod": "node /opt/app"
   },
   "dependencies": {
-    "async": "2.0.1",
-    "bcrypt": "^0.8.7",
-    "bluebird": "^3.4.6",
-    "body-parser": "^1.15.2",
-    "config": "^1.21.0",
-    "connect-mongo": "^1.3.2",
-    "cookie-parser": "^1.4.3",
-    "cors": "^2.8.1",
-    "express": "^4.14.0",
-    "express-session": "^1.14.0",
-    "mailgun-js": "^0.8.0",
-    "moment": "^2.15.2",
-    "mongoose": "^4.6.0",
-    "oauth": "^0.9.14",
-    "passport": "^0.3.2",
-    "passport-discord": "^0.1.1",
-    "passport-github": "^1.1.0",
-    "passport-local": "^1.0.0",
-    "passport.socketio": "^3.7.0",
-    "redis": "^2.6.3",
-    "request": "^2.74.0",
+    "async": "3.1.0",
+    "bcrypt": "^3.0.6",
+    "bluebird": "^3.5.5",
+    "body-parser": "^1.19.0",
+    "config": "^3.2.0",
+    "cookie-parser": "^1.4.4",
+    "cors": "^2.8.5",
+    "discord.js": "^11.5.1",
+    "express": "^4.17.1",
+    "mailgun-js": "^0.22.0",
+    "moment": "^2.24.0",
+    "mongoose": "^5.6.4",
+    "oauth": "^0.9.15",
+    "redis": "^2.8.0",
+    "request": "^2.88.0",
     "sha256": "^0.2.0",
-    "socket.io": "^1.5.0",
-    "underscore": "^1.8.3"
+    "socket.io": "^2.2.0",
+    "underscore": "^1.9.1"
   }
 }

+ 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"

+ 26 - 7
docker-compose.yml

@@ -3,28 +3,47 @@ services:
   backend:
     build: ./backend
     ports:
-    - "8080:8080"
+    - "${BACKEND_PORT}:8080"
     volumes:
     - ./backend:/opt/app
+    - ./log:/opt/log
     links:
     - mongo
     - redis
     environment:
-    - NGINX_PORT=80
+    - SNYK_TOKEN=${SNYK_TOKEN}
   frontend:
     build: ./frontend
+    environment:
     ports:
-    - "80:80"
+    - "${FRONTEND_PORT}:80"
     volumes:
     - ./frontend:/opt/app
+    environment:
+    - FRONTEND_MODE=${FRONTEND_MODE}
+    - SNYK_TOKEN=${SNYK_TOKEN}
   mongo:
-    image: mongo
+    image: mongo:4.0
     ports:
-    - "27017:27017"
+    - "${MONGO_PORT}:27017"
+    environment:
+      - MONGO_INITDB_ROOT_USERNAME=admin
+      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+      - MONGO_INITDB_DATABASE=musare
+      - MONGO_PORT=${MONGO_PORT}
+      - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+      - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
+      - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
+    volumes:
+      - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
   mongoclient:
     image: mongoclient/mongoclient
     ports:
-    - "3000:3000"
+    - "${MONGOCLIENT_PORT}:3000"
   redis:
     image: redis
-    command: "--notify-keyspace-events Ex"
+    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
+    volumes:
+    - .redis:/data
+    ports:
+    - "${REDIS_PORT}:6379"

+ 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: #ff4545;
+                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:#ff4545;
+                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: #ff4545;
+                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(255, 69, 69, 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(255, 69, 69, 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>

+ 9 - 0
frontend/.babelrc

@@ -0,0 +1,9 @@
+{
+	"presets": ["@babel/preset-env"],
+	"plugins": [
+		"@babel/plugin-transform-runtime",
+		"@babel/plugin-syntax-dynamic-import",
+		"@babel/plugin-proposal-object-rest-spread"
+	],
+	"comments": false
+}

+ 2 - 0
frontend/.eslintignore

@@ -0,0 +1,2 @@
+node_modules
+dist

+ 28 - 8
frontend/.eslintrc

@@ -1,14 +1,34 @@
 {
-	"rules": {
-		"indent": [2, "tab", { "SwitchCase": 1 }]
+	"root": true,
+	"env": {
+		"browser": true,
+		"amd": true,
+		"node": true,
+		"es6": true,
+		"jquery": true
 	},
 	"parserOptions": {
-		"ecmaVersion": 6,
-		"sourceType": "module"
+		"ecmaVersion": 2018,
+		"sourceType": "module",
+		"parser": "babel-eslint"
+	},
+	"extends": [
+		"airbnb-base",
+		"plugin:vue/essential",
+		"plugin:prettier/recommended",
+		"eslint:recommended"
+	],
+	"globals": {
+		"lofig": "writable",
+		"grecaptcha": "readonly"
 	},
-	"plugins": [ "html" ],
-	"settings": {
-		"html/indent": "tab",
-		"html/report-bad-indent": 2
+	"rules": {
+		"no-console": 0,
+		"no-control-regex": 0,
+		"no-var": 2,
+		"no-underscore-dangle": 0,
+		"radix": 0,
+		"no-multi-assign": 0,
+		"no-shadow": 0
 	}
 }

+ 3 - 0
frontend/.prettierignore

@@ -0,0 +1,3 @@
+node_modules/
+build/
+yarn.lock

+ 5 - 0
frontend/.prettierrc

@@ -0,0 +1,5 @@
+{
+    "singleQuote": false,
+    "tabWidth": 4,
+    "useTabs": true
+}

+ 21 - 0
frontend/.snyk

@@ -0,0 +1,21 @@
+# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
+version: v1.13.5
+# ignores vulnerabilities until expiry date; change duration by modifying expiry date
+ignore:
+  'npm:vue:20170401':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+  'npm:vue:20170829':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+  'npm:vue:20180222':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+  'npm:vue:20180802':
+    - vue-roaster > vue:
+        reason: temp
+        expires: '2019-09-04T02:07:16.079Z'
+patch: {}

+ 246 - 204
frontend/App.vue

@@ -1,250 +1,292 @@
 <template>
 	<div>
-		<h1 v-if="!socketConnected" class="alert">Could not connect to the server.</h1>
-		<router-view></router-view>
-		<toast></toast>
-		<what-is-new></what-is-new>
-		<login-modal v-if='isLoginActive'></login-modal>
-		<register-modal v-if='isRegisterActive'></register-modal>
+		<banned v-if="banned" />
+		<div v-else>
+			<h1 v-if="!socketConnected" class="alert">
+				Could not connect to the server.
+			</h1>
+			<!-- should be a persistant toast -->
+			<router-view />
+			<toast />
+			<what-is-new />
+			<mobile-alert />
+			<login-modal v-if="modals.header.login" />
+			<register-modal v-if="modals.header.register" />
+		</div>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-
-	import WhatIsNew from './components/Modals/WhatIsNew.vue';
-	import LoginModal from './components/Modals/Login.vue';
-	import RegisterModal from './components/Modals/Register.vue';
-	import auth from './auth';
-	import io from './io';
-
-	export default {
-		replace: false,
-		data() {
-			return {
-				register: {
-					email: '',
-					username: '',
-					password: ''
-				},
-				login: {
-					email: '',
-					password: ''
-				},
-				loggedIn: false,
-				role: '',
-				username: '',
-				userId: '',
-				isRegisterActive: false,
-				isLoginActive: false,
-				serverDomain: '',
-				socketConnected: true
-			}
-		},
-		methods: {
-			logout: function () {
-				let _this = this;
-				_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();
-			}
+import { mapState, mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+
+import Banned from "./components/pages/Banned.vue";
+import WhatIsNew from "./components/Modals/WhatIsNew.vue";
+import MobileAlert from "./components/Modals/MobileAlert.vue";
+import LoginModal from "./components/Modals/Login.vue";
+import RegisterModal from "./components/Modals/Register.vue";
+import io from "./io";
+
+export default {
+	replace: false,
+	data() {
+		return {
+			serverDomain: "",
+			socketConnected: true
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		role: state => state.user.auth.role,
+		username: state => state.user.auth.username,
+		userId: state => state.user.auth.userId,
+		banned: state => state.user.auth.banned,
+		modals: state => state.modals.modals,
+		currentlyActive: state => state.modals.currentlyActive
+	}),
+	methods: {
+		submitOnEnter: (cb, event) => {
+			if (event.which === 13) cb();
 		},
-		ready: function () {
-			let _this = this;
-			auth.getStatus((authenticated, role, username, userId) => {
-				_this.socket = window.socket;
-				_this.loggedIn = authenticated;
-				_this.role = role;
-				_this.username = username;
-				_this.userId = userId;
-			});
-			io.onConnect(true, () => {
-				_this.socketConnected = true;
-			});
-			io.onConnectError(true, () => {
-				_this.socketConnected = false;
-			});
-			io.onDisconnect(true, () => {
-				_this.socketConnected = false;
-			});
-			lofig.get('serverDomain', res => {
-				_this.serverDomain = res;
-			});
-			if (_this.$route.query.err) {
-				let err = _this.$route.query.err;
-				err = err.replace(new RegExp('<', 'g'), '&lt;').replace(new RegExp('>', 'g'), '&gt;');
+		...mapActions("modals", ["closeCurrentModal"])
+	},
+	mounted() {
+		document.onkeydown = ev => {
+			const event = ev || window.event;
+			if (
+				event.keyCode === 27 &&
+				Object.keys(this.currentlyActive).length !== 0
+			)
+				this.closeCurrentModal();
+		};
+
+		if (localStorage.getItem("github_redirect")) {
+			this.$router.go(localStorage.getItem("github_redirect"));
+			localStorage.removeItem("github_redirect");
+		}
+		io.onConnect(true, () => {
+			this.socketConnected = true;
+		});
+		io.onConnectError(true, () => {
+			this.socketConnected = false;
+		});
+		io.onDisconnect(true, () => {
+			this.socketConnected = false;
+		});
+		lofig.get("serverDomain", res => {
+			this.serverDomain = res;
+		});
+		this.$router.onReady(() => {
+			if (this.$route.query.err) {
+				let { err } = this.$route.query;
+				err = err
+					.replace(new RegExp("<", "g"), "&lt;")
+					.replace(new RegExp(">", "g"), "&gt;");
+				this.$router.push({ query: {} });
 				Toast.methods.addToast(err, 20000);
 			}
-		},
-		events: {
-			'register': function () {
-				let { register: { email, username, password } } = this;
-				let _this = this;
-				this.socket.emit('users.register', username, email, password, grecaptcha.getResponse(), result => {
-					if (result.status === 'success') {
-						Toast.methods.addToast(`You have successfully registered.`, 4000);
-						if (result.SID) {
-							lofig.get('cookie', cookie => {
-								let date = new Date();
-								date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-								let secure = (cookie.secure) ? 'secure=true; ' : '';
-								document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; ${secure}path=/`;
-								location.reload();
-							});
-						} else _this.$router.go('/login');
-					} else Toast.methods.addToast(result.message, 8000);
-				});
-			},
-			'login': function () {
-				let { login: { email, password } } = this;
-				let _this = this;
-				this.socket.emit('users.login', email, password, result => {
-					if (result.status === 'success') {
-						lofig.get('cookie', cookie => {
-							let date = new Date();
-							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};`;
-							}
-							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);
-				});
-			},
-			'toggleModal': function (type) {
-				switch(type) {
-					case 'register':
-						this.isRegisterActive = !this.isRegisterActive;
-						break;
-					case 'login':
-						this.isLoginActive = !this.isLoginActive;
-						break;
-				}
-			},
-			'closeModal': function() {
-				this.$broadcast('closeModal');
+			if (this.$route.query.msg) {
+				let { msg } = this.$route.query;
+				msg = msg
+					.replace(new RegExp("<", "g"), "&lt;")
+					.replace(new RegExp(">", "g"), "&gt;");
+				this.$router.push({ query: {} });
+				Toast.methods.addToast(msg, 20000);
 			}
-		},
-		components: { Toast, WhatIsNew, LoginModal, RegisterModal }
+		});
+		io.getSocket(true, socket => {
+			socket.on("keep.event:user.session.removed", () => {
+				window.location.reload();
+			});
+		});
+	},
+	components: {
+		Toast,
+		WhatIsNew,
+		MobileAlert,
+		LoginModal,
+		RegisterModal,
+		Banned
 	}
+};
 </script>
 
-<style type='scss'>
-	#toast-container { z-index: 10000 !important; }
+<style lang="scss">
+@import "styles/global.scss";
 
-	html {
-		overflow: auto !important;
-	}
+#toast-container {
+	z-index: 10000 !important;
+}
 
-	.modal-card {
-		margin: 0 !important;
-	}
+html {
+	overflow: auto !important;
+}
 
-	.absolute-a {
-		width: 100%;
-		height: 100%;
-		position: absolute;
-		top: 0;
-		left: 0;
-	}
+body {
+	background-color: $light-grey;
+	color: $dark-grey;
+	font-family: "Roboto", Helvetica, Arial, sans-serif;
+}
 
-	.alert {
-		padding: 20px;
-		color: white;
-		background-color: red;
-		position: fixed;
-		top: 50px;
-		right: 50px;
-		font-size: 2em;
-		border-radius: 5px;
-		z-index: 10000000;
-	}
+a {
+	color: $primary-color;
+	text-decoration: none;
+}
+
+.modal-card {
+	margin: 0 !important;
+}
 
-	.tooltip {
-		position: relative;
+.absolute-a {
+	width: 100%;
+	height: 100%;
+	position: absolute;
+	top: 0;
+	left: 0;
+}
+
+.alert {
+	padding: 20px;
+	color: $white;
+	background-color: $red;
+	position: fixed;
+	top: 50px;
+	right: 50px;
+	font-size: 2em;
+	border-radius: 5px;
+	z-index: 10000000;
+}
+
+.tooltip {
+	position: relative;
 
 	&:after {
-		 position: absolute;
-		 min-width: 80px;
-		 margin-left: -75%;
-		 text-align: center;
-		 padding: 7.5px 6px;
-		 border-radius: 2px;
-		 background-color: #323232;
-		 font-size: .9em;
-		 color: #fff;
-		 content: attr(data-tooltip);
-		 opacity: 0;
-		 transition: all .2s ease-in-out .1s;
-		 visibility: hidden;
-	 }
+		position: absolute;
+		min-width: 80px;
+		margin-left: -75%;
+		text-align: center;
+		padding: 7.5px 6px;
+		border-radius: 2px;
+		background-color: $dark-grey;
+		font-size: 0.9em;
+		color: $white;
+		content: attr(data-tooltip);
+		opacity: 0;
+		transition: all 0.2s ease-in-out 0.1s;
+		visibility: hidden;
+	}
 
 	&:hover:after {
-		 opacity: 1;
-		 visibility: visible;
-	 }
+		opacity: 1;
+		visibility: visible;
 	}
+}
 
-	.tooltip-top {
+.tooltip-top {
 	&:after {
-		 bottom: 150%;
-	 }
+		bottom: 150%;
+	}
 
 	&:hover {
-	&:after { bottom: 120%; }
-	}
+		&:after {
+			bottom: 120%;
+		}
 	}
+}
 
-
-	.tooltip-bottom {
+.tooltip-bottom {
 	&:after {
-		 top: 155%;
-	 }
+		top: 155%;
+	}
 
 	&:hover {
-	&:after { top: 125%; }
-	}
+		&:after {
+			top: 125%;
+		}
 	}
+}
 
-	.tooltip-left {
+.tooltip-left {
 	&:after {
-		 bottom: -10px;
-		 right: 130%;
-		 min-width: 100px;
-	 }
+		bottom: -10px;
+		right: 130%;
+		min-width: 100px;
+	}
 
 	&:hover {
-	&:after { right: 110%; }
-	}
+		&:after {
+			right: 110%;
+		}
 	}
+}
 
-	.tooltip-right {
+.tooltip-right {
 	&:after {
-		 bottom: -10px;
-		 left: 190%;
-		 min-width: 100px;
-	 }
+		bottom: -10px;
+		left: 190%;
+		min-width: 100px;
+	}
 
 	&:hover {
-	&:after { left: 200%; }
+		&:after {
+			left: 200%;
+		}
+	}
+}
+
+.button:focus,
+.button:active {
+	border-color: #dbdbdb !important;
+}
+.input:focus,
+.input:active {
+	border-color: $primary-color !important;
+}
+button.delete:focus {
+	background-color: rgba(10, 10, 10, 0.3);
+}
+
+.tag {
+	padding-right: 6px !important;
+}
+
+.button {
+	&.is-success {
+		background-color: $green !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($green, 5%) !important;
+		}
 	}
+	&.is-primary {
+		background-color: $primary-color !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($primary-color, 5%) !important;
+		}
 	}
+	&.is-danger {
+		background-color: $red !important;
 
-	.button:focus, .button:active { border-color: #dbdbdb !important; }
-	.input:focus, .input:active { border-color: #03a9f4 !important; }
-	button.delete:focus { background-color: rgba(10, 10, 10, 0.3); }
+		&:hover,
+		&:focus {
+			background-color: darken($red, 5%) !important;
+		}
+	}
+	&.is-info {
+		background-color: $blue !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($blue, 5%) !important;
+		}
+	}
+}
 
-	.tag { padding-right: 6px !important; }
-</style>
+.center {
+	text-align: center;
+}
+</style>

+ 11 - 5
frontend/Dockerfile

@@ -1,18 +1,24 @@
-FROM node
+FROM node:12
 
 RUN apt-get update
 RUN apt-get install nginx -y
 
-RUN npm install -g webpack
+RUN npm install -g yarn
+
+RUN yarn global add snyk
+RUN yarn global add webpack@4.35.3
+RUN yarn global add webpack-cli@3.3.5
+RUN yarn global add webpack-dev-server@3.7.2
 
 RUN mkdir -p /opt
 WORKDIR /opt
 ADD package.json /opt/package.json
 
-RUN npm install
+RUN yarn install
 
 RUN mkdir -p /run/nginx
 
-EXPOSE 80
+COPY bootstrap.sh /opt/
+RUN chmod u+x /opt/bootstrap.sh
 
-CMD nginx -c /opt/app/nginx.conf; cd /opt/app; npm run development-watch
+CMD bash /opt/bootstrap.sh

+ 93 - 0
frontend/api/auth.js

@@ -0,0 +1,93 @@
+import { Toast } from "vue-roaster";
+import io from "../io";
+
+// when Vuex needs to interact with socket.io
+
+export default {
+	register(user) {
+		return new Promise((resolve, reject) => {
+			const { username, email, password, recaptchaToken } = user;
+
+			io.getSocket(socket => {
+				socket.emit(
+					"users.register",
+					username,
+					email,
+					password,
+					recaptchaToken,
+					res => {
+						if (res.status === "success") {
+							if (res.SID) {
+								return lofig.get("cookie", cookie => {
+									const date = new Date();
+									date.setTime(
+										new Date().getTime() +
+											2 * 365 * 24 * 60 * 60 * 1000
+									);
+									const secure = cookie.secure
+										? "secure=true; "
+										: "";
+									document.cookie = `SID=${
+										res.SID
+									}; expires=${date.toGMTString()}; domain=${
+										cookie.domain
+									}; ${secure}path=/`;
+
+									return resolve({ status: "success" });
+								});
+							}
+							return reject(new Error("You must login"));
+						}
+
+						return reject(new Error(res.message));
+					}
+				);
+			});
+		});
+	},
+	login(user) {
+		return new Promise((resolve, reject) => {
+			const { email, password } = user;
+
+			io.getSocket(socket => {
+				socket.emit("users.login", email, password, res => {
+					if (res.status === "success") {
+						return lofig.get("cookie", cookie => {
+							const date = new Date();
+							date.setTime(
+								new Date().getTime() +
+									2 * 365 * 24 * 60 * 60 * 1000
+							);
+							const secure = cookie.secure ? "secure=true; " : "";
+							let domain = "";
+							if (cookie.domain !== "localhost")
+								domain = ` domain=${cookie.domain};`;
+							document.cookie = `${cookie.SIDname}=${
+								res.SID
+							}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
+							return resolve({ status: "success" });
+						});
+					}
+
+					return reject(new Error(res.message));
+				});
+			});
+		});
+	},
+	logout() {
+		return new Promise((resolve, reject) => {
+			io.getSocket(socket => {
+				socket.emit("users.logout", result => {
+					if (result.status === "success") {
+						return lofig.get("cookie", cookie => {
+							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
+							return window.location.reload();
+						});
+					}
+					Toast.methods.addToast(result.message, 4000);
+					return reject(new Error(result.message));
+				});
+			});
+		});
+	}
+};

+ 28 - 8
frontend/auth.js

@@ -1,19 +1,36 @@
 let callbacks = [];
+const bannedCallbacks = [];
 
 export default {
-
 	ready: false,
 	authenticated: false,
-	username: '',
-	userId: '',
-	role: 'default',
+	username: "",
+	userId: "",
+	role: "default",
+	banned: null,
+	ban: {},
 
-	getStatus: function (cb) {
-		if (this.ready) cb(this.authenticated, this.role, this.username, this.userId);
+	getStatus(cb) {
+		if (this.ready)
+			cb(this.authenticated, this.role, this.username, this.userId);
 		else callbacks.push(cb);
 	},
 
-	data: function (authenticated, role, username, userId) {
+	setBanned(ban) {
+		this.banned = true;
+		this.ban = ban;
+		bannedCallbacks.forEach(callback => {
+			callback(true, this.ban);
+		});
+	},
+
+	isBanned(cb) {
+		if (this.ready) return cb(false);
+		if (!this.ready && this.banned === true) return cb(true, this.ban);
+		return bannedCallbacks.push(cb);
+	},
+
+	data(authenticated, role, username, userId) {
 		this.authenticated = authenticated;
 		this.role = role;
 		this.username = username;
@@ -22,6 +39,9 @@ export default {
 		callbacks.forEach(callback => {
 			callback(authenticated, role, username, userId);
 		});
+		bannedCallbacks.forEach(callback => {
+			callback(false);
+		});
 		callbacks = [];
 	}
-}
+};

+ 9 - 0
frontend/bootstrap.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+
+if [ "$FRONTEND_MODE" == "prod" ] ; then
+	cd /opt/app ; yarn run $FRONTEND_MODE
+	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf -g "daemon off;"
+elif [ "$FRONTEND_MODE" == "dev" ] ; then
+	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf
+	cd /opt/app; yarn run $FRONTEND_MODE
+fi

BIN
frontend/build/assets/notes-transparent.png


+ 0 - 12
frontend/build/browserconfig.xml

@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<browserconfig>
-  <msapplication>
-    <tile>
-      <square70x70logo src="/mstile-70x70.png?v=06042016"/>
-      <square150x150logo src="/mstile-150x150.png?v=06042016"/>
-      <square310x310logo src="/mstile-310x310.png?v=06042016"/>
-      <wide310x150logo src="/mstile-310x150.png?v=06042016"/>
-      <TileColor>#2d89ef</TileColor>
-    </tile>
-  </msapplication>
-</browserconfig>

+ 0 - 10
frontend/build/config/template.json

@@ -1,10 +0,0 @@
-{
-	"recaptcha": {
-		"key": ""
-	},
-  	"serverDomain": "",
-  	"cookie": {
-		"domain": "",
-		"secure": false
-	}
-}

+ 0 - 12
frontend/build/index.css

@@ -1,12 +0,0 @@
-body {
-	background-color: rgb(245, 245, 245);
-}
-
-.card {
-	background-color: white;
-	/*padding: 20px;*/
-}
-
-.toast {
-	z-index: 10000 !important;
-}

+ 0 - 50
frontend/build/index.html

@@ -1,50 +0,0 @@
-<!DOCTYPE html>
-<html lang='en'>
-<head>
-	<title>Musare</title>
-
-	<meta charset='UTF-8'>
-	<meta http-equiv='X-UA-Compatible' content='IE=edge'>
-	<meta name='viewport' content='width=device-width, initial-scale=1'>
-	<meta name='keywords' content='music, musare, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
-	<meta name='description' content='On Musare you can listen to lots of different songs, playing 24/7 in our official stations and in user-made community stations!'>
-	<meta name='copyright' content='© Copyright Musare 2015-2016 All Right Reserved'>
-
-	<link rel='apple-touch-icon' sizes='57x57' href='/apple-touch-icon-57x57.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='60x60' href='/apple-touch-icon-60x60.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='72x72' href='/apple-touch-icon-72x72.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='76x76' href='/apple-touch-icon-76x76.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='114x114' href='/apple-touch-icon-114x114.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='120x120' href='/apple-touch-icon-120x120.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='144x144' href='/apple-touch-icon-144x144.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='152x152' href='/apple-touch-icon-152x152.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon-180x180.png?v=06042016'>
-	<link rel='icon' type='image/png' href='/favicon-32x32.png?v=06042016' sizes='32x32'>
-	<link rel='icon' type='image/png' href='/favicon-194x194.png?v=06042016' sizes='194x194'>
-	<link rel='icon' type='image/png' href='/favicon-96x96.png?v=06042016' sizes='96x96'>
-	<link rel='icon' type='image/png' href='/android-chrome-192x192.png?v=06042016' sizes='192x192'>
-	<link rel='icon' type='image/png' href='/favicon-16x16.png?v=06042016' sizes='16x16'>
-	<link rel='manifest' href='/manifest.json?v=06042016'>
-	<link rel='mask-icon' href='/safari-pinned-tab.svg?v=06042016' color='#03a9f4'>
-	<link rel='shortcut icon' href='/favicon.ico?v=06042016'>
-	<meta name='msapplication-TileColor' content='#03a9f4'>
-	<meta name='msapplication-TileImage' content='/mstile-144x144.png?v=06042016'>
-	<meta name='theme-color' content='#03a9f4'>
-	<meta name='google' content='nositelinkssearchbox' />
-
-
-	<link href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.6.3/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
-	<link href='https://fonts.googleapis.com/css?family=Roboto:100,400' rel='stylesheet'>
-	<link href='https://fonts.googleapis.com/icon?family=Material+Icons' rel='stylesheet'>
-	<link rel='stylesheet' href='/index.css'>
-	<script src='https://www.youtube.com/iframe_api'></script>
-	<script src='/vendor/jquery.min.js'></script>
-	<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.15.0/moment.min.js'></script>
-	<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.4.8/socket.io.min.js'></script>
-	<script type='text/javascript' src='https://cdn.rawgit.com/atjonathan/lofig/master/dist/lofig.min.js'></script>
-</head>
-<body>
-	<script src='https://www.google.com/recaptcha/api.js'></script>
-	<script src='/bundle.js'></script>
-</body>
-</html>

File diff suppressed because it is too large
+ 0 - 1
frontend/build/vendor/jquery.min.js


+ 23 - 17
frontend/components/404.vue

@@ -1,25 +1,31 @@
 <template>
 	<div class="wrapper">
+		<metadata title="404" />
+
 		<h3><strong>404</strong>&nbsp;Not Found</h3>
-		<button class="button is-black" @click="$router.go('/')">Back to Home</button>
+		<router-link class="button is-black" to="/">
+			Back to Home
+		</router-link>
 	</div>
 </template>
 
-<style type="scss" scoped>
-	* {
-		margin: 0;
-		padding: 0;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+* {
+	margin: 0;
+	padding: 0;
+}
 
-	.wrapper {
-		height: 100vh;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		flex-direction: column;
-	}
+.wrapper {
+	height: 100vh;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	flex-direction: column;
+}
 
-	button {
-		margin-top: 15px;
-	}
-</style>
+a {
+	margin-top: 15px;
+}
+</style>

+ 501 - 0
frontend/components/Admin/EditStation.vue

@@ -0,0 +1,501 @@
+<template>
+	<modal title="Edit Station">
+		<template v-slot:body>
+			<label class="label">Name</label>
+			<p class="control">
+				<input
+					v-model="editing.name"
+					class="input"
+					type="text"
+					placeholder="Station Name"
+				/>
+			</p>
+			<label class="label">Display name</label>
+			<p class="control">
+				<input
+					v-model="editing.displayName"
+					class="input"
+					type="text"
+					placeholder="Station Display Name"
+				/>
+			</p>
+			<label class="label">Description</label>
+			<p class="control">
+				<input
+					v-model="editing.description"
+					class="input"
+					type="text"
+					placeholder="Station Description"
+				/>
+			</p>
+			<label class="label">Privacy</label>
+			<p class="control">
+				<span class="select">
+					<select v-model="editing.privacy">
+						<option value="public">Public</option>
+						<option value="unlisted">Unlisted</option>
+						<option value="private">Private</option>
+					</select>
+				</span>
+			</p>
+			<br />
+			<p class="control" v-if="station.type === 'community'">
+				<label class="checkbox party-mode-inner">
+					<input v-model="editing.partyMode" type="checkbox" />
+					&nbsp;Party mode
+				</label>
+			</p>
+			<small v-if="station.type === 'community'"
+				>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="station.type === 'community' && station.partyMode">
+				<br />
+				<br />
+				<label class="label">Queue lock</label>
+				<small v-if="station.partyMode"
+					>With the queue locked, only owners (you) can add songs to
+					the queue.</small
+				>
+				<br />
+				<button
+					v-if="!station.locked"
+					class="button is-danger"
+					@click="$parent.toggleLock()"
+				>
+					Lock the queue
+				</button>
+				<button
+					v-if="station.locked"
+					class="button is-success"
+					@click="$parent.toggleLock()"
+				>
+					Unlock the queue
+				</button>
+			</div>
+			<div
+				v-if="station.type === 'official'"
+				class="control is-grouped genre-wrapper"
+			>
+				<div class="sector">
+					<p class="control has-addons">
+						<input
+							id="new-genre-edit"
+							class="input"
+							type="text"
+							placeholder="Genre"
+							@keyup.enter="addGenre()"
+						/>
+						<a class="button is-info" href="#" @click="addGenre()"
+							>Add genre</a
+						>
+					</p>
+					<span
+						v-for="(genre, index) in editing.genres"
+						:key="index"
+						class="tag is-info"
+					>
+						{{ genre }}
+						<button
+							class="delete is-info"
+							@click="removeGenre(index)"
+						/>
+					</span>
+				</div>
+				<div class="sector">
+					<p class="control has-addons">
+						<input
+							id="new-blacklisted-genre-edit"
+							class="input"
+							type="text"
+							placeholder="Blacklisted Genre"
+							@keyup.enter="addBlacklistedGenre()"
+						/>
+						<a
+							class="button is-info"
+							href="#"
+							@click="addBlacklistedGenre()"
+							>Add blacklisted genre</a
+						>
+					</p>
+					<span
+						v-for="(genre, index) in editing.blacklistedGenres"
+						:key="index"
+						class="tag is-info"
+					>
+						{{ genre }}
+						<button
+							class="delete is-info"
+							@click="removeBlacklistedGenre(index)"
+						/>
+					</span>
+				</div>
+			</div>
+		</template>
+		<template v-slot:footer>
+			<button class="button is-success" v-on:click="update()">
+				Update Settings
+			</button>
+			<button
+				v-if="station.type === 'community'"
+				class="button is-danger"
+				@click="deleteStation()"
+			>
+				Delete station
+			</button>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import { Toast } from "vue-roaster";
+
+import Modal from "../Modals/Modal.vue";
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	computed: mapState("admin/stations", {
+		station: state => state.station,
+		editing: state => state.editing
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			return socket;
+		});
+	},
+	methods: {
+		update() {
+			if (this.station.name !== this.editing.name) this.updateName();
+			if (this.station.displayName !== this.editing.displayName)
+				this.updateDisplayName();
+			if (this.station.description !== this.editing.description)
+				this.updateDescription();
+			if (this.station.privacy !== this.editing.privacy)
+				this.updatePrivacy();
+			if (this.station.partyMode !== this.editing.partyMode)
+				this.updatePartyMode();
+			if (
+				this.station.genres.toString() !==
+				this.editing.genres.toString()
+			)
+				this.updateGenres();
+			if (
+				this.station.blacklistedGenres.toString() !==
+				this.editing.blacklistedGenres.toString()
+			)
+				this.updateBlacklistedGenres();
+		},
+		updateName() {
+			const { name } = this.editing;
+			if (!validation.isLength(name, 2, 16))
+				return Toast.methods.addToast(
+					"Name must have between 2 and 16 characters.",
+					8000
+				);
+			if (!validation.regex.az09_.test(name))
+				return Toast.methods.addToast(
+					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateName",
+				this.editing._id,
+				name,
+				res => {
+					if (res.status === "success") {
+						if (this.station) this.station.name = name;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[index].name = name;
+								return name;
+							}
+
+							return false;
+						});
+					}
+					Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updateDisplayName() {
+			const { displayName } = this.editing;
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateDisplayName",
+				this.editing._id,
+				displayName,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.displayName = displayName;
+							return displayName;
+						}
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].displayName = displayName;
+								return displayName;
+							}
+
+							return false;
+						});
+					}
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updateDescription() {
+			const { description } = this.editing;
+			if (!validation.isLength(description, 2, 200))
+				return Toast.methods.addToast(
+					"Description must have between 2 and 200 characters.",
+					8000
+				);
+			let characters = description.split("");
+			characters = characters.filter(character => {
+				return character.charCodeAt(0) === 21328;
+			});
+			if (characters.length !== 0)
+				return Toast.methods.addToast(
+					"Invalid description format. Swastika's are not allowed.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateDescription",
+				this.editing._id,
+				description,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.description = description;
+							return description;
+						}
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].description = description;
+								return description;
+							}
+
+							return false;
+						});
+
+						return Toast.methods.addToast(res.message, 4000);
+					}
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updatePrivacy() {
+			this.socket.emit(
+				"stations.updatePrivacy",
+				this.editing._id,
+				this.editing.privacy,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.privacy = this.editing.privacy;
+						else {
+							this.$parent.stations.forEach((station, index) => {
+								if (station._id === this.editing._id) {
+									this.$parent.stations[
+										index
+									].privacy = this.editing.privacy;
+									return this.editing.privacy;
+								}
+
+								return false;
+							});
+						}
+						return Toast.methods.addToast(res.message, 4000);
+					}
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updateGenres() {
+			this.socket.emit(
+				"stations.updateGenres",
+				this.editing._id,
+				this.editing.genres,
+				res => {
+					if (res.status === "success") {
+						const genres = JSON.parse(
+							JSON.stringify(this.editing.genres)
+						);
+						if (this.station) this.station.genres = genres;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[index].genres = genres;
+								return genres;
+							}
+
+							return false;
+						});
+						return Toast.methods.addToast(res.message, 4000);
+					}
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updateBlacklistedGenres() {
+			this.socket.emit(
+				"stations.updateBlacklistedGenres",
+				this.editing._id,
+				this.editing.blacklistedGenres,
+				res => {
+					if (res.status === "success") {
+						const blacklistedGenres = JSON.parse(
+							JSON.stringify(this.editing.blacklistedGenres)
+						);
+						if (this.station)
+							this.station.blacklistedGenres = blacklistedGenres;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].blacklistedGenres = blacklistedGenres;
+								return blacklistedGenres;
+							}
+
+							return false;
+						});
+						return Toast.methods.addToast(res.message, 4000);
+					}
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updatePartyMode() {
+			this.socket.emit(
+				"stations.updatePartyMode",
+				this.editing._id,
+				this.editing.partyMode,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.partyMode = this.editing.partyMode;
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].partyMode = this.editing.partyMode;
+								return this.editing.partyMode;
+							}
+
+							return false;
+						});
+
+						return Toast.methods.addToast(res.message, 4000);
+					}
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		addGenre() {
+			const genre = document
+				.getElementById(`new-genre-edit`)
+				.value.toLowerCase()
+				.trim();
+
+			if (this.editing.genres.indexOf(genre) !== -1)
+				return Toast.methods.addToast("Genre already exists", 3000);
+			if (genre) {
+				this.editing.genres.push(genre);
+				document.getElementById(`new-genre`).value = "";
+				return true;
+			}
+			return Toast.methods.addToast("Genre cannot be empty", 3000);
+		},
+		removeGenre(index) {
+			this.editing.genres.splice(index, 1);
+		},
+		addBlacklistedGenre() {
+			const genre = document
+				.getElementById(`new-blacklisted-genre-edit`)
+				.value.toLowerCase()
+				.trim();
+			if (this.editing.blacklistedGenres.indexOf(genre) !== -1)
+				return Toast.methods.addToast("Genre already exists", 3000);
+
+			if (genre) {
+				this.editing.blacklistedGenres.push(genre);
+				document.getElementById(`new-blacklisted-genre`).value = "";
+				return true;
+			}
+			return Toast.methods.addToast("Genre cannot be empty", 3000);
+		},
+		removeBlacklistedGenre(index) {
+			this.editing.blacklistedGenres.splice(index, 1);
+		},
+		deleteStation() {
+			this.socket.emit("stations.remove", this.editing._id, res => {
+				if (res.status === "success")
+					this.closeModal({
+						sector: "station",
+						modal: "editStation"
+					});
+				return Toast.methods.addToast(res.message, 8000);
+			});
+		},
+		...mapActions("modals", ["closeModal"])
+	},
+	components: { Modal }
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.controls {
+	display: flex;
+
+	a {
+		display: flex;
+		align-items: center;
+	}
+}
+
+.table {
+	margin-bottom: 0;
+}
+
+h5 {
+	padding: 20px 0;
+}
+
+.party-mode-inner,
+.party-mode-outer {
+	display: flex;
+	align-items: center;
+}
+
+.select:after {
+	border-color: $primary-color;
+}
+</style>

+ 327 - 199
frontend/components/Admin/News.vue

@@ -1,227 +1,355 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Title</td>
-					<td>Description</td>
-					<td>Bugs</td>
-					<td>Features</td>
-					<td>Improvements</td>
-					<td>Upcoming</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, news) in news' track-by='$index'>
-					<td>
-						<strong>{{ news.title }}</strong>
-					</td>
-					<td>{{ news.description }}</td>
-					<td>{{ news.bugs.join(', ') }}</td>
-					<td>{{ news.features.join(', ') }}</td>
-					<td>{{ news.improvements.join(', ') }}</td>
-					<td>{{ news.upcoming.join(', ') }}</td>
-					<td>
-						<button class='button is-primary' @click='editNews(news)'>Edit</button>
-						<button class='button is-danger' @click='removeNews(news)'>Remove</button>
-					</td>
-				</tr>
-			</tbody>
-		</table>
-
-		<div class='card is-fullwidth'>
-			<header class='card-header'>
-				<p class='card-header-title'>Create News</p>
-			</header>
-			<div class='card-content'>
-				<div class='content'>
-
-					<label class='label'>Title & Description</label>
-					<div class='control is-horizontal'>
-						<div class='control is-grouped'>
-							<p class='control is-expanded'>
-								<input class='input' type='text' placeholder='Title' v-model='creating.title'>
-							</p>
-							<p class='control is-expanded'>
-								<input class='input' type='text' placeholder='Short description' v-model='creating.description'>
-							</p>
-						</div>
-					</div>
+	<div>
+		<metadata title="Admin | News" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Title</td>
+						<td>Description</td>
+						<td>Bugs</td>
+						<td>Features</td>
+						<td>Improvements</td>
+						<td>Upcoming</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(news, index) in news" :key="index">
+						<td>
+							<strong>{{ news.title }}</strong>
+						</td>
+						<td>{{ news.description }}</td>
+						<td>{{ news.bugs.join(", ") }}</td>
+						<td>{{ news.features.join(", ") }}</td>
+						<td>{{ news.improvements.join(", ") }}</td>
+						<td>{{ news.upcoming.join(", ") }}</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="editNewsClick(news)"
+							>
+								Edit
+							</button>
+							<button
+								class="button is-danger"
+								@click="removeNews(news)"
+							>
+								Remove
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
 
-					<div class="columns">
-						<div class="column">
-							<label class='label'>Bugs</label>
-							<p class='control has-addons'>
-								<input class='input' id='new-bugs' type='text' placeholder='Bug' v-on:keyup.enter='addChange("bugs")'>
-								<a class='button is-info' href='#' @click='addChange("bugs")'>Add</a>
-							</p>
-							<span class='tag is-info' v-for='(index, bug) in creating.bugs' track-by='$index'>
-								{{ bug }}
-								<button class='delete is-info' @click='removeChange("bugs", index)'></button>
-							</span>
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
+						Create News
+					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<label class="label">Title & Description</label>
+						<div class="control is-horizontal">
+							<div class="control is-grouped">
+								<p class="control is-expanded">
+									<input
+										v-model="creating.title"
+										class="input"
+										type="text"
+										placeholder="Title"
+									/>
+								</p>
+								<p class="control is-expanded">
+									<input
+										v-model="creating.description"
+										class="input"
+										type="text"
+										placeholder="Short description"
+									/>
+								</p>
+							</div>
 						</div>
-						<div class="column">
-							<label class='label'>Features</label>
-							<p class='control has-addons'>
-								<input class='input' id='new-features' type='text' placeholder='Feature' v-on:keyup.enter='addChange("features")'>
-								<a class='button is-info' href='#' @click='addChange("features")'>Add</a>
-							</p>
-							<span class='tag is-info' v-for='(index, feature) in creating.features' track-by='$index'>
-								{{ feature }}
-								<button class='delete is-info' @click='removeChange("features", index)'></button>
-							</span>
-						</div>
-					</div>
 
-					<div class="columns">
-						<div class="column">
-							<label class='label'>Improvements</label>
-							<p class='control has-addons'>
-								<input class='input' id='new-improvements' type='text' placeholder='Improvement' v-on:keyup.enter='addChange("improvements")'>
-								<a class='button is-info' href='#' @click='addChange("improvements")'>Add</a>
-							</p>
-							<span class='tag is-info' v-for='(index, improvement) in creating.improvements' track-by='$index'>
-								{{ improvement }}
-								<button class='delete is-info' @click='removeChange("improvements", index)'></button>
-							</span>
+						<div class="columns">
+							<div class="column">
+								<label class="label">Bugs</label>
+								<p class="control has-addons">
+									<input
+										id="new-bugs"
+										class="input"
+										type="text"
+										placeholder="Bug"
+										@keyup.enter="addChange('bugs')"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addChange('bugs')"
+										>Add</a
+									>
+								</p>
+								<span
+									v-for="(bug, index) in creating.bugs"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ bug }}
+									<button
+										class="delete is-info"
+										@click="removeChange('bugs', index)"
+									/>
+								</span>
+							</div>
+							<div class="column">
+								<label class="label">Features</label>
+								<p class="control has-addons">
+									<input
+										id="new-features"
+										class="input"
+										type="text"
+										placeholder="Feature"
+										@keyup.enter="addChange('features')"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addChange('features')"
+										>Add</a
+									>
+								</p>
+								<span
+									v-for="(feature,
+									index) in creating.features"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ feature }}
+									<button
+										class="delete is-info"
+										@click="removeChange('features', index)"
+									/>
+								</span>
+							</div>
 						</div>
-						<div class="column">
-							<label class='label'>Upcoming</label>
-							<p class='control has-addons'>
-								<input class='input' id='new-upcoming' type='text' placeholder='Upcoming' v-on:keyup.enter='addChange("upcoming")'>
-								<a class='button is-info' href='#' @click='addChange("upcoming")'>Add</a>
-							</p>
-							<span class='tag is-info' v-for='(index, upcoming) in creating.upcoming' track-by='$index'>
-								{{ upcoming }}
-								<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
-							</span>
+
+						<div class="columns">
+							<div class="column">
+								<label class="label">Improvements</label>
+								<p class="control has-addons">
+									<input
+										id="new-improvements"
+										class="input"
+										type="text"
+										placeholder="Improvement"
+										@keyup.enter="addChange('improvements')"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addChange('improvements')"
+										>Add</a
+									>
+								</p>
+								<span
+									v-for="(improvement,
+									index) in creating.improvements"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ improvement }}
+									<button
+										class="delete is-info"
+										@click="
+											removeChange('improvements', index)
+										"
+									/>
+								</span>
+							</div>
+							<div class="column">
+								<label class="label">Upcoming</label>
+								<p class="control has-addons">
+									<input
+										id="new-upcoming"
+										class="input"
+										type="text"
+										placeholder="Upcoming"
+										@keyup.enter="addChange('upcoming')"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addChange('upcoming')"
+										>Add</a
+									>
+								</p>
+								<span
+									v-for="(upcoming,
+									index) in creating.upcoming"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ upcoming }}
+									<button
+										class="delete is-info"
+										@click="removeChange('upcoming', index)"
+									/>
+								</span>
+							</div>
 						</div>
 					</div>
-
 				</div>
+				<footer class="card-footer">
+					<a class="card-footer-item" @click="createNews()" href="#"
+						>Create</a
+					>
+				</footer>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='createNews()' href='#'>Create</a>
-			</footer>
 		</div>
-	</div>
 
-	<edit-news v-if='modals.editNews'></edit-news>
+		<edit-news v-if="modals.editNews" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
-
-	import EditNews from '../Modals/EditNews.vue';
-
-	export default {
-		components: { EditNews },
-		data() {
-			return {
-				modals: { editNews: false },
-				news: [],
-				creating: {
-					title: '',
-					description: '',
-					bugs: [],
-					features: [],
-					improvements: [],
-					upcoming: []
-				},
-				editing: {}
+import { mapActions, mapState } from "vuex";
+
+import { Toast } from "vue-roaster";
+import io from "../../io";
+
+import EditNews from "../Modals/EditNews.vue";
+
+export default {
+	components: { EditNews },
+	data() {
+		return {
+			news: [],
+			creating: {
+				title: "",
+				description: "",
+				bugs: [],
+				features: [],
+				improvements: [],
+				upcoming: []
 			}
-		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editNews = !this.modals.editNews;
-			},
-			createNews: function () {
-				let _this = this;
-
-				let { creating: { bugs, features, improvements, upcoming } } = this;
-
-				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 && 
-					improvements.length <= 0 && upcoming.length <= 0
-				) return Toast.methods.addToast('You must have at least one News Item', 3000);
-
-				_this.socket.emit('news.create', _this.creating, result => {
-					Toast.methods.addToast(result.message, 4000);
-					if (result.status == 'success') _this.creating = {
-						title: '',
-						description: '',
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("news.index", res => {
+				this.news = res.data;
+				return res.data;
+			});
+			this.socket.on("event:admin.news.created", news => {
+				this.news.unshift(news);
+			});
+			this.socket.on("event:admin.news.removed", news => {
+				this.news = this.news.filter(item => item._id !== news._id);
+			});
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+		});
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		}),
+		...mapState("admin/news", {
+			editing: state => state.editing
+		})
+	},
+	methods: {
+		createNews() {
+			const {
+				creating: { bugs, features, improvements, upcoming }
+			} = this;
+
+			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 &&
+				improvements.length <= 0 &&
+				upcoming.length <= 0
+			)
+				return Toast.methods.addToast(
+					"You must have at least one News Item",
+					3000
+				);
+
+			return this.socket.emit("news.create", this.creating, result => {
+				Toast.methods.addToast(result.message, 4000);
+				if (result.status === "success")
+					this.creating = {
+						title: "",
+						description: "",
 						bugs: [],
 						features: [],
 						improvements: [],
 						upcoming: []
-					}
-				});
-			},
-			removeNews: function (news) {
-				this.socket.emit('news.remove', news, res => {
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			editNews: function (news) {
-				this.editing = news;
-				this.toggleModal();
-			},
-			updateNews: function (close) {
-				let _this = this;
-				this.socket.emit('news.update', _this.editing._id, _this.editing, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						if (close) _this.toggleModal();
-					}
-				});
-			},
-			addChange: function (type) {
-				let change = $(`#new-${type}`).val().trim();
-
-				if (this.creating[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
-
-				if (change) this.creating[type].push(change);
-				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
-			},
-			removeChange: function (type, index) {
-				this.creating[type].splice(index, 1);
-			},
-			init: function () {
-				this.socket.emit('apis.joinAdminRoom', 'news', data => {});
+					};
+			});
+		},
+		removeNews(news) {
+			this.socket.emit("news.remove", news, res =>
+				Toast.methods.addToast(res.message, 8000)
+			);
+		},
+		editNewsClick(news) {
+			this.editNews(news);
+			this.openModal({ sector: "admin", modal: "editNews" });
+		},
+		addChange(type) {
+			const change = document.getElementById(`new-${type}`).value.trim();
+
+			if (this.creating[type].indexOf(change) !== -1)
+				return Toast.methods.addToast(`Tag already exists`, 3000);
+
+			if (change) {
+				document.getElementById(`new-${type}`).value = "";
+				this.creating[type].push(change);
+				return true;
 			}
+			return Toast.methods.addToast(`${type} cannot be empty`, 3000);
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('news.index', result => {
-					_this.news = result.data;
-				});
-				_this.socket.on('event:admin.news.created', news => {
-					_this.news.unshift(news);
-				});
-				_this.socket.on('event:admin.news.removed', news => {
-					_this.news = _this.news.filter(item => item._id !== news._id);
-				});
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => {
-					_this.init();
-				});
-			});
-		}
+		removeChange(type, index) {
+			this.creating[type].splice(index, 1);
+		},
+		init() {
+			this.socket.emit("apis.joinAdminRoom", "news", () => {});
+		},
+		...mapActions("modals", ["openModal", "closeModal"]),
+		...mapActions("admin/news", ["editNews"])
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag:not(:last-child) { margin-right: 5px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-info:focus { background-color: #0398db; }
+.is-info:focus {
+	background-color: $primary-color;
+}
 
-	.card-footer-item { color: #03A9F4; }
+.card-footer-item {
+	color: $primary-color;
+}
 </style>

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

@@ -0,0 +1,191 @@
+<template>
+	<div>
+		<metadata title="Admin | Punishments" />
+		<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="(punishment, index) in sortedPunishments"
+						:key="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
+								v-model="ipBan.ip"
+								class="input"
+								type="text"
+								placeholder="IP address (xxx.xxx.xxx.xxx)"
+							/>
+						</p>
+						<label class="label">Reason</label>
+						<p class="control is-expanded">
+							<input
+								v-model="ipBan.reason"
+								class="input"
+								type="text"
+								placeholder="Reason"
+							/>
+						</p>
+					</div>
+				</div>
+				<footer class="card-footer">
+					<a class="card-footer-item" v-on:click="banIP()" href="#"
+						>Ban IP</a
+					>
+				</footer>
+			</div>
+		</div>
+		<view-punishment v-if="modals.viewPunishment" />
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import { Toast } from "vue-roaster";
+
+import ViewPunishment from "../Modals/ViewPunishment.vue";
+import io from "../../io";
+
+export default {
+	components: { ViewPunishment },
+	data() {
+		return {
+			punishments: [],
+			ipBan: {
+				expiresAt: "1h"
+			}
+		};
+	},
+	computed: {
+		sortedPunishments() {
+			//   return _.orderBy(this.punishments, -1);
+			return this.punishments;
+		},
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		view(punishment) {
+			this.viewPunishment(punishment);
+			this.openModal({ sector: "admin", modal: "viewPunishment" });
+		},
+		banIP() {
+			this.socket.emit(
+				"punishments.banIP",
+				this.ipBan.ip,
+				this.ipBan.reason,
+				this.ipBan.expiresAt,
+				res => {
+					Toast.methods.addToast(res.message, 6000);
+				}
+			);
+		},
+		init() {
+			this.socket.emit("punishments.index", res => {
+				if (res.status === "success") this.punishments = res.data;
+			});
+			this.socket.emit("apis.joinAdminRoom", "punishments", () => {});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("admin/punishments", ["viewPunishment"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+			socket.on("event:admin.punishment.added", punishment => {
+				this.punishments.push(punishment);
+			});
+		});
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+td {
+	vertical-align: middle;
+}
+select {
+	margin-bottom: 10px;
+}
+</style>

+ 218 - 127
frontend/components/Admin/QueueSongs.vue

@@ -1,147 +1,238 @@
 <template>
-	<div class='container'>
-		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
-		<br /><br />
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Thumbnail</td>
-					<td>Title</td>
-					<td>YouTube ID</td>
-					<td>Artists</td>
-					<td>Genres</td>
-					<td>Requested By</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
-					<td>
-						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-					</td>
-					<td>
-						<strong>{{ song.title }}</strong>
-					</td>
-					<td>{{ song.songId }}</td>
-					<td>{{ song.artists.join(', ') }}</td>
-					<td>{{ song.genres.join(', ') }}</td>
-					<td>{{ song.requestedBy }}</td>
-					<td>
-						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
-						<button class='button is-success' @click='add(song)'>Add</button>
-						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
-					</td>
-				</tr>
-			</tbody>
-		</table>
+	<div>
+		<metadata title="Admin | Queue songs" />
+		<div class="container">
+			<input
+				v-model="searchQuery"
+				type="text"
+				class="input"
+				placeholder="Search for Songs"
+			/>
+			<br />
+			<br />
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>Artists</td>
+						<td>Genres</td>
+						<td>ID / YouTube ID</td>
+						<td>Requested By</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(song, index) in filteredSongs" :key="index">
+						<td>
+							<img
+								class="song-thumbnail"
+								:src="song.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</td>
+						<td>
+							<strong>{{ song.title }}</strong>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>
+							{{ song._id }}
+							<br />
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="song.requestedBy"
+								:link="true"
+							/>
+						</td>
+						<td class="optionsColumn">
+							<button
+								class="button is-primary"
+								@click="edit(song, index)"
+							>
+								<i class="material-icons">edit</i>
+							</button>
+							<button
+								class="button is-success"
+								@click="add(song)"
+							>
+								<i class="material-icons">add</i>
+							</button>
+							<button
+								class="button is-danger"
+								@click="remove(song._id, index)"
+							>
+								<i class="material-icons">cancel</i>
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<nav class="pagination">
+			<a
+				v-if="position > 1"
+				class="button"
+				href="#"
+				@click="getSet(position - 1)"
+			>
+				<i class="material-icons">navigate_before</i>
+			</a>
+			<a
+				v-if="maxPosition > position"
+				class="button"
+				href="#"
+				@click="getSet(position + 1)"
+			>
+				<i class="material-icons">navigate_next</i>
+			</a>
+		</nav>
+		<edit-song v-if="modals.editSong" />
 	</div>
-	<nav class="pagination">
-		<a class="button" href='#' @click='getSet(position - 1)' v-if='position > 1'><i class="material-icons">navigate_before</i></a>
-		<a class="button" href='#' @click='getSet(position + 1)' v-if='maxPosition > position'><i class="material-icons">navigate_next</i></a>
-	</nav>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { mapState, mapActions } from "vuex";
 
-	import EditSong from '../Modals/EditSong.vue';
-	import io from '../../io';
+import { Toast } from "vue-roaster";
 
-	export default {
-		components: { EditSong },
-		data() {
-			return {
-				position: 1,
-				maxPosition: 1,
-				searchQuery: '',
-				songs: [],
-				modals: { editSong: false }
-			}
+import EditSong from "../Modals/EditSong.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+import io from "../../io";
+
+export default {
+	components: { EditSong, UserIdToUsername },
+	data() {
+		return {
+			position: 1,
+			maxPosition: 1,
+			searchQuery: "",
+			songs: []
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
 		},
-		computed: {
-			filteredSongs: function () {
-				return this.$eval('songs | filterBy searchQuery');
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	// watch: {
+	//   "modals.editSong": function(value) {
+	//     console.log(value);
+	//     if (value === false) this.stopVideo();
+	//   }
+	// },
+	methods: {
+		getSet(position) {
+			this.socket.emit("queueSongs.getSet", position, data => {
+				this.songs = data;
+				this.position = position;
+			});
 		},
-		watch: {
-			'modals.editSong': function (value) {
-				if (!value) this.$broadcast('stopVideo');
-			}
+		edit(song, index) {
+			const newSong = {};
+			Object.keys(song).forEach(n => {
+				newSong[n] = song[n];
+			});
+
+			this.editSong({ index, song: newSong, type: "queueSongs" });
+			this.openModal({ sector: "admin", modal: "editSong" });
 		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editSong = !this.modals.editSong;
-			},
-			getSet: function (position) {
-				let _this = this;
-				this.socket.emit('queueSongs.getSet', position, data => {
-					_this.songs = data;
-					this.position = position;
-				});
-			},
-			edit: function (song, index) {
-				this.$broadcast('editSong', song, index, 'queueSongs');
-			},
-			add: function (song) {
-				this.socket.emit('songs.add', song, res => {
-					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
-				});
-			},
-			remove: function (id, index) {
-				console.log("Removing ", id);
-				this.socket.emit('queueSongs.remove', id, res => {
-					if (res.status == 'success') Toast.methods.addToast(res.message, 2000);
-				});
-			},
-			init: function() {
-				let _this = this;
-				_this.socket.emit('queueSongs.index', data => {
-					_this.songs = data.songs;
-					_this.maxPosition = Math.round(data.maxLength / 50);
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'queue', data => {});
-			}
+		add(song) {
+			this.socket.emit("songs.add", song, res => {
+				if (res.status === "success")
+					Toast.methods.addToast(res.message, 2000);
+				else Toast.methods.addToast(res.message, 4000);
+			});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				if (_this.socket.connected) {
-					_this.init();
-					_this.socket.on('event:admin.queueSong.added', queueSong => {
-						_this.songs.push(queueSong);
-					});
-					_this.socket.on('event:admin.queueSong.removed', songId => {
-						_this.songs = _this.songs.filter(function(song) {
-							return song._id !== songId;
-						});
-					});
-					_this.socket.on('event:admin.queueSong.updated', updatedSong => {
-						for (let i = 0; i < _this.songs.length; i++) {
-							let song = _this.songs[i];
-							if (song._id === updatedSong._id) {
-								_this.songs.$set(i, updatedSong);
-							}
-						}
-					});
-				}
-				io.onConnect(() => {
-					_this.init();
+		remove(id) {
+			this.socket.emit("queueSongs.remove", id, res => {
+				if (res.status === "success")
+					Toast.methods.addToast(res.message, 2000);
+				else Toast.methods.addToast(res.message, 4000);
+			});
+		},
+		init() {
+			this.socket.emit("queueSongs.index", data => {
+				this.songs = data.songs;
+				this.maxPosition = Math.round(data.maxLength / 50);
+			});
+			this.socket.emit("apis.joinAdminRoom", "queue", () => {});
+		},
+		...mapActions("admin/songs", ["stopVideo", "editSong"]),
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			this.socket.on("event:admin.queueSong.added", queueSong => {
+				this.songs.push(queueSong);
+			});
+			this.socket.on("event:admin.queueSong.removed", songId => {
+				this.songs = this.songs.filter(song => {
+					return song._id !== songId;
 				});
 			});
-		}
+			this.socket.on("event:admin.queueSong.updated", updatedSong => {
+				for (let i = 0; i < this.songs.length; i += 1) {
+					const song = this.songs[i];
+					if (song._id === updatedSong._id) {
+						this.songs.$set(i, updatedSong);
+					}
+				}
+			});
+
+			if (this.socket.connected) {
+				this.init();
+			}
+			io.onConnect(() => {
+				this.init();
+			});
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.song-thumbnail {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.optionsColumn {
+	width: 140px;
+	button {
+		width: 35px;
 	}
+}
+
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 130 - 92
frontend/components/Admin/Reports.vue

@@ -1,109 +1,147 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Song ID</td>
-					<td>Created By</td>
-					<td>Created At</td>
-					<td>Description</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, report) in reports' track-by='$index'>
-					<td>
-						<span>{{ report.songId }}</span>
-					</td>
-					<td>
-						<span>{{ report.createdBy }}</span>
-					</td>
-					<td>
-						<span>{{ report.createdAt }}</span>
-					</td>
-					<td>
-						<span>{{ report.description }}</span>
-					</td>
-					<td>
-						<a class='button is-warning' href='#' @click='toggleModal(report)'>Issues Modal</a>
-						<a class='button is-primary' href='#' @click='resolve(report._id)'>Resolve</a>
-					</td>
-				</tr>
-			</tbody>
-		</table>
-	</div>
+	<div>
+		<metadata title="Admin | Reports" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Song ID</td>
+						<td>Created By</td>
+						<td>Created At</td>
+						<td>Description</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(report, index) in reports" :key="index">
+						<td>
+							<span>
+								{{ report.song.songId }}
+								<br />
+								{{ report.song._id }}
+							</span>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="report.createdBy"
+								:link="true"
+							/>
+						</td>
+						<td>
+							<span>{{ report.createdAt }}</span>
+						</td>
+						<td>
+							<span>{{ report.description }}</span>
+						</td>
+						<td>
+							<a
+								class="button is-warning"
+								href="#"
+								@click="view(report)"
+								>View</a
+							>
+							<a
+								class="button is-primary"
+								href="#"
+								@click="resolve(report._id)"
+								>Resolve</a
+							>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
 
-	<issues-modal v-if='modals.report'></issues-modal>
+		<issues-modal v-if="modals.viewReport" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	import IssuesModal from '../Modals/IssuesModal.vue';
+import { Toast } from "vue-roaster";
+import io from "../../io";
 
-	export default {
-		data() {
-			return {
-				reports: [],
-				modals: {
-					report: false
-				}
-			}
-		},
-		methods: {
-			init: function() {
-				this.socket.emit('apis.joinAdminRoom', 'reports', data => {});
-			},
-			toggleModal: function (report) {
-				this.modals.report = !this.modals.report;
-				if (this.modals.report) this.editing = report;
-			},
-			resolve: function (reportId) {
-				let _this = this;
-				this.socket.emit('reports.resolve', reportId, res => {
-					Toast.methods.addToast(res.message, 3000);
-					if (res.status === 'success' && this.modals.report) _this.toggleModal();
+import IssuesModal from "../Modals/IssuesModal.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	components: { IssuesModal, UserIdToUsername },
+	data() {
+		return {
+			reports: []
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			this.socket.emit("reports.index", res => {
+				this.reports = res.data;
+			});
+			this.socket.on("event:admin.report.resolved", reportId => {
+				this.reports = this.reports.filter(report => {
+					return report._id !== reportId;
 				});
-			}
+			});
+			this.socket.on("event:admin.report.created", report => {
+				this.reports.push(report);
+			});
+			io.onConnect(() => {
+				this.init();
+			});
+		});
+
+		if (this.$route.query.id) {
+			this.socket.emit("reports.findOne", this.$route.query.id, res => {
+				if (res.status === "success") this.view(res.data);
+				else
+					Toast.methods.addToast(
+						"Report with that ID not found",
+						3000
+					);
+			});
+		}
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		init() {
+			this.socket.emit("apis.joinAdminRoom", "reports", () => {});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				_this.socket.emit('reports.index', res => {
-					_this.reports = res.data;
-				});
-				_this.socket.on('event:admin.report.resolved', reportId => {
-					_this.reports = _this.reports.filter(report => {
-						return report._id !== reportId;
+		view(report) {
+			this.viewReport(report);
+			this.openModal({ sector: "admin", modal: "viewReport" });
+		},
+		resolve(reportId) {
+			this.socket.emit("reports.resolve", reportId, res => {
+				Toast.methods.addToast(res.message, 3000);
+				if (res.status === "success" && this.modals.viewReport)
+					this.closeModal({
+						sector: "admin",
+						modal: "viewReport"
 					});
-				});
-				_this.socket.on('event:admin.report.created', report => {
-					_this.reports.push(report);
-				});
-				io.onConnect(() => {
-					_this.init();
-				});
 			});
-			if (this.$route.query.id) {
-				this.socket.emit('reports.findOne', this.$route.query.id, res => {
-					if (res.status === 'success') _this.toggleModal(res.data);
-					else Toast.methods.addToast('Report with that ID not found', 3000);
-				});
-			}
 		},
-		components: { IssuesModal }
+		...mapActions("modals", ["openModal", "closeModal"]),
+		...mapActions("admin/reports", ["viewReport"])
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag:not(:last-child) { margin-right: 5px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	td {
-		word-wrap: break-word;
-		max-width: 10vw;
-		vertical-align: middle;
-	}
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
+
+td {
+	word-wrap: break-word;
+	max-width: 10vw;
+	vertical-align: middle;
+}
 </style>

+ 223 - 128
frontend/components/Admin/Songs.vue

@@ -1,151 +1,246 @@
 <template>
-	<div class='container'>
-		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
-		<br /><br />
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Thumbnail</td>
-					<td>Title</td>
-					<td>YouTube ID</td>
-					<td>Artists</td>
-					<td>Genres</td>
-					<td>Requested By</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
-					<td>
-						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-					</td>
-					<td>
-						<strong>{{ song.title }}</strong>
-					</td>
-					<td>{{ song.songId }}</td>
-					<td>{{ song.artists.join(', ') }}</td>
-					<td>{{ song.genres.join(', ') }}</td>
-					<td>{{ song.requestedBy }}</td>
-					<td>
-						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
-						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
-					</td>
-				</tr>
-			</tbody>
-		</table>
+	<div>
+		<metadata title="Admin | Songs" />
+		<div class="container">
+			<input
+				v-model="searchQuery"
+				type="text"
+				class="input"
+				placeholder="Search for Songs"
+			/>
+			<br />
+			<br />
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>Artists</td>
+						<td>Genres</td>
+						<td class="likesColumn">
+							<i class="material-icons thumbLike">thumb_up</i>
+						</td>
+						<td class="dislikesColumn">
+							<i class="material-icons thumbDislike"
+								>thumb_down</i
+							>
+						</td>
+						<td>ID / Youtube ID</td>
+						<td>Requested By</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(song, index) in filteredSongs" :key="index">
+						<td>
+							<img
+								class="song-thumbnail"
+								:src="song.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</td>
+						<td>
+							<strong>{{ song.title }}</strong>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>{{ song.likes }}</td>
+						<td>{{ song.dislikes }}</td>
+						<td>
+							{{ song._id }}
+							<br />
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="song.requestedBy"
+								:link="true"
+							/>
+						</td>
+						<td class="optionsColumn">
+							<button
+								class="button is-primary"
+								@click="edit(song)"
+							>
+								<i class="material-icons">edit</i>
+							</button>
+							<button
+								class="button is-danger"
+								@click="remove(song._id, index)"
+							>
+								<i class="material-icons">cancel</i>
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-song v-if="modals.editSong" />
 	</div>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { mapState, mapActions } from "vuex";
 
-	import EditSong from '../Modals/EditSong.vue';
-	import io from '../../io';
+import { Toast } from "vue-roaster";
 
-	export default {
-		components: { EditSong },
-		data() {
-			return {
-				position: 1,
-				maxPosition: 1,
-				songs: [],
-				searchQuery: '',
-				modals: { editSong: false },
-				editing: {
-					index: 0,
-					song: {}
-				},
-				video: {
-					player: null,
-					paused: false,
-					playerReady: false
-				}
+import EditSong from "../Modals/EditSong.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+import io from "../../io";
+
+export default {
+	components: { EditSong, UserIdToUsername },
+	data() {
+		return {
+			position: 1,
+			maxPosition: 1,
+			searchQuery: "",
+			editing: {
+				index: 0,
+				song: {}
 			}
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
 		},
-		computed: {
-			filteredSongs: function () {
-				return this.$eval('songs | filterBy searchQuery');
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		}),
+		...mapState("admin/songs", {
+			songs: state => state.songs
+		})
+	},
+	watch: {
+		"modals.editSong": val => {
+			if (!val) this.stopVideo();
+		}
+	},
+	methods: {
+		edit(song) {
+			this.editSong({ song, type: "songs" });
+			this.openModal({ sector: "admin", modal: "editSong" });
 		},
-		watch: {
-			'modals.editSong': function (value) {
-				if (!value) this.$broadcast('stopVideo');
-			}
+		remove(id) {
+			this.socket.emit("songs.remove", id, res => {
+				if (res.status === "success")
+					Toast.methods.addToast(res.message, 4000);
+				else Toast.methods.addToast(res.message, 8000);
+			});
 		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editSong = !this.modals.editSong;
-			},
-			edit: function (song, index) {
-				this.$broadcast('editSong', song, index, 'songs');
-			},
-			remove: function (id) {
-				this.socket.emit('songs.remove', id, res => {
-					if (res.status == 'success') Toast.methods.addToast(res.message, 4000);
-					else Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			getSet: function () {
-				let _this = this;
-				_this.socket.emit('songs.getSet', _this.position, data => {
-					data.forEach(song => {
-						_this.songs.push(song);
-					});
-					_this.position = _this.position + 1;
-					if (_this.maxPosition > _this.position - 1) _this.getSet();
+		getSet() {
+			this.socket.emit("songs.getSet", this.position, data => {
+				data.forEach(song => {
+					this.addSong(song);
 				});
-			},
-			init: function () {
-				let _this = this;
-				_this.socket.emit('songs.length', length => {
-					_this.maxPosition = Math.round(length / 15);
-					_this.getSet();
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'songs', () => {});
-			}
+				this.position += 1;
+				if (this.maxPosition > this.position - 1) this.getSet();
+			});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				if (_this.socket.connected) {
-					_this.init();
-					_this.socket.on('event:admin.song.added', song => {
-						_this.songs.push(song);
-					});
-					_this.socket.on('event:admin.song.removed', songId => {
-						_this.songs = _this.songs.filter(function(song) {
-							return song._id !== songId;
-						});
-					});
-					_this.socket.on('event:admin.song.updated', updatedSong => {
-						for (let i = 0; i < _this.songs.length; i++) {
-							let song = _this.songs[i];
-							if (song._id === updatedSong._id) {
-								_this.songs.$set(i, updatedSong);
-							}
-						}
-					});
-				}
-				io.onConnect(() => {
-					_this.init();
-				});
+		init() {
+			this.socket.emit("songs.length", length => {
+				this.maxPosition = Math.ceil(length / 15);
+				this.getSet();
+			});
+			this.socket.emit("apis.joinAdminRoom", "songs", () => {});
+		},
+		...mapActions("admin/songs", [
+			"stopVideo",
+			"editSong",
+			"addSong",
+			"removeSong",
+			"updateSong"
+		]),
+		...mapActions("modals", ["openModal", "closeModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.on("event:admin.song.added", song => {
+				this.addSong(song);
+			});
+			this.socket.on("event:admin.song.removed", songId => {
+				this.removeSong(songId);
+			});
+			this.socket.on("event:admin.song.updated", updatedSong => {
+				this.updateSong(updatedSong);
+			});
+
+			if (this.socket.connected) {
+				this.init();
+			}
+			io.onConnect(() => {
+				this.init();
+			});
+		});
+
+		if (this.$route.query.id) {
+			this.socket.emit("songs.getSong", this.$route.query.id, res => {
+				if (res.status === "success") {
+					this.edit(res.data);
+					this.closeModal({ sector: "admin", modal: "viewReport" });
+				} else
+					Toast.methods.addToast("Song with that ID not found", 3000);
 			});
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.song-thumbnail {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+.optionsColumn {
+	width: 100px;
+	button {
+		width: 35px;
+	}
+}
+
+.likesColumn,
+.dislikesColumn {
+	width: 40px;
+	i {
+		font-size: 20px;
 	}
+	.thumbLike {
+		color: $green !important;
+	}
+	.thumbDislike {
+		color: $red !important;
+	}
+}
+
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 338 - 187
frontend/components/Admin/Stations.vue

@@ -1,224 +1,375 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>ID</td>
-					<td>Name</td>
-					<td>Type</td>
-					<td>Display Name</td>
-					<td>Description</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, station) in stations' track-by='$index'>
-					<td>
-						<span>{{station._id}}</span>
-					</td>
-					<td>
-						<span>{{station.name}}</span>
-					</td>
-					<td>
-						<span>{{station.type}}</span>
-					</td>
-					<td>
-						<span>{{station.displayName}}</span>
-					</td>
-					<td>
-						<span>{{station.description}}</span>
-					</td>
-					<td>
-						<a class='button is-info' @click='editStation(station)'>Edit</a>
-						<a class='button is-danger' @click='removeStation(index)' href='#'>Remove</a>
-					</td>
-				</tr>
-			</tbody>
-		</table>
-	</div>
-	<div class='container'>
-		<div class='card is-fullwidth'>
-			<header class='card-header'>
-				<p class='card-header-title'>Create official station</p>
-			</header>
-			<div class='card-content'>
-				<div class='content'>
-					<div class='control is-horizontal'>
-						<div class='control is-grouped'>
-							<p class='control is-expanded'>
-								<input class='input' type='text' placeholder='Name' v-model='newStation.name'>
-							</p>
-							<p class='control is-expanded'>
-								<input class='input' type='text' placeholder='Display Name' v-model='newStation.displayName'>
-							</p>
-						</div>
-					</div>
-					<label class='label'>Description</label>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Short description' v-model='newStation.description'>
-					</p>
-					<div class="control is-grouped genre-wrapper">
-						<div class="sector">
-							<p class='control has-addons'>
-								<input class='input' id='new-genre' type='text' placeholder='Genre' v-on:keyup.enter='addGenre()'>
-								<a class='button is-info' href='#' @click='addGenre()'>Add genre</a>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in newStation.genres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeGenre(index)'></button>
+	<div>
+		<metadata title="Admin | Stations" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>ID</td>
+						<td>Name</td>
+						<td>Type</td>
+						<td>Display Name</td>
+						<td>Description</td>
+						<td>Owner</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(station, index) in stations" :key="index">
+						<td>
+							<span>{{ station._id }}</span>
+						</td>
+						<td>
+							<span>
+								<router-link
+									:to="{
+										name: 'station',
+										params: { id: station.name }
+									}"
+								>
+									{{ station.name }}
+								</router-link>
 							</span>
+						</td>
+						<td>
+							<span>{{ station.type }}</span>
+						</td>
+						<td>
+							<span>{{ station.displayName }}</span>
+						</td>
+						<td>
+							<span>{{ station.description }}</span>
+						</td>
+						<td>
+							<span
+								v-if="station.type === 'official'"
+								title="Musare"
+								>Musare</span
+							>
+							<user-id-to-username
+								v-else
+								:userId="station.owner"
+								:link="true"
+							/>
+						</td>
+						<td>
+							<a class="button is-info" v-on:click="edit(station)"
+								>Edit</a
+							>
+							<a
+								class="button is-danger"
+								href="#"
+								@click="removeStation(index)"
+								>Remove</a
+							>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<div class="container">
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
+						Create official station
+					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<div class="control is-horizontal">
+							<div class="control is-grouped">
+								<p class="control is-expanded">
+									<input
+										v-model="newStation.name"
+										class="input"
+										type="text"
+										placeholder="Name"
+									/>
+								</p>
+								<p class="control is-expanded">
+									<input
+										v-model="newStation.displayName"
+										class="input"
+										type="text"
+										placeholder="Display Name"
+									/>
+								</p>
+							</div>
 						</div>
-						<div class="sector">
-							<p class='control has-addons'>
-								<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre' v-on:keyup.enter='addBlacklistedGenre()'>
-								<a class='button is-info' href='#' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
-							</span>
+						<label class="label">Description</label>
+						<p class="control is-expanded">
+							<input
+								v-model="newStation.description"
+								class="input"
+								type="text"
+								placeholder="Short description"
+							/>
+						</p>
+						<div class="control is-grouped genre-wrapper">
+							<div class="sector">
+								<p class="control has-addons">
+									<input
+										id="new-genre"
+										class="input"
+										type="text"
+										placeholder="Genre"
+										@keyup.enter="addGenre()"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addGenre()"
+										>Add genre</a
+									>
+								</p>
+								<span
+									v-for="(genre, index) in newStation.genres"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ genre }}
+									<button
+										class="delete is-info"
+										@click="removeGenre(index)"
+									/>
+								</span>
+							</div>
+							<div class="sector">
+								<p class="control has-addons">
+									<input
+										id="new-blacklisted-genre"
+										class="input"
+										type="text"
+										placeholder="Blacklisted Genre"
+										@keyup.enter="addBlacklistedGenre()"
+									/>
+									<a
+										class="button is-info"
+										href="#"
+										@click="addBlacklistedGenre()"
+										>Add blacklisted genre</a
+									>
+								</p>
+								<span
+									v-for="(genre,
+									index) in newStation.blacklistedGenres"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ genre }}
+									<button
+										class="delete is-info"
+										@click="removeBlacklistedGenre(index)"
+									/>
+								</span>
+							</div>
 						</div>
 					</div>
 				</div>
+				<footer class="card-footer">
+					<a
+						class="card-footer-item"
+						href="#"
+						@click="createStation()"
+						>Create</a
+					>
+				</footer>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
-			</footer>
 		</div>
-	</div>
 
-	<edit-station v-show='modals.editStation'></edit-station>
+		<edit-station v-if="modals.editStation" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	import EditStation from '../Modals/EditStation.vue';
+import { Toast } from "vue-roaster";
+import io from "../../io";
 
-	export default {
-		components: { EditStation },
-		data() {
-			return {
-				stations: [],
-				newStation: {
-					genres: [],
-					blacklistedGenres: []
-				},
-				modals: { editStation: false }
+import EditStation from "./EditStation.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	components: { EditStation, UserIdToUsername },
+	data() {
+		return {
+			stations: [],
+			newStation: {
+				genres: [],
+				blacklistedGenres: []
 			}
-		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editStation	= !this.modals.editStation;
-			},
-			createStation: function () {
-				let _this = this;
-				let {newStation: {name, displayName, description, genres, blacklistedGenres}} = this;
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		})
+	},
+	methods: {
+		createStation() {
+			const {
+				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);
-				if (description == undefined) return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
+			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
+				);
+			if (description === undefined)
+				return Toast.methods.addToast(
+					"Field (Description) cannot be empty",
+					3000
+				);
 
-				_this.socket.emit('stations.create', {
+			return this.socket.emit(
+				"stations.create",
+				{
 					name,
-					type: 'official',
+					type: "official",
 					displayName,
 					description,
 					genres,
-					blacklistedGenres,
-				}, result => {
+					blacklistedGenres
+				},
+				result => {
 					Toast.methods.addToast(result.message, 3000);
-					if (result.status == 'success') this.newStation = {
-						genres: [],
-						blacklistedGenres: []
-					}
-				});
-			},
-			removeStation: function (index) {
-				this.socket.emit('stations.remove', this.stations[index]._id, res => {
+					if (result.status === "success")
+						this.newStation = {
+							genres: [],
+							blacklistedGenres: []
+						};
+				}
+			);
+		},
+		removeStation(index) {
+			this.socket.emit(
+				"stations.remove",
+				this.stations[index]._id,
+				res => {
 					Toast.methods.addToast(res.message, 3000);
-				});
-			},
-			editStation: function (station) {
-				this.$broadcast('editStation', {
-					_id: station._id,
-					name: station.name,
-					type: station.type,
-					partyMode: station.partyMode,
-					description: station.description,
-					privacy: station.privacy,
-					displayName: station.displayName
-				});
-			},
-			addGenre: function () {
-				let genre = $('#new-genre').val().toLowerCase().trim();
-				if (this.newStation.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-				if (genre) {
-					this.newStation.genres.push(genre);
-					$('#new-genre').val('');
 				}
-				else Toast.methods.addToast('Genre cannot be empty', 3000);
-			},
-			removeGenre: function (index) { this.newStation.genres.splice(index, 1); },
-			addBlacklistedGenre: function () {
-				let genre = $('#new-blacklisted-genre').val().toLowerCase().trim();
-				if (this.newStation.blacklistedGenres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
+			);
+		},
+		edit(station) {
+			this.editStation({
+				_id: station._id,
+				name: station.name,
+				type: station.type,
+				partyMode: station.partyMode,
+				description: station.description,
+				privacy: station.privacy,
+				displayName: station.displayName,
+				genres: station.genres,
+				blacklistedGenres: station.blacklistedGenres
+			});
+			this.openModal({
+				sector: "station",
+				modal: "editStation"
+			});
+		},
+		addGenre() {
+			const genre = document
+				.getElementById(`new-genre`)
+				.value.toLowerCase()
+				.trim();
+			if (this.newStation.genres.indexOf(genre) !== -1)
+				return Toast.methods.addToast("Genre already exists", 3000);
+			if (genre) {
+				this.newStation.genres.push(genre);
+				document.getElementById(`new-genre`).value = "";
+				return true;
+			}
+			return Toast.methods.addToast("Genre cannot be empty", 3000);
+		},
+		removeGenre(index) {
+			this.newStation.genres.splice(index, 1);
+		},
+		addBlacklistedGenre() {
+			const genre = document
+				.getElementById(`new-blacklisted-genre`)
+				.value.toLowerCase()
+				.trim();
+			if (this.newStation.blacklistedGenres.indexOf(genre) !== -1)
+				return Toast.methods.addToast("Genre already exists", 3000);
 
-				if (genre) {
-					this.newStation.blacklistedGenres.push(genre);
-					$('#new-blacklisted-genre').val('');
-				}
-				else Toast.methods.addToast('Genre cannot be empty', 3000);
-			},
-			removeBlacklistedGenre: function (index) { this.newStation.blacklistedGenres.splice(index, 1); },
-			init: function () {
-				let _this = this;
-				_this.socket.emit('stations.index', data => {
-					_this.stations = data.stations;
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'stations', data => {});
+			if (genre) {
+				this.newStation.blacklistedGenres.push(genre);
+				document.getElementById(`new-blacklisted-genre`).value = "";
+				return true;
 			}
+			return Toast.methods.addToast("Genre cannot be empty", 3000);
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				_this.socket.on('event:admin.station.added', station => {
-					_this.stations.push(station);
-				});
-				_this.socket.on('event:admin.station.removed', stationId => {
-					_this.stations = _this.stations.filter(station => {
-						return station._id !== stationId;
-					});
-				});
-				io.onConnect(() => {
-					_this.init();
+		removeBlacklistedGenre(index) {
+			this.newStation.blacklistedGenres.splice(index, 1);
+		},
+		init() {
+			this.socket.emit("stations.index", data => {
+				this.stations = data.stations;
+			});
+			this.socket.emit("apis.joinAdminRoom", "stations", () => {});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("admin/stations", ["editStation"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			this.socket.on("event:admin.station.added", station => {
+				this.stations.push(station);
+			});
+			this.socket.on("event:admin.station.removed", stationId => {
+				this.stations = this.stations.filter(station => {
+					return station._id !== stationId;
 				});
 			});
-		}
+			io.onConnect(() => {
+				this.init();
+			});
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag {
-		margin-top: 5px;
-		&:not(:last-child) {
-			margin-right: 5px;
-		}
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	td {
-		word-wrap: break-word;
-		max-width: 10vw;
-		vertical-align: middle;
+.tag {
+	margin-top: 5px;
+	&:not(:last-child) {
+		margin-right: 5px;
 	}
+}
 
-	.is-info:focus { background-color: #0398db; }
+td {
+	word-wrap: break-word;
+	max-width: 10vw;
+	vertical-align: middle;
+}
 
-	.genre-wrapper {
-		display: flex;
-    	justify-content: space-around;
-	}
+.is-info:focus {
+	background-color: $primary-color;
+}
+
+.genre-wrapper {
+	display: flex;
+	justify-content: space-around;
+}
+
+.card-footer-item {
+	color: $primary-color;
+}
 </style>

+ 274 - 224
frontend/components/Admin/Statistics.vue

@@ -1,18 +1,21 @@
 <template>
-	<div class='container'>
+	<div class="container">
+		<metadata title="Admin | Statistics" />
 		<div class="columns">
-			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
-				<header class='card-header'>
-					<p class='card-header-title'>
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<header class="card-header">
+					<p class="card-header-title">
 						Average Logs
 					</p>
 				</header>
-				<div class='card-content'>
-					<div class='content'>
+				<div class="card-content">
+					<div class="content">
 						<table class="table">
 							<thead>
 								<tr>
-									<th> </th>
+									<th />
 									<th>Success</th>
 									<th>Error</th>
 									<th>Info</th>
@@ -21,27 +24,51 @@
 							<tbody>
 								<tr>
 									<th><strong>Average per second</strong></th>
-									<th v-bind:title="logs.second.success">{{round(logs.second.success)}}</th>
-									<th v-bind:title="logs.second.error">{{round(logs.second.error)}}</th>
-									<th v-bind:title="logs.second.info">{{round(logs.second.info)}}</th>
+									<th :title="logs.second.success">
+										{{ round(logs.second.success) }}
+									</th>
+									<th :title="logs.second.error">
+										{{ round(logs.second.error) }}
+									</th>
+									<th :title="logs.second.info">
+										{{ round(logs.second.info) }}
+									</th>
 								</tr>
 								<tr>
 									<th><strong>Average per minute</strong></th>
-									<th v-bind:title="logs.minute.success">{{round(logs.minute.success)}}</th>
-									<th v-bind:title="logs.minute.error">{{round(logs.minute.error)}}</th>
-									<th v-bind:title="logs.minute.info">{{round(logs.minute.info)}}</th>
+									<th :title="logs.minute.success">
+										{{ round(logs.minute.success) }}
+									</th>
+									<th :title="logs.minute.error">
+										{{ round(logs.minute.error) }}
+									</th>
+									<th :title="logs.minute.info">
+										{{ round(logs.minute.info) }}
+									</th>
 								</tr>
 								<tr>
 									<th><strong>Average per hour</strong></th>
-									<th v-bind:title="logs.hour.success">{{round(logs.hour.success)}}</th>
-									<th v-bind:title="logs.hour.error">{{round(logs.hour.error)}}</th>
-									<th v-bind:title="logs.hour.info">{{round(logs.hour.info)}}</th>
+									<th :title="logs.hour.success">
+										{{ round(logs.hour.success) }}
+									</th>
+									<th :title="logs.hour.error">
+										{{ round(logs.hour.error) }}
+									</th>
+									<th :title="logs.hour.info">
+										{{ round(logs.hour.info) }}
+									</th>
 								</tr>
 								<tr>
 									<th><strong>Average per day</strong></th>
-									<th v-bind:title="logs.day.success">{{round(logs.day.success)}}</th>
-									<th v-bind:title="logs.day.error">{{round(logs.day.error)}}</th>
-									<th v-bind:title="logs.day.info">{{round(logs.day.info)}}</th>
+									<th :title="logs.day.success">
+										{{ round(logs.day.success) }}
+									</th>
+									<th :title="logs.day.error">
+										{{ round(logs.day.error) }}
+									</th>
+									<th :title="logs.day.info">
+										{{ round(logs.day.info) }}
+									</th>
 								</tr>
 							</tbody>
 						</table>
@@ -49,22 +76,26 @@
 				</div>
 			</div>
 		</div>
-		<br>
+		<br />
 		<div class="columns">
-			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
-				<div class='card-content'>
-					<div class='content'>
-						<canvas id="minuteChart" height="400"></canvas>
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<div class="card-content">
+					<div class="content">
+						<canvas id="minuteChart" height="400" />
 					</div>
 				</div>
 			</div>
 		</div>
-		<br>
+		<br />
 		<div class="columns">
-			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
-				<div class='card-content'>
-					<div class='content'>
-						<canvas id="hourChart" height="400"></canvas>
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<div class="card-content">
+					<div class="content">
+						<canvas id="hourChart" height="400" />
 					</div>
 				</div>
 			</div>
@@ -73,228 +104,247 @@
 </template>
 
 <script>
-	import EditUser from '../Modals/EditUser.vue';
-	import io from '../../io';
-	import Chart from 'chart.js'
+import { Line } from "chart.js";
+import "chartjs-adapter-date-fns";
 
-	export default {
-		components: {},
-		data() {
-			return {
-				successUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
-				errorUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
-				infoUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
-				successUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
-				errorUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
-				infoUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
-				minuteChart: null,
-				hourChart: null,
-				logs: {
-					second: {
-						success: 0,
-						error: 0,
-						info: 0
+import io from "../../io";
+
+export default {
+	components: {},
+	data() {
+		return {
+			successUnitsPerMinute: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			errorUnitsPerMinute: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			infoUnitsPerMinute: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			successUnitsPerHour: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			errorUnitsPerHour: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			infoUnitsPerHour: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			minuteChart: null,
+			hourChart: null,
+			logs: {
+				second: {
+					success: 0,
+					error: 0,
+					info: 0
+				},
+				minute: {
+					success: 0,
+					error: 0,
+					info: 0
+				},
+				hour: {
+					success: 0,
+					error: 0,
+					info: 0
+				},
+				day: {
+					success: 0,
+					error: 0,
+					info: 0
+				}
+			}
+		};
+	},
+	mounted() {
+		const minuteCtx = document.getElementById("minuteChart");
+		const hourCtx = document.getElementById("hourChart");
+
+		this.minuteChart = new Line(minuteCtx, {
+			data: {
+				labels: [
+					"-10",
+					"-9",
+					"-8",
+					"-7",
+					"-6",
+					"-5",
+					"-4",
+					"-3",
+					"-2",
+					"-1"
+				],
+				datasets: [
+					{
+						label: "Success",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(75, 192, 192, 0.2)"],
+						borderColor: ["rgba(75, 192, 192, 1)"],
+						borderWidth: 1
 					},
-					minute: {
-						success: 0,
-						error: 0,
-						info: 0
+					{
+						label: "Error",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(255, 99, 132, 0.2)"],
+						borderColor: ["rgba(255,99,132,1)"],
+						borderWidth: 1
 					},
-					hour: {
-						success: 0,
-						error: 0,
-						info: 0
+					{
+						label: "Info",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(54, 162, 235, 0.2)"],
+						borderColor: ["rgba(54, 162, 235, 1)"],
+						borderWidth: 1
+					}
+				]
+			},
+			options: {
+				title: {
+					display: true,
+					text: "Logs per minute"
+				},
+				scales: {
+					yAxes: [
+						{
+							ticks: {
+								beginAtZero: true,
+								stepSize: 1
+							}
+						}
+					]
+				},
+				responsive: true,
+				maintainAspectRatio: false
+			}
+		});
+
+		this.hourChart = new Line(hourCtx, {
+			data: {
+				labels: [
+					"-10",
+					"-9",
+					"-8",
+					"-7",
+					"-6",
+					"-5",
+					"-4",
+					"-3",
+					"-2",
+					"-1"
+				],
+				datasets: [
+					{
+						label: "Success",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(75, 192, 192, 0.2)"],
+						borderColor: ["rgba(75, 192, 192, 1)"],
+						borderWidth: 1
+					},
+					{
+						label: "Error",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(255, 99, 132, 0.2)"],
+						borderColor: ["rgba(255,99,132,1)"],
+						borderWidth: 1
 					},
-					day: {
-						success: 0,
-						error: 0,
-						info: 0
+					{
+						label: "Info",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(54, 162, 235, 0.2)"],
+						borderColor: ["rgba(54, 162, 235, 1)"],
+						borderWidth: 1
 					}
-				}
+				]
+			},
+			options: {
+				title: {
+					display: true,
+					text: "Logs per hour"
+				},
+				scales: {
+					yAxes: [
+						{
+							ticks: {
+								beginAtZero: true,
+								stepSize: 1
+							}
+						}
+					]
+				},
+				responsive: true,
+				maintainAspectRatio: false
 			}
-		},
-		methods: {
-			init: function () {
-				this.socket.emit('apis.joinAdminRoom', 'statistics', () => {});
-				this.socket.on('event:admin.statistics.success.units.minute', units => {
+		});
+
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+		});
+	},
+	methods: {
+		init() {
+			this.socket.emit("apis.joinAdminRoom", "statistics", () => {});
+			this.socket.on(
+				"event:admin.statistics.success.units.minute",
+				units => {
 					this.successUnitsPerMinute = units;
 					this.minuteChart.data.datasets[0].data = units;
 					this.minuteChart.update();
-				});
-				this.socket.on('event:admin.statistics.error.units.minute', units => {
+				}
+			);
+			this.socket.on(
+				"event:admin.statistics.error.units.minute",
+				units => {
 					this.errorUnitsPerMinute = units;
 					this.minuteChart.data.datasets[1].data = units;
 					this.minuteChart.update();
-				});
-				this.socket.on('event:admin.statistics.info.units.minute', units => {
+				}
+			);
+			this.socket.on(
+				"event:admin.statistics.info.units.minute",
+				units => {
 					this.infoUnitsPerMinute = units;
 					this.minuteChart.data.datasets[2].data = units;
 					this.minuteChart.update();
-				});
-				this.socket.on('event:admin.statistics.success.units.hour', units => {
+				}
+			);
+			this.socket.on(
+				"event:admin.statistics.success.units.hour",
+				units => {
 					this.successUnitsPerHour = units;
 					this.hourChart.data.datasets[0].data = units;
 					this.hourChart.update();
-				});
-				this.socket.on('event:admin.statistics.error.units.hour', units => {
-					this.errorUnitsPerHour = units;
-					this.hourChart.data.datasets[1].data = units;
-					this.hourChart.update();
-				});
-				this.socket.on('event:admin.statistics.info.units.hour', units => {
-					this.infoUnitsPerHour = units;
-					this.hourChart.data.datasets[2].data = units;
-					this.hourChart.update();
-				});
-				this.socket.on('event:admin.statistics.logs', logs => {
-					this.logs = logs;
-				});
-			},
-			round: function(number) {
-				return Math.round(number);
-			}
-		},
-		ready: function () {
-			let _this = this;
-			var minuteCtx = document.getElementById("minuteChart");
-			var hourCtx = document.getElementById("hourChart");
-
-			_this.minuteChart = new Chart(minuteCtx, {
-				type: 'line',
-				data: {
-					labels: ["-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1"],
-					datasets: [
-						{
-							label: 'Success',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(75, 192, 192, 0.2)'
-							],
-							borderColor: [
-								'rgba(75, 192, 192, 1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Error',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(255, 99, 132, 0.2)'
-							],
-							borderColor: [
-								'rgba(255,99,132,1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Info',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(54, 162, 235, 0.2)'
-							],
-							borderColor: [
-								'rgba(54, 162, 235, 1)'
-							],
-							borderWidth: 1
-						}
-					]
-				},
-				options: {
-					title: {
-						display: true,
-						text: 'Logs per minute'
-					},
-					scales: {
-						yAxes: [{
-							ticks: {
-								beginAtZero: true,
-								stepSize: 1
-							}
-						}]
-					},
-					responsive: true,
-					maintainAspectRatio: false
 				}
+			);
+			this.socket.on("event:admin.statistics.error.units.hour", units => {
+				this.errorUnitsPerHour = units;
+				this.hourChart.data.datasets[1].data = units;
+				this.hourChart.update();
 			});
-
-			_this.hourChart = new Chart(hourCtx, {
-				type: 'line',
-				data: {
-					labels: ["-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1"],
-					datasets: [
-						{
-							label: 'Success',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(75, 192, 192, 0.2)'
-							],
-							borderColor: [
-								'rgba(75, 192, 192, 1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Error',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(255, 99, 132, 0.2)'
-							],
-							borderColor: [
-								'rgba(255,99,132,1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Info',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(54, 162, 235, 0.2)'
-							],
-							borderColor: [
-								'rgba(54, 162, 235, 1)'
-							],
-							borderWidth: 1
-						}
-					]
-				},
-				options: {
-					title: {
-						display: true,
-						text: 'Logs per hour'
-					},
-					scales: {
-						yAxes: [{
-							ticks: {
-								beginAtZero: true,
-								stepSize: 1
-							}
-						}]
-					},
-					responsive: true,
-					maintainAspectRatio: false
-				}
+			this.socket.on("event:admin.statistics.info.units.hour", units => {
+				this.infoUnitsPerHour = units;
+				this.hourChart.data.datasets[2].data = units;
+				this.hourChart.update();
 			});
-
-
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => _this.init());
+			this.socket.on("event:admin.statistics.logs", logs => {
+				this.logs = logs;
 			});
+		},
+		round(number) {
+			return Math.round(number);
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.user-avatar {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
-	}
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+.user-avatar {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 113 - 85
frontend/components/Admin/Users.vue

@@ -1,100 +1,128 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-			<tr>
-				<td>Profile Picture</td>
-				<td>User ID</td>
-				<td>GitHub ID</td>
-				<td>Password</td>
-				<td>Username</td>
-				<td>Role</td>
-				<td>Email Address</td>
-				<td>Email Verified</td>
-				<td>Likes</td>
-				<td>Dislikes</td>
-				<td>Songs Requested</td>
-				<td>Options</td>
-			</tr>
-			</thead>
-			<tbody>
-			<tr v-for='(index, user) in users' track-by='$index'>
-				<td>
-					<img class='user-avatar' src='/assets/notes-transparent.png'>
-				</td>
-				<td>{{ user._id }}</td>
-				<td v-if='user.services.github'>{{ user.services.github.id }}</td>
-				<td v-else>Not Linked</td>
-				<td v-if='user.hasPassword'>Yes</td>
-				<td v-else>Not Linked</td>
-				<td>{{ user.username }}</td>
-				<td>{{ user.role }}</td>
-				<td>{{ user.email.address }}</td>
-				<td>{{ user.email.verified }}</td>
-				<td>{{ user.liked.length }}</td>
-				<td>{{ user.disliked.length }}</td>
-				<td>{{ user.songsRequested }}</td>
-				<td>
-					<button class='button is-primary' @click='edit(user)'>Edit</button>
-				</td>
-			</tr>
-			</tbody>
-		</table>
+	<div>
+		<metadata title="Admin | Users" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Profile Picture</td>
+						<td>User ID</td>
+						<td>GitHub ID</td>
+						<td>Password</td>
+						<td>Username</td>
+						<td>Role</td>
+						<td>Email Address</td>
+						<td>Email Verified</td>
+						<td>Likes</td>
+						<td>Dislikes</td>
+						<td>Songs Requested</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(user, index) in users" :key="index">
+						<td>
+							<img
+								class="user-avatar"
+								src="/assets/notes-transparent.png"
+							/>
+						</td>
+						<td>{{ user._id }}</td>
+						<td v-if="user.services.github">
+							{{ user.services.github.id }}
+						</td>
+						<td v-else>
+							Not Linked
+						</td>
+						<td v-if="user.hasPassword">
+							Yes
+						</td>
+						<td v-else>
+							Not Linked
+						</td>
+						<td>{{ user.username }}</td>
+						<td>{{ user.role }}</td>
+						<td>{{ user.email.address }}</td>
+						<td>{{ user.email.verified }}</td>
+						<td>{{ user.liked.length }}</td>
+						<td>{{ user.disliked.length }}</td>
+						<td>{{ user.songsRequested }}</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="edit(user)"
+							>
+								Edit
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-user v-if="modals.editUser" />
 	</div>
-	<edit-user v-show='modals.editUser'></edit-user>
 </template>
 
 <script>
-	import EditUser from '../Modals/EditUser.vue';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { EditUser },
-		data() {
-			return {
-				users: [],
-				modals: { editUser: false }
-			}
-		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editUser = !this.modals.editUser;
-			},
-			edit: function (user) {
-				this.$broadcast('editUser', user);
-			},
-			init: function () {
-				let _this = this;
-				_this.socket.emit('users.index', result => {
-					if (result.status === 'success') _this.users = result.data;
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'users', () => {});
-				_this.socket.on('event:user.username.changed', username => {
-					_this.$parent.$parent.username = username;
-				});
-			}
+import EditUser from "../Modals/EditUser.vue";
+import io from "../../io";
+
+export default {
+	components: { EditUser },
+	data() {
+		return {
+			users: []
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		edit(user) {
+			this.editUser(user);
+			this.openModal({ sector: "admin", modal: "editUser" });
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => _this.init());
+		init() {
+			this.socket.emit("users.index", result => {
+				if (result.status === "success") this.users = result.data;
 			});
-		}
+			this.socket.emit("apis.joinAdminRoom", "users", () => {});
+		},
+		...mapActions("admin/users", ["editUser"]),
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		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; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.user-avatar {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
-	}
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+.user-avatar {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 142 - 22
frontend/components/MainFooter.vue

@@ -1,37 +1,157 @@
 <template>
-	<footer class='footer'>
-		<div class='container'>
-			<div class='content has-text-centered'>
-				<p>
-					© Copyright Musare 2015 - 2017
-				</p>
-				<p>
-					<a class='icon' href='https://github.com/Musare/MusareNode' title='GitHub Repository'>
-						<img src='/assets/social/github.svg'/>
+	<footer class="footer">
+		<div class="container">
+			<div class="content has-text-centered">
+				<p class="socialIcons">
+					<a
+						class="icon"
+						:href="`${this.socialLinks.github}`"
+						target="_blank"
+						title="GitHub Repository"
+					>
+						<img src="/assets/social/github.svg" />
 					</a>
-					<a class='icon' href='https://twitter.com/MusareApp' title='Twitter Account'>
-						<img src='/assets/social/twitter.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.twitter}`"
+						target="_blank"
+						title="Twitter Account"
+					>
+						<img src="/assets/social/twitter.svg" />
 					</a>
-					<a class='icon' href='https://www.facebook.com/MusareMusic/' title='Facebook Page'>
-						<img src='/assets/social/facebook.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.facebook}`"
+						target="_blank"
+						title="Facebook Page"
+					>
+						<img src="/assets/social/facebook.svg" />
 					</a>
-					<a class='icon' href='https://discord.gg/Y5NxYGP' title='Discord Server'>
-						<img src='/assets/social/discord.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.discord}`"
+						target="_blank"
+						title="Discord Server"
+					>
+						<img src="/assets/social/discord.svg" />
 					</a>
 				</p>
+				<a href="/"
+					><img
+						class="musareFooterLogo"
+						src="/assets/wordmark.png"
+						alt="Musare"
+				/></a>
+				<p class="footerLinks">
+					<router-link title="About Musare" to="/about">
+						About
+					</router-link>
+					<router-link title="The Musare Team" to="/team">
+						Team
+					</router-link>
+					<router-link title="News" to="/news">
+						News
+					</router-link>
+				</p>
+				<p>
+					© Copyright Musare 2015 - 2019
+				</p>
 			</div>
 		</div>
 	</footer>
 </template>
 
-<style lang='scss' scoped>
-	.content a:not(.button) { border: 0; }
+<script>
+export default {
+	data() {
+		return {
+			socialLinks: {
+				github: "",
+				twitter: "",
+				facebook: "",
+				discord: ""
+			}
+		};
+	},
+	mounted() {
+		lofig.get("siteSettings.socialLinks", res => {
+			this.socialLinks = res;
+		});
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.content {
-		display: flex;
-		align-items: center;
-		flex-direction: column;
+.content a:not(.button) {
+	border: 0;
+}
+
+.content {
+	display: flex;
+	align-items: center;
+	flex-direction: column;
+}
+
+.footer {
+	position: absolute;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	height: 240px;
+	padding: 40px 20px 40px;
+	border-radius: 33% 33% 0% 0% / 7% 7% 0% 0%;
+	box-shadow: 0 4px 8px 0 rgba(3, 169, 244, 0.65),
+		0 6px 20px 0 rgba(3, 169, 244, 0.4);
+	background-color: $white;
+	width: 100%;
+
+	.musareFooterLogo {
+		display: block;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 15px;
+		width: 200px;
+	}
+
+	.socialIcons {
+		.icon {
+			height: 28px;
+			line-height: 28px;
+			width: 28px;
+		}
+	}
+
+	.footerLinks {
+		:not(:last-child) {
+			border-right: solid 1px $primary-color;
+		}
+		a {
+			padding: 0 5px;
+			font-size: 18px;
+			color: $primary-color;
+		}
+		a:hover {
+			color: $primary-color;
+			text-decoration: underline;
+		}
 	}
+}
 
-	.icon:visited { color: #4a4a4a !important; }
+@media only screen and (min-width: 992px) {
+	.footer {
+		height: 180px;
+		.socialIcons {
+			left: 0;
+			top: 35px;
+			position: absolute;
+		}
+		.footerLinks {
+			right: 0;
+			top: 35px;
+			position: absolute;
+		}
+	}
+}
 </style>

+ 134 - 79
frontend/components/MainHeader.vue

@@ -1,117 +1,172 @@
 <template>
 	<nav class="nav is-info">
 		<div class="nav-left">
-			<a class="nav-item is-brand" href="#" v-link="{ path: '/' }">
-				Musare
-			</a>
+			<router-link class="nav-item is-brand" to="/">
+				<img
+					:src="`${this.siteSettings.logo}`"
+					:alt="`${this.siteSettings.siteName}` || `Musare`"
+				/>
+			</router-link>
 		</div>
 
-		<span class="nav-toggle" :class="{ 'is-active': isMobile }" @click="isMobile = !isMobile">
-			<span></span>
-			<span></span>
-			<span></span>
+		<span
+			class="nav-toggle"
+			:class="{ 'is-active': isMobile }"
+			@click="isMobile = !isMobile"
+		>
+			<span />
+			<span />
+			<span />
 		</span>
 
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
-			<a class="nav-item is-tab admin" href="#" v-link="{ path: '/admin' }" v-if="$parent.$parent.role === 'admin'">
+			<router-link
+				v-if="role === 'admin'"
+				class="nav-item is-tab admin"
+				to="/admin"
+			>
 				<strong>Admin</strong>
-			</a>
-			<!--a class="nav-item is-tab" href="#">
-				About
-			</a-->
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/team' }">
-				Team
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/about' }">
-				About
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
-				News
-			</a>
-			<span class="grouped" v-if="$parent.$parent.loggedIn">
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/u/' + $parent.$parent.username }">
+			</router-link>
+			<span v-if="loggedIn" class="grouped">
+				<router-link
+					class="nav-item is-tab"
+					:to="{
+						name: 'profile',
+						params: { username }
+					}"
+				>
 					Profile
-				</a>
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/settings' }">
-					Settings
-				</a>
-				<a class="nav-item is-tab" href="#" @click="$parent.$parent.logout()">
-					Logout
-				</a>
+				</router-link>
+				<router-link class="nav-item is-tab" to="/settings"
+					>Settings</router-link
+				>
+				<a class="nav-item is-tab" href="#" @click="logout()">Logout</a>
 			</span>
-			<span class="grouped" v-else>
-				<a class="nav-item" href="#" @click="toggleModal('login')">
-					Login
-				</a>
-				<a class="nav-item" href="#" @click="toggleModal('register')">
-					Register
-				</a>
+			<span v-else class="grouped">
+				<a
+					class="nav-item"
+					href="#"
+					@click="
+						openModal({
+							sector: 'header',
+							modal: 'login'
+						})
+					"
+					>Login</a
+				>
+				<a
+					class="nav-item"
+					href="#"
+					@click="
+						openModal({
+							sector: 'header',
+							modal: 'register'
+						})
+					"
+					>Register</a
+				>
 			</span>
 		</div>
 	</nav>
 </template>
 
 <script>
-	export default {
-		data() {
-			return {
-				isMobile: false
-			}
-		},
-		methods: {
-			toggleModal: function (type) {
-				this.$dispatch('toggleModal', type);
+import { mapState, mapActions } from "vuex";
+
+export default {
+	data() {
+		return {
+			isMobile: false,
+			frontendDomain: "",
+			siteSettings: {
+				logo: "",
+				siteName: ""
 			}
-		}
+		};
+	},
+	mounted() {
+		lofig.get("frontendDomain", res => {
+			this.frontendDomain = res;
+			return res;
+		});
+		lofig.get("siteSettings", res => {
+			this.siteSettings = res;
+			return res;
+		});
+	},
+	computed: mapState({
+		modals: state => state.modals.modals.header,
+		role: state => state.user.auth.role,
+		loggedIn: state => state.user.auth.loggedIn,
+		username: state => state.user.auth.username
+	}),
+	methods: {
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/auth", ["logout"])
 	}
+};
 </script>
 
 <style lang="scss" scoped>
-	@import 'theme.scss';
+@import "styles/global.scss";
 
-	.nav {
-		background-color: #03a9f4;
-		height: 64px;
+.nav {
+	background-color: $primary-color;
+	height: 64px;
+	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 
-		.nav-menu.is-active {
-			.nav-item {
-				color: #333;
+	.nav-menu.is-active {
+		.nav-item {
+			color: $dark-grey-2;
 
-				&:hover {
-					color: #333;
-				}
+			&:hover {
+				color: $dark-grey-2;
 			}
 		}
+	}
 
-		.nav-toggle {
-			height: 64px;
+	a.nav-item.is-tab:hover {
+		border-bottom: none;
+		border-top: solid 1px $white;
+		padding-top: 9px;
+	}
 
-			&.is-active span {
-				background-color: #333;
-			}
-		}
+	.nav-toggle {
+		height: 64px;
 
-		.is-brand {
-			font-size: 2.1rem !important;
-			line-height: 64px !important;
-			padding: 0 20px;
+		&.is-active span {
+			background-color: $dark-grey-2;
 		}
+	}
 
-		.nav-item {
-			font-size: 15px;
-			color: $white;
+	.is-brand {
+		font-size: 2.1rem !important;
+		line-height: 38px !important;
+		padding: 0 20px;
+		color: $white;
+		font-family: Pacifico, cursive;
+		filter: brightness(0) invert(1);
 
-			&:hover {
-				color: $white;
-			}
+		img {
+			max-height: 38px;
 		}
-		.admin {
-			color: #424242;
+	}
+
+	.nav-item {
+		font-size: 17px;
+		color: $white;
+
+		&:hover {
+			color: $white;
 		}
 	}
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
+	.admin strong {
+		color: #9d42b1;
 	}
+}
+.grouped {
+	margin: 0;
+	display: flex;
+	text-decoration: none;
+}
 </style>

+ 150 - 0
frontend/components/Modals/AddSongToPlaylist.vue

@@ -0,0 +1,150 @@
+<template>
+	<modal title="Add Song To Playlist">
+		<template v-slot:body>
+			<h4 class="songTitle">
+				{{ currentSong.title }}
+			</h4>
+			<h5 class="songArtist">
+				{{ currentSong.artists }}
+			</h5>
+			<aside class="menu">
+				<p class="menu-label">
+					Playlists
+				</p>
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlistsArr" :key="index">
+						<div class="playlist">
+							<span
+								v-if="playlists[playlist._id].hasSong"
+								class="icon is-small"
+								@click="removeSongFromPlaylist(playlist._id)"
+							>
+								<i class="material-icons">playlist_add_check</i>
+							</span>
+							<span
+								v-else
+								class="icon"
+								@click="addSongToPlaylist(playlist._id)"
+							>
+								<i class="material-icons">playlist_add</i>
+							</span>
+							{{ playlist.displayName }}
+						</div>
+					</li>
+				</ul>
+			</aside>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+import { Toast } from "vue-roaster";
+import Modal from "./Modal.vue";
+import io from "../../io";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			playlists: {},
+			playlistsArr: [],
+			songId: null,
+			song: null
+		};
+	},
+	mounted() {
+		this.songId = this.currentSong.songId;
+		this.song = this.currentSong;
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") {
+					res.data.forEach(playlist => {
+						this.playlists[playlist._id] = playlist;
+					});
+					this.recalculatePlaylists();
+				}
+			});
+		});
+	},
+	computed: {
+		...mapState("station", {
+			currentSong: state => state.currentSong
+		})
+	},
+	methods: {
+		addSongToPlaylist(playlistId) {
+			this.socket.emit(
+				"playlists.addSongToPlaylist",
+				this.currentSong.songId,
+				playlistId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === "success") {
+						this.playlists[playlistId].songs.push(this.song);
+					}
+					this.recalculatePlaylists();
+				}
+			);
+		},
+		removeSongFromPlaylist(playlistId) {
+			this.socket.emit(
+				"playlists.removeSongFromPlaylist",
+				this.songId,
+				playlistId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === "success") {
+						this.playlists[playlistId].songs.forEach(
+							(song, index) => {
+								if (song.songId === this.songId)
+									this.playlists[playlistId].songs.splice(
+										index,
+										1
+									);
+							}
+						);
+					}
+					this.recalculatePlaylists();
+				}
+			);
+		},
+		recalculatePlaylists() {
+			this.playlistsArr = Object.values(this.playlists).map(playlist => {
+				let hasSong = false;
+				for (let i = 0; i < playlist.songs.length; i += 1) {
+					if (playlist.songs[i].songId === this.songId) {
+						hasSong = true;
+					}
+				}
+
+				playlist.hasSong = hasSong; // eslint-disable-line no-param-reassign
+				this.playlists[playlist._id] = playlist;
+				return playlist;
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.icon.is-small {
+	margin-right: 10px !important;
+}
+.songTitle {
+	font-size: 22px;
+	padding: 0 10px;
+}
+.songArtist {
+	font-size: 19px;
+	font-weight: 200;
+	padding: 0 10px;
+}
+.menu-label {
+	font-size: 16px;
+}
+</style>

+ 189 - 110
frontend/components/Modals/AddSongToQueue.vue

@@ -1,13 +1,30 @@
 <template>
-	<modal title='Add Song To Queue'>
-		<div slot='body'>
-			<aside class='menu' v-if='$parent.$parent.loggedIn && $parent.type === "community"'>
-				<ul class='menu-list'>
-					<li v-for='playlist in playlists' track-by='$index'>
-						<a href='#' target='_blank' @click='$parent.editPlaylist(playlist._id)'>{{ playlist.displayName }}</a>
-						<div class='controls'>
-							<a href='#' @click='selectPlaylist(playlist._id)' v-if="!isPlaylistSelected(playlist._id)"><i class='material-icons'>panorama_fish_eye</i></a>
-							<a href='#' @click='unSelectPlaylist()' v-if="isPlaylistSelected(playlist._id)"><i class='material-icons'>lens</i></a>
+	<modal title="Add Song To Queue">
+		<div slot="body">
+			<aside class="menu" v-if="loggedIn && station.type === 'community'">
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlists" :key="index">
+						<a
+							href="#"
+							target="_blank"
+							v-on:click="editPlaylist(playlist._id)"
+							>{{ playlist.displayName }}</a
+						>
+						<div class="controls">
+							<a
+								href="#"
+								v-on:click="selectPlaylist(playlist._id)"
+								v-if="!isPlaylistSelected(playlist._id)"
+							>
+								<i class="material-icons">panorama_fish_eye</i>
+							</a>
+							<a
+								href="#"
+								v-on:click="unSelectPlaylist()"
+								v-if="isPlaylistSelected(playlist._id)"
+							>
+								<i class="material-icons">lens</i>
+							</a>
 						</div>
 					</li>
 				</ul>
@@ -15,27 +32,59 @@
 			</aside>
 			<div class="control is-grouped">
 				<p class="control is-expanded">
-					<input class="input" type="text" placeholder="YouTube Query" v-model='querySearch' autofocus @keyup.enter='submitQuery()'>
+					<input
+						class="input"
+						type="text"
+						placeholder="YouTube Query"
+						v-model="querySearch"
+						autofocus
+						@keyup.enter="submitQuery()"
+					/>
 				</p>
 				<p class="control">
-					<a class="button is-info" @click="submitQuery()" href='#'>
-						Search
-					</a>
+					<a
+						class="button is-info"
+						v-on:click="submitQuery()"
+						href="#"
+						>Search</a
+					>
 				</p>
 			</div>
-			<table class="table">
+			<div class="control is-grouped" v-if="station.type === 'official'">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="YouTube Playlist URL"
+						v-model="importQuery"
+						@keyup.enter="importPlaylist()"
+					/>
+				</p>
+				<p class="control">
+					<a
+						class="button is-info"
+						v-on:click="importPlaylist()"
+						href="#"
+						>Import</a
+					>
+				</p>
+			</div>
+			<table class="table" v-if="queryResults.length > 0">
 				<tbody>
-				<tr v-for="result in queryResults">
-					<td>
-						<img :src="result.thumbnail" />
-					</td>
-					<td>{{ result.title }}</td>
-					<td>
-						<a class="button is-success" @click="addSongToQueue(result.id)" href='#'>
-							Add
-						</a>
-					</td>
-				</tr>
+					<tr v-for="(result, index) in queryResults" :key="index">
+						<td>
+							<img :src="result.thumbnail" />
+						</td>
+						<td>{{ result.title }}</td>
+						<td>
+							<a
+								class="button is-success"
+								v-on:click="addSongToQueue(result.id)"
+								href="#"
+								>Add</a
+							>
+						</td>
+					</tr>
 				</tbody>
 			</table>
 		</div>
@@ -43,103 +92,133 @@
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import auth from '../../auth';
+import { mapState, mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+import Modal from "./Modal.vue";
+import io from "../../io";
 
-	export default {
-		data() {
-			return {
-				querySearch: '',
-				queryResults: [],
-				playlists: [],
-				privatePlaylistQueueSelected: null
+export default {
+	data() {
+		return {
+			querySearch: "",
+			queryResults: [],
+			playlists: [],
+			privatePlaylistQueueSelected: null,
+			importQuery: ""
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		station: state => state.station.station
+	}),
+	methods: {
+		isPlaylistSelected(playlistId) {
+			return this.privatePlaylistQueueSelected === playlistId;
+		},
+		selectPlaylist(playlistId) {
+			if (this.station.type === "community") {
+				this.privatePlaylistQueueSelected = playlistId;
+				this.$parent.privatePlaylistQueueSelected = playlistId;
+				this.$parent.addFirstPrivatePlaylistSongToQueue();
 			}
 		},
-		methods: {
-			isPlaylistSelected: function(playlistId) {
-				return this.privatePlaylistQueueSelected === playlistId;
-			},
-			selectPlaylist: function (playlistId) {
-				let _this = this;
-				if (_this.$parent.type === 'community') {
-					_this.privatePlaylistQueueSelected = playlistId;
-					_this.$parent.privatePlaylistQueueSelected = playlistId;
-					_this.$parent.addFirstPrivatePlaylistSongToQueue();
-				}
-			},
-			unSelectPlaylist: function () {
-				let _this = this;
-				if (_this.$parent.type === 'community') {
-					_this.privatePlaylistQueueSelected = null;
-					_this.$parent.privatePlaylistQueueSelected = null;
-				}
-			},
-			addSongToQueue: function (songId) {
-				let _this = this;
-				if (_this.$parent.type === 'community') {
-					_this.socket.emit('stations.addToQueue', _this.$parent.stationId, songId, data => {
-						if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-						else Toast.methods.addToast(`${data.message}`, 4000);
-					});
-				} else {
-					_this.socket.emit('queueSongs.add', songId, data => {
-						if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
+		unSelectPlaylist() {
+			if (this.station.type === "community") {
+				this.privatePlaylistQueueSelected = null;
+				this.$parent.privatePlaylistQueueSelected = null;
+			}
+		},
+		addSongToQueue(songId) {
+			console.log(this.station.type);
+			if (this.station.type === "community") {
+				this.socket.emit(
+					"stations.addToQueue",
+					this.station._id,
+					songId,
+					data => {
+						if (data.status !== "success")
+							Toast.methods.addToast(
+								`Error: ${data.message}`,
+								8000
+							);
 						else Toast.methods.addToast(`${data.message}`, 4000);
-					});
-				}
-			},
-			submitQuery: function () {
-				let _this = this;
-				let query = _this.querySearch;
-				if (query.indexOf('&index=') !== -1) {
-					query = query.split('&index=');
-					query.pop();
-					query = query.join('');
-				}
-				if (query.indexOf('&list=') !== -1) {
-					query = query.split('&list=');
-					query.pop();
-					query = query.join('');
-				}
-				_this.socket.emit('apis.searchYoutube', query, results => {
-					results = results.data;
-					_this.queryResults = [];
-					for (let i = 0; i < results.items.length; i++) {
-						_this.queryResults.push({
-							id: results.items[i].id.videoId,
-							url: `https://www.youtube.com/watch?v=${this.id}`,
-							title: results.items[i].snippet.title,
-							thumbnail: results.items[i].snippet.thumbnails.default.url
-						});
 					}
+				);
+			} else {
+				this.socket.emit("queueSongs.add", songId, data => {
+					if (data.status !== "success")
+						Toast.methods.addToast(`Error: ${data.message}`, 8000);
+					else Toast.methods.addToast(`${data.message}`, 4000);
 				});
 			}
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('playlists.indexForUser', res => {
-					if (res.status === 'success') _this.playlists = res.data;
-				});
-				_this.privatePlaylistQueueSelected = _this.$parent.privatePlaylistQueueSelected;
-			});
+		importPlaylist() {
+			Toast.methods.addToast(
+				"Starting to import your playlist. This can take some time to do.",
+				4000
+			);
+			this.socket.emit(
+				"queueSongs.addSetToQueue",
+				this.importQuery,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.addSongToQueue = !this.$parent.modals.addSongToQueue;
+		submitQuery() {
+			let query = this.querySearch;
+			if (query.indexOf("&index=") !== -1) {
+				query = query.split("&index=");
+				query.pop();
+				query = query.join("");
+			}
+			if (query.indexOf("&list=") !== -1) {
+				query = query.split("&list=");
+				query.pop();
+				query = query.join("");
 			}
+			this.socket.emit("apis.searchYoutube", query, res => {
+				// check for error
+				const { data } = res;
+				this.queryResults = [];
+				for (let i = 0; i < data.items.length; i += 1) {
+					this.queryResults.push({
+						id: data.items[i].id.videoId,
+						url: `https://www.youtube.com/watch?v=${this.id}`,
+						title: data.items[i].snippet.title,
+						thumbnail: data.items[i].snippet.thumbnails.default.url
+					});
+				}
+			});
 		},
-		components: { Modal }
-	}
+		...mapActions("user/playlists", ["editPlaylist"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") this.playlists = res.data;
+			});
+			this.privatePlaylistQueueSelected = this.$parent.privatePlaylistQueueSelected;
+		});
+	},
+	components: { Modal }
+};
 </script>
 
-<style type='scss' scoped>
-	tr td {
-		vertical-align: middle;
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+tr td {
+	vertical-align: middle;
 
-		img { width: 55px; }
+	img {
+		width: 55px;
 	}
-</style>
+}
+
+.table {
+	margin-bottom: 0;
+}
+</style>

+ 127 - 59
frontend/components/Modals/CreateCommunityStation.vue

@@ -1,73 +1,141 @@
 <template>
-	<modal title='Create Community Station'>
-		<div slot='body'>
+	<modal title="Create Community Station">
+		<template v-slot:body>
 			<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-			<label class='label'>Name (lowercase, a-z, used in the url)</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='Name...' v-model='newCommunity.name' autofocus>
+			<label class="label">Name (unique lowercase station id)</label>
+			<p class="control">
+				<input
+					v-model="newCommunity.name"
+					class="input"
+					type="text"
+					placeholder="Name..."
+					autofocus
+				/>
 			</p>
-			<label class='label'>Display Name</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='Display name...' v-model='newCommunity.displayName'>
+			<label class="label">Display Name</label>
+			<p class="control">
+				<input
+					v-model="newCommunity.displayName"
+					class="input"
+					type="text"
+					placeholder="Display name..."
+				/>
 			</p>
-			<label class='label'>Description</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='Description...' v-model='newCommunity.description' @keyup.enter="submitModal()">
+			<label class="label">Description</label>
+			<p class="control">
+				<input
+					v-model="newCommunity.description"
+					class="input"
+					type="text"
+					placeholder="Description..."
+					@keyup.enter="submitModal()"
+				/>
 			</p>
-		</div>
-		<div slot='footer'>
-			<a class='button is-primary' @click='submitModal()'>Create</a>
-		</div>
+		</template>
+		<template v-slot:footer>
+			<a class="button is-primary" v-on:click="submitModal()">Create</a>
+		</template>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
+import { mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				newCommunity: {
-					name: '',
-					displayName: '',
-					description: ''
-				}
+import { Toast } from "vue-roaster";
+import Modal from "./Modal.vue";
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			newCommunity: {
+				name: "",
+				displayName: "",
+				description: ""
 			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
+	methods: {
+		submitModal() {
+			const { name, displayName, description } = this.newCommunity;
+
+			if (!name || !displayName || !description)
+				return Toast.methods.addToast(
+					"Please fill in all fields",
+					8000
+				);
+
+			if (!validation.isLength(name, 2, 16))
+				return Toast.methods.addToast(
+					"Name must have between 2 and 16 characters.",
+					8000
+				);
+
+			if (!validation.regex.az09_.test(name))
+				return Toast.methods.addToast(
+					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					8000
+				);
+
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			if (!validation.isLength(description, 2, 200))
+				return Toast.methods.addToast(
+					"Description must have between 2 and 200 characters.",
+					8000
+				);
+
+			let characters = description.split("");
+
+			characters = characters.filter(character => {
+				return character.charCodeAt(0) === 21328;
 			});
+
+			if (characters.length !== 0)
+				return Toast.methods.addToast(
+					"Invalid description format. Swastika's are not allowed.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.create",
+				{
+					name,
+					type: "community",
+					displayName,
+					description
+				},
+				res => {
+					if (res.status === "success") {
+						Toast.methods.addToast(
+							`You have added the station successfully`,
+							4000
+						);
+						this.closeModal({
+							sector: "home",
+							modal: "createCommunityStation"
+						});
+					} else Toast.methods.addToast(res.message, 4000);
+				}
+			);
 		},
-		methods: {
-			toggleModal: function () {
-				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
-			},
-			submitModal: function () {
-				let _this = this;
-				if (_this.newCommunity.name == '') return Toast.methods.addToast('Name cannot be a blank field', 3000);
-				if (_this.newCommunity.displayName == '') return Toast.methods.addToast('Display Name cannot be a blank field', 3000);
-				if (_this.newCommunity.description == '') return Toast.methods.addToast('Description cannot be a blank field', 3000);
-				this.socket.emit('stations.create', {
-					name: _this.newCommunity.name,
-					type: 'community',
-					displayName: _this.newCommunity.displayName,
-					description: _this.newCommunity.description
-				}, res => {
-					if (res.status === 'success') Toast.methods.addToast(`You have added the station successfully`, 4000);
-					else Toast.methods.addToast(res.message, 4000);
-				});
-				this.toggleModal();
-			}
-		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
-			}
-		}
+		...mapActions("modals", ["closeModal"])
 	}
-</script>
+};
+</script>

+ 306 - 167
frontend/components/Modals/EditNews.vue

@@ -1,74 +1,165 @@
 <template>
-	<modal title='Edit News'>
-		<div slot='body'>
-			<label class='label'>Title</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='News Title' v-model='$parent.editing.title' autofocus>
+	<modal title="Edit News">
+		<div slot="body">
+			<label class="label">Title</label>
+			<p class="control">
+				<input
+					v-model="editing.title"
+					class="input"
+					type="text"
+					placeholder="News Title"
+					autofocus
+				/>
 			</p>
-			<label class='label'>Description</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='News Description' v-model='$parent.editing.description'>
+			<label class="label">Description</label>
+			<p class="control">
+				<input
+					v-model="editing.description"
+					class="input"
+					type="text"
+					placeholder="News Description"
+				/>
 			</p>
 			<div class="columns">
 				<div class="column">
-					<label class='label'>Bugs</label>
-					<p class='control has-addons'>
-						<input class='input' id='edit-bugs' type='text' placeholder='Bug' v-on:keyup.enter='addChange("bugs")'>
-						<a class='button is-info' href='#' @click='addChange("bugs")'>Add</a>
+					<label class="label">Bugs</label>
+					<p class="control has-addons">
+						<input
+							id="edit-bugs"
+							class="input"
+							type="text"
+							placeholder="Bug"
+							@keyup.enter="addChangeClick('bugs')"
+						/>
+						<a
+							class="button is-info"
+							href="#"
+							@click="addChangeClick('bugs')"
+							>Add</a
+						>
 					</p>
-					<span class='tag is-info' v-for='(index, bug) in $parent.editing.bugs' track-by='$index'>
+					<span
+						v-for="(bug, index) in editing.bugs"
+						class="tag is-info"
+						:key="index"
+					>
 						{{ bug }}
-						<button class='delete is-info' @click='removeChange("bugs", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('bugs', index)"
+						/>
 					</span>
 				</div>
 				<div class="column">
-					<label class='label'>Features</label>
-					<p class='control has-addons'>
-						<input class='input' id='edit-features' type='text' placeholder='Feature' v-on:keyup.enter='addChange("features")'>
-						<a class='button is-info' href='#' @click='addChange("features")'>Add</a>
+					<label class="label">Features</label>
+					<p class="control has-addons">
+						<input
+							id="edit-features"
+							class="input"
+							type="text"
+							placeholder="Feature"
+							@keyup.enter="addChangeClick('features')"
+						/>
+						<a
+							class="button is-info"
+							href="#"
+							@click="addChangeClick('features')"
+							>Add</a
+						>
 					</p>
-					<span class='tag is-info' v-for='(index, feature) in $parent.editing.features' track-by='$index'>
+					<span
+						v-for="(feature, index) in editing.features"
+						class="tag is-info"
+						:key="index"
+					>
 						{{ feature }}
-						<button class='delete is-info' @click='removeChange("features", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('features', index)"
+						/>
 					</span>
 				</div>
 			</div>
 
 			<div class="columns">
 				<div class="column">
-					<label class='label'>Improvements</label>
-					<p class='control has-addons'>
-						<input class='input' id='edit-improvements' type='text' placeholder='Improvement' v-on:keyup.enter='addChange("improvements")'>
-						<a class='button is-info' href='#' @click='addChange("improvements")'>Add</a>
+					<label class="label">Improvements</label>
+					<p class="control has-addons">
+						<input
+							id="edit-improvements"
+							class="input"
+							type="text"
+							placeholder="Improvement"
+							@keyup.enter="addChangeClick('improvements')"
+						/>
+						<a
+							class="button is-info"
+							href="#"
+							@click="addChangeClick('improvements')"
+							>Add</a
+						>
 					</p>
-					<span class='tag is-info' v-for='(index, improvement) in $parent.editing.improvements' track-by='$index'>
+					<span
+						v-for="(improvement, index) in editing.improvements"
+						class="tag is-info"
+						:key="index"
+					>
 						{{ improvement }}
-						<button class='delete is-info' @click='removeChange("improvements", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('improvements', index)"
+						/>
 					</span>
 				</div>
 				<div class="column">
-					<label class='label'>Upcoming</label>
-					<p class='control has-addons'>
-						<input class='input' id='edit-upcoming' type='text' placeholder='Upcoming' v-on:keyup.enter='addChange("upcoming")'>
-						<a class='button is-info' href='#' @click='addChange("upcoming")'>Add</a>
+					<label class="label">Upcoming</label>
+					<p class="control has-addons">
+						<input
+							id="edit-upcoming"
+							class="input"
+							type="text"
+							placeholder="Upcoming"
+							@keyup.enter="addChangeClick('upcoming')"
+						/>
+						<a
+							class="button is-info"
+							href="#"
+							@click="addChangeClick('upcoming')"
+							>Add</a
+						>
 					</p>
-					<span class='tag is-info' v-for='(index, upcoming) in $parent.editing.upcoming' track-by='$index'>
+					<span
+						v-for="(upcoming, index) in editing.upcoming"
+						class="tag is-info"
+						:key="index"
+					>
 						{{ upcoming }}
-						<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
+						<button
+							class="delete is-info"
+							@click="removeChangeClick('upcoming', index)"
+						/>
 					</span>
 				</div>
 			</div>
 		</div>
-		<div slot='footer'>
-			<button class='button is-success' @click='$parent.updateNews(false)'>
-				<i class='material-icons save-changes'>done</i>
+		<div slot="footer">
+			<button class="button is-success" @click="updateNews(false)">
+				<i class="material-icons save-changes">done</i>
 				<span>&nbsp;Save</span>
 			</button>
-			<button class='button is-success' @click='$parent.updateNews(true)'>
-				<i class='material-icons save-changes'>done</i>
+			<button class="button is-success" @click="updateNews(true)">
+				<i class="material-icons save-changes">done</i>
 				<span>&nbsp;Save and close</span>
 			</button>
-			<button class='button is-danger' @click='$parent.toggleModal()'>
+			<button
+				class="button is-danger"
+				@click="
+					closeModal({
+						sector: 'admin',
+						modal: 'editNews'
+					})
+				"
+			>
 				<span>&nbsp;Close</span>
 			</button>
 		</div>
@@ -76,161 +167,209 @@
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { mapActions, mapState } from "vuex";
 
-	import Modal from './Modal.vue';
+import { Toast } from "vue-roaster";
+import io from "../../io";
 
-	export default {
-		components: { Modal },
-		methods: {
-			addChange: function (type) {
-				let change = $(`#edit-${type}`).val().trim();
+import Modal from "./Modal.vue";
 
-				if (this.$parent.editing[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
+export default {
+	components: { Modal },
+	computed: {
+		...mapState("admin/news", {
+			editing: state => state.editing
+		})
+	},
+	methods: {
+		addChange(type) {
+			const change = document.getElementById(`edit-${type}`).value.trim();
 
-				if (change) this.$parent.editing[type].push(change);
-				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
-			},
-			removeChange: function (type, index) {
-				this.$parent.editing[type].splice(index, 1);
-			},
+			if (this.editing[type].indexOf(change) !== -1)
+				return Toast.methods.addToast(`Tag already exists`, 3000);
+
+			if (change) this.addChange({ type, change });
+			else Toast.methods.addToast(`${type} cannot be empty`, 3000);
+
+			document.getElementById(`edit-${type}`).value = "";
+			return true;
+		},
+		removeChange(type, index) {
+			this.removeChange({ type, index });
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.toggleModal();
-			}
-		}
+		updateNews(close) {
+			this.socket.emit(
+				"news.update",
+				this.editing._id,
+				this.editing,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === "success") {
+						if (close)
+							this.closeModal({
+								sector: "admin",
+								modal: "editNews"
+							});
+					}
+				}
+			);
+		},
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("admin/news", ["addChange", "removeChange"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	input[type=range] {
-		-webkit-appearance: none;
-		width: 100%;
-		margin: 7.3px 0;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	input[type=range]:focus {
-		outline: none;
-	}
+input[type="range"] {
+	-webkit-appearance: none;
+	width: 100%;
+	margin: 7.3px 0;
+}
 
-	input[type=range]::-webkit-slider-runnable-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
-	}
+input[type="range"]:focus {
+	outline: none;
+}
 
-	input[type=range]::-webkit-slider-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
-	}
+input[type="range"]::-webkit-slider-runnable-track {
+	width: 100%;
+	height: 5.2px;
+	cursor: pointer;
+	box-shadow: 0;
+	background: $light-grey-2;
+	border-radius: 0;
+	border: 0;
+}
 
-	input[type=range]::-moz-range-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
-	}
+input[type="range"]::-webkit-slider-thumb {
+	box-shadow: 0;
+	border: 0;
+	height: 19px;
+	width: 19px;
+	border-radius: 15px;
+	background: $primary-color;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -6.5px;
+}
 
-	input[type=range]::-moz-range-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
-	}
+input[type="range"]::-moz-range-track {
+	width: 100%;
+	height: 5.2px;
+	cursor: pointer;
+	box-shadow: 0;
+	background: $light-grey-2;
+	border-radius: 0;
+	border: 0;
+}
 
-	input[type=range]::-ms-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 1.3px;
-	}
+input[type="range"]::-moz-range-thumb {
+	box-shadow: 0;
+	border: 0;
+	height: 19px;
+	width: 19px;
+	border-radius: 15px;
+	background: $primary-color;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -6.5px;
+}
 
-	input[type=range]::-ms-fill-lower {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
-	}
+input[type="range"]::-ms-track {
+	width: 100%;
+	height: 5.2px;
+	cursor: pointer;
+	box-shadow: 0;
+	background: $light-grey-2;
+	border-radius: 1.3px;
+}
 
-	input[type=range]::-ms-fill-upper {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
-	}
+input[type="range"]::-ms-fill-lower {
+	background: $light-grey-2;
+	border: 0;
+	border-radius: 0;
+	box-shadow: 0;
+}
 
-	input[type=range]::-ms-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 15px;
-		width: 15px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: 1.5px;
-	}
+input[type="range"]::-ms-fill-upper {
+	background: $light-grey-2;
+	border: 0;
+	border-radius: 0;
+	box-shadow: 0;
+}
 
-	.controls {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-	}
+input[type="range"]::-ms-thumb {
+	box-shadow: 0;
+	border: 0;
+	height: 15px;
+	width: 15px;
+	border-radius: 15px;
+	background: $primary-color;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: 1.5px;
+}
 
-	.artist-genres {
-		display: flex;
-    	justify-content: space-between;
-	}
+.controls {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+}
 
-	#volumeSlider { margin-bottom: 15px; }
+.artist-genres {
+	display: flex;
+	justify-content: space-between;
+}
 
-	.has-text-centered { padding: 10px; }
+#volumeSlider {
+	margin-bottom: 15px;
+}
 
-	.thumbnail-preview {
-		display: flex;
-		margin: 0 auto 25px auto;
-		max-width: 200px;
-		width: 100%;
-	}
+.has-text-centered {
+	padding: 10px;
+}
 
-	.modal-card-body, .modal-card-foot { border-top: 0; }
+.thumbnail-preview {
+	display: flex;
+	margin: 0 auto 25px auto;
+	max-width: 200px;
+	width: 100%;
+}
 
-	.label, .checkbox, h5 {
-		font-weight: normal;
-	}
+.modal-card-body,
+.modal-card-foot {
+	border-top: 0;
+}
+
+.label,
+.checkbox,
+h5 {
+	font-weight: normal;
+}
 
-	.video-container {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		padding: 10px;
+.video-container {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	padding: 10px;
 
-		iframe { pointer-events: none; }
+	iframe {
+		pointer-events: none;
 	}
+}
 
-	.save-changes { color: #fff; }
+.save-changes {
+	color: $white;
+}
 
-	.tag:not(:last-child) { margin-right: 5px; }
-</style>
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
+</style>

+ 1718 - 404
frontend/components/Modals/EditSong.vue

@@ -1,486 +1,1800 @@
 <template>
 	<div>
-		<modal title='Edit Song'>
-			<div slot='body'>
-				<h5 class='has-text-centered'>Video Preview</h5>
-				<div class='video-container'>
-					<div id='player'></div>
-					<div class="controls">
-						<form action="#" class="column is-7-desktop is-4-mobile">
-							<p style="margin-top: 0; position: relative;">
-								<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
-							</p>
-						</form>
-						<p class='control has-addons'>
-							<button class='button' @click='settings("pause")' v-if='!video.paused'>
-								<i class='material-icons'>pause</i>
-							</button>
-							<button class='button' @click='settings("play")' v-if='video.paused'>
-								<i class='material-icons'>play_arrow</i>
-							</button>
-							<button class='button' @click='settings("stop")'>
-								<i class='material-icons'>stop</i>
-							</button>
-							<button class='button' @click='settings("skipToLast10Secs")'>
-								<i class='material-icons'>fast_forward</i>
-							</button>
-						</p>
-					</div>
-				</div>
-				<h5 class='has-text-centered'>Thumbnail Preview</h5>
-				<img class='thumbnail-preview' :src='editing.song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-
-				<div class="control is-horizontal">
-					<div class="control-label">
-						<label class="label">Thumbnail URL</label>
-					</div>
-					<div class="control">
-						<input class='input' type='text' v-model='editing.song.thumbnail'>
-					</div>
-				</div>
-
-				<h5 class='has-text-centered'>Edit Information</h5>
-
-				<p class='control'>
-					<label class='checkbox'>
-						<input type='checkbox' v-model='editing.song.explicit'>
-						Explicit
-					</label>
-				</p>
-				<label class='label'>Song ID & Title</label>
-				<div class="control is-horizontal">
-					<div class="control is-grouped">
-						<p class='control is-expanded'>
-							<input class='input' type='text' v-model='editing.song.songId'>
-						</p>
-						<p class='control is-expanded'>
-							<input class='input' type='text' v-model='editing.song.title' autofocus>
-						</p>
+		<modal title="Edit Song" class="song-modal">
+			<div slot="body">
+				<div class="left-section">
+					<div class="top-section">
+						<div class="player-section">
+							<div id="player"></div>
+							<canvas
+								id="durationCanvas"
+								height="20"
+								width="530"
+							></canvas>
+							<div class="player-footer">
+								<div class="player-footer-left">
+									<i
+										class="material-icons player-play-pause"
+										v-on:click="settings('play')"
+										v-if="video.paused"
+										>play_arrow</i
+									>
+									<i
+										class="material-icons player-play-pause"
+										v-on:click="settings('pause')"
+										v-if="!video.paused"
+										>pause</i
+									>
+									<i
+										class="material-icons player-stop"
+										v-on:click="settings('stop')"
+										>stop</i
+									>
+									<i
+										class="material-icons player-fast-forward"
+										v-on:click="
+											settings('skipToLast10Secs')
+										"
+										>fast_forward</i
+									>
+								</div>
+								<div class="player-footer-center">
+									<img src="/assets/social/youtube.svg" />
+									<span>
+										<span>
+											{{ youtubeVideoCurrentTime }}
+										</span>
+										/
+										<span>
+											{{ youtubeVideoDuration }}
+											{{ youtubeVideoNote }}
+										</span>
+									</span>
+								</div>
+								<div class="player-footer-right">
+									<input
+										type="range"
+										id="volumeSlider"
+										min="0"
+										max="100"
+										class="active"
+										v-on:change="changeVolume()"
+										v-on:input="changeVolume()"
+									/>
+								</div>
+							</div>
+						</div>
+						<img
+							class="thumbnail-preview"
+							:src="editing.song.thumbnail"
+							onerror="this.src='/assets/notes-transparent.png'"
+						/>
 					</div>
-				</div>
-				<label class='label'>Artists & Genres</label>
-				<div class='control is-horizontal'>
-					<div class='control is-grouped artist-genres'>
-						<div>
-							<p class='control has-addons'>
-								<input class='input' id='new-artist' type='text' placeholder='Artist'>
-								<button class='button is-info' @click='addTag("artists")'>Add Artist</button>
-							</p>
-							<span class='tag is-info' v-for='(index, artist) in editing.song.artists' track-by='$index'>
-								{{ artist }}
-								<button class='delete is-info' @click='removeTag("artists", index)'></button>
-							</span>
+					<div class="edit-section">
+						<div class="control is-grouped">
+							<div class="title-container">
+								<label class="label">Title</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.title"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('title')"
+									>
+										<i class="material-icons album-get-icon"
+											>album</i
+										>
+									</button>
+								</p>
+							</div>
+							<div class="duration-container">
+								<label class="label">Duration</label>
+								<p class="control">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.duration"
+									/>
+								</p>
+							</div>
+							<div class="skip-duration-container">
+								<label class="label">Skip duration</label>
+								<p class="control">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.skipDuration"
+									/>
+								</p>
+							</div>
+						</div>
+						<div class="control is-grouped">
+							<div class="album-art-container">
+								<label class="label">Album art</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.thumbnail"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('albumArt')"
+									>
+										<i class="material-icons album-get-icon"
+											>album</i
+										>
+									</button>
+								</p>
+							</div>
 						</div>
-						<div>
-							<p class='control has-addons'>
-								<input class='input' id='new-genre' type='text' placeholder='Genre'>
-								<button class='button is-info' @click='addTag("genres")'>Add Genre</button>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in editing.song.genres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeTag("genres", index)'></button>
-							</span>
+						<div class="control is-grouped">
+							<div class="artists-container">
+								<label class="label">Artists</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										id="new-artist"
+										v-model="artistInputValue"
+										v-on:blur="blurArtistInput()"
+										v-on:focus="focusArtistInput()"
+										v-on:keydown="keydownArtistInput()"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('artists')"
+									>
+										<i class="material-icons album-get-icon"
+											>album</i
+										>
+									</button>
+									<button
+										class="button is-info add-button"
+										v-on:click="addTag('artists')"
+									>
+										<i class="material-icons">add</i>
+									</button>
+								</p>
+								<div
+									class="autosuggest-container"
+									v-if="
+										(artistInputFocussed ||
+											artistAutosuggestContainerFocussed) &&
+											artistAutosuggestItems.length > 0
+									"
+									@mouseover="focusArtistContainer()"
+									@mouseleave="blurArtistContainer()"
+								>
+									<span
+										class="autosuggest-item"
+										tabindex="0"
+										v-on:click="
+											selectArtistAutosuggest(item)
+										"
+										v-for="(item,
+										index) in artistAutosuggestItems"
+										:key="index"
+										>{{ item }}</span
+									>
+								</div>
+								<div class="list-container">
+									<div
+										class="list-item"
+										v-for="(artist, index) in editing.song
+											.artists"
+										:key="index"
+									>
+										<div
+											class="list-item-circle"
+											v-on:click="
+												removeTag('artists', index)
+											"
+										>
+											<i class="material-icons">close</i>
+										</div>
+										<p>{{ artist }}</p>
+									</div>
+								</div>
+							</div>
+							<div class="genres-container">
+								<label class="label">
+									<span>Genres</span>
+									<i
+										class="material-icons"
+										@click="toggleGenreHelper"
+										@dblclick="resetGenreHelper"
+										>info</i
+									>
+								</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										id="new-genre"
+										v-model="genreInputValue"
+										v-on:blur="blurGenreInput()"
+										v-on:focus="focusGenreInput()"
+										v-on:keydown="keydownGenreInput()"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('genres')"
+									>
+										<i class="material-icons album-get-icon"
+											>album</i
+										>
+									</button>
+									<button
+										class="button is-info add-button"
+										v-on:click="addTag('genres')"
+									>
+										<i class="material-icons">add</i>
+									</button>
+								</p>
+								<div
+									class="autosuggest-container"
+									v-if="
+										(genreInputFocussed ||
+											genreAutosuggestContainerFocussed) &&
+											genreAutosuggestItems.length > 0
+									"
+									@mouseover="focusGenreContainer()"
+									@mouseleave="blurGenreContainer()"
+								>
+									<span
+										class="autosuggest-item"
+										tabindex="0"
+										v-on:click="
+											selectGenreAutosuggest(item)
+										"
+										v-for="(item,
+										index) in genreAutosuggestItems"
+										:key="index"
+										>{{ item }}</span
+									>
+								</div>
+								<div class="list-container">
+									<div
+										class="list-item"
+										v-for="(genre, index) in editing.song
+											.genres"
+										:key="index"
+									>
+										<div
+											class="list-item-circle"
+											v-on:click="
+												removeTag('genres', index)
+											"
+										>
+											<i class="material-icons">close</i>
+										</div>
+										<p>{{ genre }}</p>
+									</div>
+								</div>
+							</div>
+							<div class="youtube-id-container">
+								<label class="label">YouTube ID</label>
+								<p class="control">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.songId"
+									/>
+								</p>
+							</div>
 						</div>
 					</div>
 				</div>
-				<label class='label'>Song Duration</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='editing.song.duration'>
-				</p>
-				<label class='label'>Skip Duration</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='editing.song.skipDuration'>
-				</p>
-				<article class="message">
-					<div class="message-body">
-						<span class="reports-length">
-							{{ reports.length }}
-							<span v-if="reports.length > 1 || reports.length <= 0">&nbsp;Reports</span>
-							<span v-else>&nbsp;Report</span>
-						</span>
-						<div v-for='report in reports'>
-							<a :href='`/admin/reports?id=${report}`' class='report-link'>Report - {{ report }}</a>
+				<div class="right-section">
+					<div class="api-section">
+						<div
+							class="selected-discogs-info"
+							v-if="!editing.song.discogs"
+						>
+							<p class="selected-discogs-info-none">None</p>
 						</div>
-					</div>
-				</article>
-				<hr />
-				<h5 class='has-text-centered'>Spotify Information</h5>
-				<label class='label'>Song title</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='spotify.title'>
-				</p>
-				<label class='label'>Song artist (1 artist full name)</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='spotify.artist'>
-				</p>
-				<button class='button is-success' @click='getSpotifySongs()'>
-					Get Spotify songs
-				</button>
-				<hr />
-				<article class="media" v-for='song in spotify.songs'>
-					<figure class="media-left">
-						<p class="image is-64x64">
-							<img :src="song.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+						<div
+							class="selected-discogs-info"
+							v-if="editing.song.discogs"
+						>
+							<div class="top-container">
+								<img
+									:src="editing.song.discogs.album.albumArt"
+								/>
+								<div class="right-container">
+									<p class="album-title">
+										{{ editing.song.discogs.album.title }}
+									</p>
+									<div class="bottom-row">
+										<p class="type-year">
+											<span>{{
+												editing.song.discogs.album.type
+											}}</span>
+											•
+											<span>{{
+												editing.song.discogs.album.year
+											}}</span>
+										</p>
+									</div>
+								</div>
+							</div>
+							<div class="bottom-container">
+								<p class="bottom-container-field">
+									Artists:
+									<span>{{
+										editing.song.discogs.album.artists.join(
+											", "
+										)
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Genres:
+									<span>{{
+										editing.song.discogs.album.genres.join(
+											", "
+										)
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Data quality:
+									<span>{{
+										editing.song.discogs.dataQuality
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Track:
+									<span
+										>{{
+											editing.song.discogs.track.position
+										}}.
+										{{
+											editing.song.discogs.track.title
+										}}</span
+									>
+								</p>
+							</div>
+						</div>
+						<p class="control is-expanded">
+							<label class="label">Search query</label>
+							<input
+								class="input"
+								type="text"
+								v-model="discogsQuery"
+								@change="onDiscogsQueryChange"
+							/>
 						</p>
-					</figure>
-					<div class="media-content">
-						<div class="content">
-							<p>
-								<strong>{{song.title}}</strong>
-								<br />
-								<small>Artists: {{song.artists}}</small>, <small>Duration: {{song.duration}}</small>, <small>Explicit: {{song.explicit}}</small>
-								<br />
-								<small>Thumbnail: {{song.thumbnail}}</small>
-							</p>
+						<button
+							class="button is-info is-fullwidth"
+							v-on:click="searchDiscogsForPage(1)"
+						>
+							Search
+						</button>
+						<label
+							class="label"
+							v-if="discogs.apiResults.length > 0"
+							>API results</label
+						>
+						<div
+							class="api-results-container"
+							v-if="discogs.apiResults.length > 0"
+						>
+							<div
+								class="api-result"
+								v-for="(result, index) in discogs.apiResults"
+								:key="index"
+							>
+								<div class="top-container">
+									<img :src="result.album.albumArt" />
+									<div class="right-container">
+										<p class="album-title">
+											{{ result.album.title }}
+										</p>
+										<div class="bottom-row">
+											<img
+												src="/assets/arrow_up.svg"
+												v-if="result.expanded"
+												v-on:click="
+													toggleAPIResult(index)
+												"
+											/>
+											<img
+												src="/assets/arrow_down.svg"
+												v-if="!result.expanded"
+												v-on:click="
+													toggleAPIResult(index)
+												"
+											/>
+											<p class="type-year">
+												<span>{{
+													result.album.type
+												}}</span>
+												•
+												<span>{{
+													result.album.year
+												}}</span>
+											</p>
+										</div>
+									</div>
+								</div>
+								<div
+									class="bottom-container"
+									v-if="result.expanded"
+								>
+									<p class="bottom-container-field">
+										Artists:
+										<span>{{
+											result.album.artists.join(", ")
+										}}</span>
+									</p>
+									<p class="bottom-container-field">
+										Genres:
+										<span>{{
+											result.album.genres.join(", ")
+										}}</span>
+									</p>
+									<p class="bottom-container-field">
+										Data quality:
+										<span>{{ result.dataQuality }}</span>
+									</p>
+									<div class="tracks">
+										<div
+											class="track"
+											tabindex="0"
+											v-for="(track,
+											trackIndex) in result.tracks"
+											:key="trackIndex"
+											v-on:click="
+												selectTrack(index, trackIndex)
+											"
+										>
+											<span>{{ track.position }}.</span>
+											<p>{{ track.title }}</p>
+										</div>
+									</div>
+								</div>
+							</div>
 						</div>
+						<button
+							v-if="
+								discogs.apiResults.length > 0 &&
+									!discogs.disableLoadMore &&
+									discogs.page < discogs.pages
+							"
+							class="button is-fullwidth is-info discogs-load-more"
+							@click="loadNextDiscogsPage()"
+						>
+							Load more...
+						</button>
 					</div>
-				</article>
+				</div>
 			</div>
-			<div slot='footer'>
-				<button class='button is-success' @click='save(editing.song, false)'>
-					<i class='material-icons save-changes'>done</i>
+			<div slot="footer" class="footer-buttons">
+				<button
+					class="button is-success"
+					v-on:click="save(editing.song, false)"
+				>
+					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Save</span>
 				</button>
-				<button class='button is-success' @click='save(editing.song, true)'>
-					<i class='material-icons save-changes'>done</i>
+				<button
+					class="button is-success"
+					v-on:click="save(editing.song, true)"
+				>
+					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Save and close</span>
 				</button>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+				<button
+					class="button is-danger"
+					v-on:click="
+						closeModal({ sector: 'admin', modal: 'editSong' })
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
 		</modal>
+		<div
+			id="genre-helper-container"
+			v-bind:style="{
+				width: genreHelper.width + 'px',
+				height: genreHelper.height + 'px',
+				top: genreHelper.top + 'px',
+				left: genreHelper.left + 'px'
+			}"
+			v-if="genreHelper.shown"
+			@mousedown="onResizeGenreHelper"
+		>
+			<div
+				class="genre-helper-header"
+				@mousedown="onDragGenreHelper"
+			></div>
+			<div class="genre-helper-body">
+				<span>Blues</span><span>Country</span><span>Disco</span
+				><span>Funk</span><span>Hip-Hop</span><span>Jazz</span
+				><span>Metal</span><span>Oldies</span><span>Other</span
+				><span>Pop</span><span>Rap</span><span>Reggae</span
+				><span>Rock</span><span>Techno</span><span>Trance</span
+				><span>Classical</span><span>Instrumental</span
+				><span>House</span><span>Electronic</span
+				><span>Christian Rap</span><span>Lo-Fi</span><span>Musical</span
+				><span>Rock 'n' Roll</span><span>Opera</span
+				><span>Drum & Bass</span><span>Club-House</span
+				><span>Indie</span><span>Heavy Metal</span
+				><span>Christian rock</span><span>Dubstep</span>
+			</div>
+		</div>
 	</div>
 </template>
 
 <script>
-	import io from '../../io';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				editing: {
-					index: 0,
-					song: {},
-					type: ''
-				},
-				reports: 0,
-				video: {
-					player: null,
-					paused: false,
-					playerReady: false
-				},
-				spotify: {
-					title: '',
-					artist: '',
-					songs: []
+import { mapState, mapActions } from "vuex";
+import { Toast } from "vue-roaster";
+
+import io from "../../io";
+import validation from "../../validation";
+import Modal from "./Modal.vue";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			discogsQuery: "",
+			youtubeVideoDuration: 0.0,
+			youtubeVideoCurrentTime: 0.0,
+			youtubeVideoNote: "",
+			useHTTPS: false,
+			discogs: {
+				apiResults: [],
+				page: 1,
+				pages: 1,
+				disableLoadMore: false
+			},
+			artistInputValue: "",
+			genreInputValue: "",
+			artistInputFocussed: false,
+			genreInputFocussed: false,
+			genreAutosuggestContainerFocussed: false,
+			artistAutosuggestContainerFocussed: false,
+			keydownArtistInputTimeout: 0,
+			keydownGenreInputTimeout: 0,
+			artistAutosuggestItems: [],
+			genreAutosuggestItems: [],
+			genreHelper: {
+				width: 200,
+				height: 200,
+				top: 0,
+				left: 0,
+				shown: false,
+				pos1: 0,
+				pos2: 0,
+				pos3: 0,
+				pos4: 0
+			},
+			genres: [
+				"Blues",
+				"Country",
+				"Disco",
+				"Funk",
+				"Hip-Hop",
+				"Jazz",
+				"Metal",
+				"Oldies",
+				"Other",
+				"Pop",
+				"Rap",
+				"Reggae",
+				"Rock",
+				"Techno",
+				"Trance",
+				"Classical",
+				"Instrumental",
+				"House",
+				"Electronic",
+				"Christian Rap",
+				"Lo-Fi",
+				"Musical",
+				"Rock 'n' Roll",
+				"Opera",
+				"Drum & Bass",
+				"Club-House",
+				"Indie",
+				"Heavy Metal",
+				"Christian rock",
+				"Dubstep"
+			]
+		};
+	},
+	computed: {
+		...mapState("admin/songs", {
+			video: state => state.video,
+			editing: state => state.editing,
+			songs: state => state.songs
+		}),
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		save(songToCopy, close) {
+			const song = JSON.parse(JSON.stringify(songToCopy));
+
+			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
+				);
+
+			// Duration
+			if (
+				Number(song.skipDuration) + Number(song.duration) >
+				this.youtubeVideoDuration
+			) {
+				return Toast.methods.addToast(
+					"Duration can't be higher than the length of the video",
+					8000
+				);
+			}
+
+			// Title
+			if (!validation.isLength(song.title, 1, 100))
+				return Toast.methods.addToast(
+					"Title must have between 1 and 100 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)) {
+					error = "Artist must have between 1 and 32 characters.";
+					return error;
+				}
+				if (!validation.regex.ascii.test(artist)) {
+					error =
+						"Invalid artist format. Only ascii characters are allowed.";
+					return error;
+				}
+				if (artist === "NONE") {
+					error =
+						'Invalid artist format. Artists are not allowed to be named "NONE".';
+					return error;
 				}
+
+				return false;
+			});
+			if (error) return Toast.methods.addToast(error, 8000);
+
+			// Genres
+			/* error = undefined;
+			song.genres.forEach(genre => {
+				if (!validation.isLength(genre, 1, 16)) {
+					error = "Genre must have between 1 and 16 characters.";
+					return error;
+				}
+				if (!validation.regex.azAZ09_.test(genre)) {
+					error =
+						"Invalid genre format. Only ascii characters are allowed.";
+					return error;
+				}
+
+				return false;
+			});
+			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 (this.useHTTPS && song.thumbnail.indexOf("https://") !== 0) {
+				return Toast.methods.addToast(
+					'Thumbnail must start with "https://".',
+					8000
+				);
 			}
-		},
-		methods: {
-			save: function (song, close) {
-				let _this = this;
-				this.socket.emit(`${_this.editing.type}.update`, song._id, song, res => {
+
+			if (
+				!this.useHTTPS &&
+				(song.thumbnail.indexOf("http://") !== 0 &&
+					song.thumbnail.indexOf("https://") !== 0)
+			) {
+				return Toast.methods.addToast(
+					'Thumbnail must start with "http://".',
+					8000
+				);
+			}
+
+			return this.socket.emit(
+				`${this.editing.type}.update`,
+				song._id,
+				song,
+				res => {
 					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.$parent.songs.forEach(lSong => {
-							if (song._id === lSong._id) {
-								for (let n in song) {
-									lSong[n] = song[n];
-								}
+					if (res.status === "success") {
+						this.songs.forEach(originalSong => {
+							const updatedSong = song;
+							if (originalSong._id === updatedSong._id) {
+								Object.keys(originalSong).forEach(n => {
+									updatedSong[n] = originalSong[n];
+									return originalSong[n];
+								});
 							}
 						});
 					}
-					if (close) _this.$parent.toggleModal();
-				});
-			},
-			settings: function (type) {
-				let _this = this;
-				switch(type) {
-					case 'stop':
-						_this.video.player.stopVideo();
-						_this.video.paused = true;
-						break;
-					case 'pause':
-						_this.video.player.pauseVideo();
-						_this.video.paused = true;
-						break;
-					case 'play':
-						_this.video.player.playVideo();
-						_this.video.paused = false;
-						break;
-					case 'skipToLast10Secs':
-						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
-						break;
-				}
-			},
-			changeVolume: function () {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
+					if (close)
+						this.closeModal({
+							sector: "admin",
+							modal: "editSong"
+						});
 				}
-			},
-			removeTag: function (type, index) {
-				if (type == 'genres') this.editing.song.genres.splice(index, 1);
-				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
-			},
-			getSpotifySongs: function() {
-				this.socket.emit('apis.getSpotifySongs', this.spotify.title, this.spotify.artist, (res) => {
-					if (res.status === 'success') {
-						this.spotify.songs = res.songs;
-					}
-				});
+			);
+		},
+		toggleAPIResult(index) {
+			const apiResult = this.discogs.apiResults[index];
+			if (apiResult.expanded === true) apiResult.expanded = false;
+			else if (apiResult.gotMoreInfo === true) apiResult.expanded = true;
+			else {
+				fetch(apiResult.album.resourceUrl)
+					.then(response => {
+						return response.json();
+					})
+					.then(data => {
+						apiResult.album.artists = [];
+						apiResult.album.artistIds = [];
+						const artistRegex = new RegExp(" \\([0-9]\\)$");
+
+						apiResult.dataQuality = data.data_quality;
+						data.artists.forEach(artist => {
+							apiResult.album.artists.push(
+								artist.name.replace(artistRegex, "")
+							);
+							apiResult.album.artistIds.push(artist.id);
+						});
+						apiResult.tracks = data.tracklist.map(track => {
+							return {
+								position: track.position,
+								title: track.title
+							};
+						});
+						apiResult.expanded = true;
+						apiResult.gotMoreInfo = true;
+					});
 			}
 		},
-		ready: function () {
+		getAlbumData(type) {
+			if (!this.editing.song.discogs) return;
+			if (type === "title")
+				this.updateSongField({
+					field: "title",
+					value: this.editing.song.discogs.track.title
+				});
+			if (type === "albumArt")
+				this.updateSongField({
+					field: "thumbnail",
+					value: this.editing.song.discogs.album.albumArt
+				});
+			if (type === "genres")
+				this.updateSongField({
+					field: "genres",
+					value: this.editing.song.discogs.album.genres
+				});
+			if (type === "artists")
+				this.updateSongField({
+					field: "artists",
+					value: this.editing.song.discogs.album.artists
+				});
+		},
+		searchDiscogsForPage(page) {
+			const query = this.discogsQuery;
+
+			this.socket.emit("apis.searchDiscogs", query, page, res => {
+				if (res.status === "success") {
+					if (page === 1)
+						Toast.methods.addToast(
+							`Successfully searched. Got ${res.results.length} results.`,
+							4000
+						);
+					else
+						Toast.methods.addToast(
+							`Successfully got ${res.results.length} more results.`,
+							4000
+						);
 
-			let _this = this;
+					if (page === 1) {
+						this.discogs.apiResults = [];
+					}
+
+					this.discogs.pages = res.pages;
+
+					this.discogs.apiResults = this.discogs.apiResults.concat(
+						res.results.map(result => {
+							const type =
+								result.type.charAt(0).toUpperCase() +
+								result.type.slice(1);
+
+							return {
+								expanded: false,
+								gotMoreInfo: false,
+								album: {
+									id: result.id,
+									title: result.title,
+									type,
+									year: result.year,
+									genres: result.genre,
+									albumArt: result.cover_image,
+									resourceUrl: result.resource_url
+								}
+							};
+						})
+					);
 
-			io.getSocket(socket => {
-				_this.socket = socket;
+					this.discogs.page = page;
+					this.discogs.disableLoadMore = false;
+				} else Toast.methods.addToast(res.message, 8000);
 			});
+		},
+		loadNextDiscogsPage() {
+			this.discogs.disableLoadMore = true;
+			this.searchDiscogsForPage(this.discogs.page + 1);
+		},
+		onDiscogsQueryChange() {
+			this.discogs.page = 1;
+			this.discogs.pages = 1;
+			this.discogs.apiResults = [];
+			this.discogs.disableLoadMore = false;
+		},
+		selectTrack(apiResultIndex, trackIndex) {
+			const apiResult = JSON.parse(
+				JSON.stringify(this.discogs.apiResults[apiResultIndex])
+			);
+			apiResult.track = apiResult.tracks[trackIndex];
+			delete apiResult.tracks;
+			delete apiResult.expanded;
+			delete apiResult.gotMoreInfo;
 
-			setInterval(() => {
-				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
-					_this.video.paused = false;
-					_this.video.player.stopVideo();
+			this.selectDiscogsInfo(apiResult);
+		},
+		blurArtistInput() {
+			this.artistInputFocussed = false;
+		},
+		focusArtistInput() {
+			this.artistInputFocussed = true;
+		},
+		blurArtistContainer() {
+			this.artistAutosuggestContainerFocussed = false;
+		},
+		focusArtistContainer() {
+			this.artistAutosuggestContainerFocussed = true;
+		},
+		keydownArtistInput() {
+			clearTimeout(this.keydownArtistInputTimeout);
+			this.keydownArtistInputTimeout = setTimeout(() => {
+				// Do things here to query the artist
+			}, 1000);
+		},
+		selectArtistAutosuggest(value) {
+			this.artistInputValue = value;
+		},
+		blurGenreInput() {
+			this.genreInputFocussed = false;
+		},
+		focusGenreInput() {
+			this.genreInputFocussed = true;
+		},
+		blurGenreContainer() {
+			this.genreAutosuggestContainerFocussed = false;
+		},
+		focusGenreContainer() {
+			this.genreAutosuggestContainerFocussed = true;
+		},
+		keydownGenreInput() {
+			clearTimeout(this.keydownGenreInputTimeout);
+			this.keydownGenreInputTimeout = setTimeout(() => {
+				if (this.genreInputValue.length > 1) {
+					this.genreAutosuggestItems = this.genres.filter(genre => {
+						return genre
+							.toLowerCase()
+							.startsWith(this.genreInputValue.toLowerCase());
+					});
+				} else this.genreAutosuggestItems = [];
+			}, 1000);
+		},
+		selectGenreAutosuggest(value) {
+			this.genreInputValue = value;
+		},
+		settings(type) {
+			switch (type) {
+				default:
+					break;
+				case "stop":
+					this.stopVideo();
+					this.pauseVideo(true);
+					break;
+				case "pause":
+					this.pauseVideo(true);
+					break;
+				case "play":
+					this.pauseVideo(false);
+					break;
+				case "skipToLast10Secs":
+					this.video.player.seekTo(
+						this.editing.song.duration -
+							10 +
+							this.editing.song.skipDuration
+					);
+					break;
+			}
+		},
+		changeVolume() {
+			const volume = document.getElementById("volumeSlider").value;
+			localStorage.setItem("volume", volume);
+			this.video.player.setVolume(volume);
+			if (volume > 0) this.video.player.unMute();
+		},
+		addTag(type) {
+			if (type === "genres") {
+				const genre = document
+					.getElementById("new-genre")
+					.value.toLowerCase()
+					.trim();
+				if (this.editing.song.genres.indexOf(genre) !== -1)
+					return Toast.methods.addToast("Genre already exists", 3000);
+				if (genre) {
+					this.editing.song.genres.push(genre);
+					document.getElementById("new-genre").value = "";
+					return false;
 				}
-			}, 200);
-
-			this.video.player = new YT.Player('player', {
-				height: 315,
-				width: 560,
-				videoId: this.editing.song.songId,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
-				startSeconds: _this.editing.song.skipDuration,
-				events: {
-					'onReady': () => {
-						let volume = parseInt(localStorage.getItem("volume"));
-						volume = (typeof volume === "number") ? volume : 20;
-						_this.video.player.seekTo(_this.editing.song.skipDuration);
-						_this.video.player.setVolume(volume);
-						if (volume > 0) _this.video.player.unMute();
-						_this.playerReady = true;
-					},
-					'onStateChange': event => {
-						if (event.data === 1) {
-							_this.video.paused = false;
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
-							} else if (_this.editing.song.duration <= 0) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
-							}
 
-							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
-								_this.video.player.seekTo(10);
-							}
-						} else if (event.data === 2) {
-							this.video.paused = true;
-						}
-					}
+				return Toast.methods.addToast("Genre cannot be empty", 3000);
+			}
+			if (type === "artists") {
+				const artist = document.getElementById("new-artist").value;
+				if (this.editing.song.artists.indexOf(artist) !== -1)
+					return Toast.methods.addToast(
+						"Artist already exists",
+						3000
+					);
+				if (document.getElementById("new-artist").value !== "") {
+					this.editing.song.artists.push(artist);
+					document.getElementById("new-artist").value = "";
+					return false;
 				}
-			});
+				return Toast.methods.addToast("Artist cannot be empty", 3000);
+			}
+
+			return false;
+		},
+		removeTag(type, index) {
+			if (type === "genres") this.editing.song.genres.splice(index, 1);
+			else if (type === "artists")
+				this.editing.song.artists.splice(index, 1);
+		},
+		drawCanvas() {
+			const canvasElement = document.getElementById("durationCanvas");
+			const ctx = canvasElement.getContext("2d");
+
+			const videoDuration = Number(this.youtubeVideoDuration);
+
+			const skipDuration = Number(this.editing.song.skipDuration);
+			const duration = Number(this.editing.song.duration);
+			const afterDuration = videoDuration - (skipDuration + duration);
 
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
+			const width = 560;
 
+			const currentTime = this.video.player.getCurrentTime();
+
+			const widthSkipDuration = (skipDuration / videoDuration) * width;
+			const widthDuration = (duration / videoDuration) * width;
+			const widthAfterDuration = (afterDuration / videoDuration) * width;
+
+			const widthCurrentTime = (currentTime / videoDuration) * width;
+
+			const skipDurationColor = "#F42003";
+			const durationColor = "#03A9F4";
+			const afterDurationColor = "#41E841";
+			const currentDurationColor = "#3b25e8";
+
+			ctx.fillStyle = skipDurationColor;
+			ctx.fillRect(0, 0, widthSkipDuration, 20);
+			ctx.fillStyle = durationColor;
+			ctx.fillRect(widthSkipDuration, 0, widthDuration, 20);
+			ctx.fillStyle = afterDurationColor;
+			ctx.fillRect(
+				widthSkipDuration + widthDuration,
+				0,
+				widthAfterDuration,
+				20
+			);
+
+			ctx.fillStyle = currentDurationColor;
+			ctx.fillRect(widthCurrentTime, 0, 1, 20);
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.editSong = false;
+		onDragGenreHelper(e) {
+			const e1 = e || window.event;
+			e1.preventDefault();
+
+			this.genreHelper.pos3 = e1.clientX;
+			this.genreHelper.pos4 = e1.clientY;
+
+			document.onmousemove = e => {
+				const e2 = e || window.event;
+				e2.preventDefault();
+				// calculate the new cursor position:
+				this.genreHelper.pos1 = this.genreHelper.pos3 - e.clientX;
+				this.genreHelper.pos2 = this.genreHelper.pos4 - e.clientY;
+				this.genreHelper.pos3 = e.clientX;
+				this.genreHelper.pos4 = e.clientY;
+				// set the element's new position:
+				this.genreHelper.top =
+					this.genreHelper.top - this.genreHelper.pos2;
+				this.genreHelper.left =
+					this.genreHelper.left - this.genreHelper.pos1;
+			};
+
+			document.onmouseup = () => {
+				document.onmouseup = null;
+				document.onmousemove = null;
+
+				this.saveGenreHelper();
+			};
+		},
+		onResizeGenreHelper(e) {
+			if (e.target.id !== "genre-helper-container") return;
+
+			document.onmouseup = () => {
+				document.onmouseup = null;
+
+				const { height, width } = e.target.style;
+
+				this.genreHelper.height = Number(
+					height
+						.split("")
+						.splice(0, height.length - 2)
+						.join("")
+				);
+				this.genreHelper.width = Number(
+					width
+						.split("")
+						.splice(0, width.length - 2)
+						.join("")
+				);
+
+				this.saveGenreHelper();
+			};
+		},
+		toggleGenreHelper() {
+			this.genreHelper.shown = !this.genreHelper.shown;
+			this.saveGenreHelper();
+		},
+		resetGenreHelper() {
+			this.genreHelper.top = 0;
+			this.genreHelper.left = 0;
+			this.genreHelper.width = 200;
+			this.genreHelper.height = 200;
+			this.saveGenreHelper();
+		},
+		saveGenreHelper() {
+			localStorage.setItem(
+				"genreHelper",
+				JSON.stringify({
+					height: this.genreHelper.height,
+					width: this.genreHelper.width,
+					top: this.genreHelper.top,
+					left: this.genreHelper.left,
+					shown: this.genreHelper.shown
+				})
+			);
+		},
+		...mapActions("admin/songs", [
+			"stopVideo",
+			"loadVideoById",
+			"pauseVideo",
+			"getCurrentTime",
+			"editSong",
+			"updateSongField",
+			"selectDiscogsInfo"
+		]),
+		...mapActions("modals", ["closeModal"])
+	},
+	mounted() {
+		// if (this.modals.editSong = false) this.video.player.stopVideo();
+
+		// this.loadVideoById(
+		//   this.editing.song.songId,
+		//   this.editing.song.skipDuration
+		// );
+
+		if (localStorage.genreHelper) {
+			const genreHelper = JSON.parse(localStorage.getItem("genreHelper"));
+			this.genreHelper.height = genreHelper.height;
+			this.genreHelper.width = genreHelper.width;
+			this.genreHelper.top = genreHelper.top;
+			this.genreHelper.left = genreHelper.left;
+			this.genreHelper.shown = genreHelper.shown;
+		}
+
+		this.discogsQuery = this.editing.song.title;
+
+		lofig.get("cookie.secure", res => {
+			this.useHTTPS = res;
+		});
+
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+
+		this.interval = setInterval(() => {
+			if (
+				this.video.paused === false &&
+				this.playerReady &&
+				this.video.player.getCurrentTime() -
+					this.editing.song.skipDuration >
+					this.editing.song.duration
+			) {
+				this.video.paused = false;
 				this.video.player.stopVideo();
+			}
+			if (this.playerReady) {
+				this.youtubeVideoCurrentTime = this.video.player
+					.getCurrentTime()
+					.toFixed(3);
+			}
+
+			if (this.video.paused === false) this.drawCanvas();
+		}, 200);
+
+		this.video.player = new window.YT.Player("player", {
+			height: 298,
+			width: 530,
+			videoId: this.editing.song.songId,
+			playerVars: {
+				controls: 0,
+				iv_load_policy: 3,
+				rel: 0,
+				showinfo: 0,
+				autoplay: 1
 			},
-			editSong: function (song, index, type) {
-				let _this = this;
-				this.video.player.loadVideoById(song.songId, this.editing.song.skipDuration);
-				let newSong = {};
-				for (let n in song) {
-					newSong[n] = song[n];
+			startSeconds: this.editing.song.skipDuration,
+			events: {
+				onReady: () => {
+					let volume = parseInt(localStorage.getItem("volume"));
+					volume = typeof volume === "number" ? volume : 20;
+					console.log(`Seekto: ${this.editing.song.skipDuration}`);
+					this.video.player.seekTo(this.editing.song.skipDuration);
+					this.video.player.setVolume(volume);
+					if (volume > 0) this.video.player.unMute();
+					this.youtubeVideoDuration = this.video.player.getDuration();
+					this.youtubeVideoNote = "(~)";
+					this.playerReady = true;
+
+					this.drawCanvas();
+				},
+				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();
+						this.youtubeVideoDuration = youtubeDuration;
+						this.youtubeVideoNote = "";
+						youtubeDuration -= this.editing.song.skipDuration;
+						if (this.editing.song.duration > youtubeDuration + 1) {
+							this.video.player.stopVideo();
+							this.video.paused = true;
+							return Toast.methods.addToast(
+								"Video can't play. Specified duration is bigger than the YouTube song duration.",
+								4000
+							);
+						}
+						if (this.editing.song.duration <= 0) {
+							this.video.player.stopVideo();
+							this.video.paused = true;
+							return Toast.methods.addToast(
+								"Video can't play. Specified duration has to be more than 0 seconds.",
+								4000
+							);
+						}
+
+						if (
+							this.video.player.getCurrentTime() <
+							this.editing.song.skipDuration
+						) {
+							return this.video.player.seekTo(
+								this.editing.song.skipDuration
+							);
+						}
+					} else if (event.data === 2) {
+						this.video.paused = true;
+					}
+
+					return false;
 				}
-				this.editing = {
-					index,
-					song: newSong,
-					type
-				};
-				_this.socket.emit('reports.getReportsForSong', song.songId, res => {
-					if (res.status === 'success') _this.reports = res.data;
-				});
-				this.$parent.toggleModal();
-			},
-			stopVideo: function () {
-				this.video.player.stopVideo();
 			}
-		}
+		});
+
+		let volume = parseInt(localStorage.getItem("volume"));
+		document.getElementById("volumeSlider").value = volume =
+			typeof volume === "number" ? volume : 20;
+	},
+	beforeDestroy() {
+		this.playerReady = false;
+		clearInterval(this.interval);
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	input[type=range] {
-		-webkit-appearance: none;
-		width: 100%;
-		margin: 7.3px 0;
-	}
+<style lang="scss">
+@import "styles/global.scss";
 
-	input[type=range]:focus {
-		outline: none;
-	}
+#genre-helper-container {
+	background-color: white;
+	position: fixed;
+	z-index: 10000000;
+	resize: both;
+	overflow: auto;
+	border: 1px solid #d3d3d3;
+	min-height: 50px !important;
+	min-width: 50px !important;
 
-	input[type=range]::-webkit-slider-runnable-track {
+	.genre-helper-header {
+		cursor: move;
+		z-index: 100000001;
+		background-color: $musareBlue;
+		padding: 10px;
+		display: block;
+		height: 10px;
 		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
 	}
 
-	input[type=range]::-webkit-slider-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
-	}
+	.genre-helper-body {
+		display: flex;
+		flex-wrap: wrap;
+		justify-content: space-evenly;
 
-	input[type=range]::-moz-range-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
+		span {
+			padding: 3px 6px;
+		}
 	}
+}
 
-	input[type=range]::-moz-range-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
+.song-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
 	}
 
-	input[type=range]::-ms-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 1.3px;
-	}
+	.modal-card {
+		width: 1160px;
+		height: 100%;
 
-	input[type=range]::-ms-fill-lower {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
+		.modal-card-body {
+			padding: 16px;
+		}
 	}
+}
+</style>
 
-	input[type=range]::-ms-fill-upper {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+.modal-card-body > div {
+	display: flex;
+	height: 100%;
+}
 
-	input[type=range]::-ms-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 15px;
-		width: 15px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: 1.5px;
-	}
+.left-section {
+	display: flex;
+	flex-direction: column;
+	margin-right: 16px;
 
-	.controls {
+	.top-section {
 		display: flex;
-		flex-direction: column;
-		align-items: center;
-	}
 
-	.artist-genres {
-		display: flex;
-    	justify-content: space-between;
-	}
+		.player-section {
+			width: 530px;
+			display: flex;
+			flex-direction: column;
 
-	#volumeSlider { margin-bottom: 15px; }
+			.player-footer {
+				background-color: #f4f4f4;
+				border: 1px rgba(163, 224, 255, 0.75) solid;
+				border-radius: 0px 0px 5px 5px;
+				display: flex;
+				justify-content: space-between;
+				height: 54px;
 
-	.has-text-centered { padding: 10px; }
+				> * {
+					width: 33.3%;
+					display: flex;
+					align-items: center;
+				}
 
-	.thumbnail-preview {
-		display: flex;
-		margin: 0 auto 25px auto;
-		max-width: 200px;
-		width: 100%;
-	}
+				.player-footer-left {
+					flex: 1;
 
-	.modal-card-body, .modal-card-foot { border-top: 0; }
+					.material-icons {
+						font-size: 38px;
+						cursor: pointer;
+					}
 
-	.label, .checkbox, h5 {
-		font-weight: normal;
-	}
+					.player-play-pause {
+						color: $musareBlue;
+					}
 
-	.video-container {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		padding: 10px;
+					.player-stop {
+						color: $red;
+					}
 
-		iframe { pointer-events: none; }
+					.player-fast-forward {
+						color: $green;
+					}
+				}
+
+				.player-footer-center {
+					justify-content: center;
+					align-items: center;
+					flex: 2;
+					font-size: 21px;
+					font-weight: 400;
+
+					img {
+						height: 21px;
+						margin-right: 12px;
+						filter: invert(26%) sepia(54%) saturate(6317%)
+							hue-rotate(2deg) brightness(92%) contrast(115%);
+					}
+				}
+
+				.player-footer-right {
+					justify-content: right;
+					flex: 1;
+
+					#volumeSlider {
+						width: 126px;
+						margin-right: 10px;
+						background-color: #f4f4f4;
+					}
+				}
+			}
+		}
+
+		.thumbnail-preview {
+			width: 189px;
+			height: 189px;
+			margin-left: 16px;
+		}
 	}
 
-	.save-changes { color: #fff; }
+	.edit-section {
+		width: 735px;
+		background-color: #f4f4f4;
+		border: 1px rgba(163, 224, 255, 0.75) solid;
+		margin-top: 16px;
+		flex: 1;
+		overflow: auto;
+		border-radius: 5px;
 
-	.tag:not(:last-child) { margin-right: 5px; }
+		.album-get-button {
+			background-color: $purple;
+			color: white;
+			width: 32px;
+			text-align: center;
+			border-width: 0;
+		}
 
-	.reports-length {
-		color: #03A9F4;
-		font-weight: bold;
-		display: flex;
-		justify-content: center;
+		.add-button {
+			background-color: $musareBlue !important;
+			width: 32px;
+
+			i {
+				font-size: 32px;
+			}
+		}
+
+		> div {
+			margin: 16px;
+		}
+
+		input {
+			width: 100%;
+		}
+
+		.title-container {
+			width: calc((100% - 32px) / 2);
+		}
+
+		.duration-container {
+			margin-right: 16px;
+			margin-left: 16px;
+			width: calc((100% - 32px) / 4);
+		}
+
+		.skip-duration-container {
+			width: calc((100% - 32px) / 4);
+		}
+
+		.album-art-container {
+			width: 100%;
+		}
+
+		.artists-container {
+			width: calc((100% - 32px) / 3);
+			position: relative;
+		}
+
+		.genres-container {
+			width: calc((100% - 32px) / 3);
+			margin-left: 16px;
+			margin-right: 16px;
+			position: relative;
+
+			label {
+				display: flex;
+
+				i {
+					font-size: 15px;
+					align-self: center;
+					margin-left: 5px;
+					color: $musareBlue;
+					cursor: pointer;
+					-webkit-user-select: none;
+					-moz-user-select: none;
+					-ms-user-select: none;
+					user-select: none;
+				}
+			}
+		}
+
+		.youtube-id-container {
+			width: calc((100% - 32px) / 3);
+		}
+
+		.list-item-circle {
+			background-color: $musareBlue;
+			width: 16px;
+			height: 16px;
+			border-radius: 8px;
+			cursor: pointer;
+			margin-right: 8px;
+			float: left;
+			-webkit-touch-callout: none;
+			-webkit-user-select: none;
+			-khtml-user-select: none;
+			-moz-user-select: none;
+			-ms-user-select: none;
+			user-select: none;
+
+			i {
+				color: $musareBlue;
+				font-size: 14px;
+				margin-left: 1px;
+			}
+		}
+
+		.list-item-circle:hover,
+		.list-item-circle:focus {
+			i {
+				color: white;
+			}
+		}
+
+		.list-item > p {
+			line-height: 16px;
+			word-wrap: break-word;
+			width: calc(100% - 24px);
+			left: 24px;
+			float: left;
+			margin-bottom: 8px;
+		}
+
+		.list-item:last-child > p {
+			margin-bottom: 0;
+		}
+
+		.autosuggest-container {
+			position: absolute;
+			background: white;
+			width: calc(100% + 1px);
+			top: 57px;
+			z-index: 200;
+			overflow: auto;
+			max-height: 100%;
+			clear: both;
+
+			.autosuggest-item {
+				padding: 8px;
+				display: block;
+				border: 1px solid #dbdbdb;
+				margin-top: -1px;
+				line-height: 16px;
+				cursor: pointer;
+				-webkit-user-select: none;
+				-ms-user-select: none;
+				-moz-user-select: none;
+				user-select: none;
+			}
+
+			.autosuggest-item:hover,
+			.autosuggest-item:focus {
+				background-color: #eee;
+			}
+
+			.autosuggest-item:first-child {
+				border-top: none;
+			}
+
+			.autosuggest-item:last-child {
+				border-radius: 0 0 3px 3px;
+			}
+		}
 	}
+}
+
+.right-section {
+	display: flex;
+	flex-wrap: wrap;
+
+	.api-section {
+		width: 376px;
+		background-color: #f4f4f4;
+		border: 1px rgba(163, 224, 255, 0.75) solid;
+		border-radius: 5px;
+		padding: 16px;
+		overflow: auto;
+		height: 100%;
+
+		> label {
+			margin-top: 12px;
+		}
+
+		.top-container {
+			display: flex;
+
+			img {
+				height: 85px;
+				width: 85px;
+			}
+
+			.right-container {
+				padding: 8px;
+				display: flex;
+				flex-direction: column;
+				flex: 1;
+
+				.album-title {
+					flex: 1;
+					font-weight: 600;
+				}
+
+				.bottom-row {
+					display: flex;
+					flex-flow: row;
+					line-height: 15px;
+
+					img {
+						height: 15px;
+						align-self: end;
+						flex: 1;
+						user-select: none;
+						-moz-user-select: none;
+						-ms-user-select: none;
+						-webkit-user-select: none;
+						cursor: pointer;
+					}
+
+					p {
+						text-align: right;
+					}
+
+					.type-year {
+						font-size: 13px;
+						align-self: end;
+					}
+				}
+			}
+		}
+
+		.bottom-container {
+			padding: 12px;
+
+			.bottom-container-field {
+				line-height: 16px;
+				margin-bottom: 8px;
+				font-weight: 600;
+
+				span {
+					font-weight: 400;
+				}
+			}
+
+			.bottom-container-field:last-of-type {
+				margin-bottom: 0;
+			}
+		}
+
+		.selected-discogs-info {
+			background-color: white;
+			border: 1px solid $purple;
+			border-radius: 5px;
+			margin-bottom: 16px;
+
+			.selected-discogs-info-none {
+				font-size: 18px;
+				text-align: center;
+			}
+
+			.bottom-row > p {
+				flex: 1;
+			}
+		}
+
+		.api-result {
+			background-color: white;
+			border: 0.5px solid $musareBlue;
+			border-radius: 5px;
+			margin-bottom: 16px;
+		}
+
+		button {
+			background-color: $musareBlue !important;
+		}
+
+		.tracks {
+			margin-top: 12px;
+
+			.track:first-child {
+				margin-top: 0;
+				border-radius: 3px 3px 0 0;
+			}
+
+			.track:last-child {
+				border-radius: 0 0 3px 3px;
+			}
+
+			.track {
+				border: 0.5px solid black;
+				margin-top: -1px;
+				line-height: 16px;
+				display: flex;
+				cursor: pointer;
+
+				span {
+					font-weight: 600;
+					display: inline-block;
+					margin-top: 7px;
+					margin-bottom: 7px;
+					margin-left: 7px;
+				}
 
-	.report-link {
-		color: #000;
+				p {
+					display: inline-block;
+					margin: 7px;
+					flex: 1;
+				}
+			}
+
+			.track:hover,
+			.track:focus {
+				background-color: #f4f4f4;
+			}
+		}
+
+		.discogs-load-more {
+			margin-bottom: 8px;
+		}
 	}
+}
+
+.footer-buttons {
+	margin-left: auto;
+	margin-right: auto;
+}
+
+input[type="range"] {
+	-webkit-appearance: none;
+	width: 100%;
+	margin: 8.5px 0;
+}
+input[type="range"]:focus {
+	outline: none;
+}
+input[type="range"]::-webkit-slider-runnable-track {
+	width: 100%;
+	height: 3px;
+	cursor: pointer;
+	box-shadow: none;
+	background: #7e7e7e;
+	border-radius: none;
+	border: none;
+}
+input[type="range"]::-webkit-slider-thumb {
+	box-shadow: none;
+	border: none;
+	height: 20px;
+	width: 20px;
+	border-radius: 100px;
+	background: #03a9f4;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -8.5px;
+}
+input[type="range"]:focus::-webkit-slider-runnable-track {
+	background: #7e7e7e;
+}
+input[type="range"]::-moz-range-track {
+	width: 100%;
+	height: 3px;
+	cursor: pointer;
+	box-shadow: none;
+	background: #7e7e7e;
+	border-radius: none;
+	border: none;
+}
+input[type="range"]::-moz-range-thumb {
+	box-shadow: none;
+	border: none;
+	height: 20px;
+	width: 20px;
+	border-radius: 100px;
+	background: #03a9f4;
+	cursor: pointer;
+}
+input[type="range"]::-ms-track {
+	width: 100%;
+	height: 3px;
+	cursor: pointer;
+	background: transparent;
+	border-color: transparent;
+	color: transparent;
+}
+input[type="range"]::-ms-fill-lower {
+	background: #717171;
+	border: none;
+	border-radius: none;
+	box-shadow: none;
+}
+input[type="range"]::-ms-fill-upper {
+	background: #7e7e7e;
+	border: none;
+	border-radius: none;
+	box-shadow: none;
+}
+input[type="range"]::-ms-thumb {
+	box-shadow: none;
+	border: none;
+	height: 20px;
+	width: 20px;
+	border-radius: 100px;
+	background: #03a9f4;
+	cursor: pointer;
+	height: 3px;
+}
+input[type="range"]:focus::-ms-fill-lower {
+	background: #7e7e7e;
+}
+input[type="range"]:focus::-ms-fill-upper {
+	background: #7e7e7e;
+}
 </style>

+ 314 - 173
frontend/components/Modals/EditStation.vue

@@ -1,204 +1,345 @@
 <template>
-	<div>
-		<modal title='Edit Station'>
-			<div slot='body'>
-				<label class='label'>Name</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Station Name' v-model='editing.name'>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updateName()' href='#'>Update</a>
-					</p>
-				</div>
-				<label class='label'>Display name</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Station Display Name' v-model='editing.displayName'>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updateDisplayName()' href='#'>Update</a>
-					</p>
-				</div>
-				<label class='label'>Description</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Station Display Name' v-model='editing.description'>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updateDescription()' href='#'>Update</a>
-					</p>
-				</div>
-				<label class='label'>Privacy</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-							<span class='select'>
-								<select v-model='editing.privacy'>
-									<option :value='"public"'>Public</option>
-									<option :value='"unlisted"'>Unlisted</option>
-									<option :value='"private"'>Private</option>
-								</select>
-							</span>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updatePrivacy()' href='#'>Update</a>
-					</p>
-				</div>
-				<div class='control is-grouped' v-if="editing.type === 'community'">
-					<p class="control is-expanded party-mode-outer">
-						<label class="checkbox party-mode-inner">
-							<input type="checkbox" v-model="editing.partyMode">
-							&nbsp;Party mode
-						</label>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updatePartyMode()' href='#'>Update</a>
-					</p>
-				</div>
-				<button class='button is-danger' @click='deleteStation()' v-if="$parent.type === 'community'">Delete station</button>
+	<modal title="Edit Station">
+		<template v-slot:body>
+			<label class="label">Name</label>
+			<p class="control">
+				<input
+					v-model="editing.name"
+					class="input"
+					type="text"
+					placeholder="Station Name"
+				/>
+			</p>
+			<label class="label">Display name</label>
+			<p class="control">
+				<input
+					v-model="editing.displayName"
+					class="input"
+					type="text"
+					placeholder="Station Display Name"
+				/>
+			</p>
+			<label class="label">Description</label>
+			<p class="control">
+				<input
+					v-model="editing.description"
+					class="input"
+					type="text"
+					placeholder="Station Description"
+				/>
+			</p>
+			<label class="label">Privacy</label>
+			<p class="control">
+				<span class="select">
+					<select v-model="editing.privacy">
+						<option value="public">Public</option>
+						<option value="unlisted">Unlisted</option>
+						<option value="private">Private</option>
+					</select>
+				</span>
+			</p>
+			<br />
+			<p class="control">
+				<label class="checkbox party-mode-inner">
+					<input v-model="editing.partyMode" type="checkbox" />
+					&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="station.partyMode">
+				<br />
+				<br />
+				<label class="label">Queue lock</label>
+				<small v-if="station.partyMode"
+					>With the queue locked, only owners (you) can add songs to
+					the queue.</small
+				>
+				<br />
+				<button
+					v-if="!station.locked"
+					class="button is-danger"
+					@click="$parent.toggleLock()"
+				>
+					Lock the queue
+				</button>
+				<button
+					v-if="station.locked"
+					class="button is-success"
+					@click="$parent.toggleLock()"
+				>
+					Unlock the queue
+				</button>
 			</div>
-		</modal>
-	</div>
+		</template>
+		<template v-slot:footer>
+			<button class="button is-success" v-on:click="update()">
+				Update Settings
+			</button>
+			<button
+				v-if="station.type === 'community'"
+				class="button is-danger"
+				@click="deleteStation()"
+			>
+				Delete station
+			</button>
+		</template>
+	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-
-	export default {
-		data: function() {
-			return {
-				editing: {
-					_id: '',
-					name: '',
-					type: '',
-					displayName: '',
-					description: '',
-					privacy: 'private',
-					partyMode: false
-				}
-			}
+import { mapState } from "vuex";
+
+import { Toast } from "vue-roaster";
+import Modal from "./Modal.vue";
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	computed: mapState("station", {
+		station: state => state.station,
+		editing: state => state.editing
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			return socket;
+		});
+	},
+	methods: {
+		update() {
+			if (this.station.name !== this.editing.name) this.updateName();
+			if (this.station.displayName !== this.editing.displayName)
+				this.updateDisplayName();
+			if (this.station.description !== this.editing.description)
+				this.updateDescription();
+			if (this.station.privacy !== this.editing.privacy)
+				this.updatePrivacy();
+			if (this.station.partyMode !== this.editing.partyMode)
+				this.updatePartyMode();
 		},
-		methods: {
-			updateName: function () {
-				let _this = this;
-				this.socket.emit('stations.updateName', this.editing._id, this.editing.name, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.name = _this.editing.name;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === _this.editing._id) return _this.$parent.stations[index].name = _this.editing.name;
-							});
+		updateName() {
+			const { name } = this.editing;
+			if (!validation.isLength(name, 2, 16))
+				return Toast.methods.addToast(
+					"Name must have between 2 and 16 characters.",
+					8000
+				);
+			if (!validation.regex.az09_.test(name))
+				return Toast.methods.addToast(
+					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateName",
+				this.editing._id,
+				name,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.name = name;
+							return name;
 						}
+
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[index].name = name;
+								return name;
+							}
+
+							return false;
+						});
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updateDisplayName: function () {
-				let _this = this;
-				this.socket.emit('stations.updateDisplayName', this.editing._id, this.editing.displayName, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.displayName = _this.editing.displayName;
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updateDisplayName() {
+			const { displayName } = this.editing;
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateDisplayName",
+				this.editing._id,
+				displayName,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.displayName = displayName;
 						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === _this.editing._id) return _this.$parent.stations[index].displayName = _this.editing.displayName;
+							this.$parent.stations.forEach((station, index) => {
+								if (station._id === this.editing._id)
+									this.$parent.stations[
+										index
+									].displayName = displayName;
+								return displayName;
 							});
 						}
 					}
 					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updateDescription: function () {
-				let _this = this;
-				this.socket.emit('stations.updateDescription', this.editing._id, this.editing.description, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.description = _this.editing.description;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].description = _this.editing.description;
-							});
+				}
+			);
+		},
+		updateDescription() {
+			const { description } = this.editing;
+			if (!validation.isLength(description, 2, 200))
+				return Toast.methods.addToast(
+					"Description must have between 2 and 200 characters.",
+					8000
+				);
+
+			let characters = description.split("");
+			characters = characters.filter(character => {
+				return character.charCodeAt(0) === 21328;
+			});
+
+			if (characters.length !== 0)
+				return Toast.methods.addToast(
+					"Invalid description format. Swastika's are not allowed.",
+					8000
+				);
+
+			return this.socket.emit(
+				"stations.updateDescription",
+				this.editing._id,
+				description,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.description = description;
+							return description;
 						}
+
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].description = description;
+								return description;
+							}
+
+							return false;
+						});
+
 						return Toast.methods.addToast(res.message, 4000);
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updatePrivacy: function () {
-				let _this = this;
-				this.socket.emit('stations.updatePrivacy', this.editing._id, this.editing.privacy, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.privacy = _this.editing.privacy;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].privacy = _this.editing.privacy;
-							});
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updatePrivacy() {
+			return this.socket.emit(
+				"stations.updatePrivacy",
+				this.editing._id,
+				this.editing.privacy,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.privacy = this.editing.privacy;
+							return this.editing.privacy;
 						}
+
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].privacy = this.editing.privacy;
+								return this.editing.privacy;
+							}
+
+							return false;
+						});
+
 						return Toast.methods.addToast(res.message, 4000);
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updatePartyMode: function () {
-				let _this = this;
-				this.socket.emit('stations.updatePartyMode', this.editing._id, this.editing.partyMode, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.partyMode = _this.editing.partyMode;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].partyMode = _this.editing.partyMode;
-							});
+
+					return Toast.methods.addToast(res.message, 8000);
+				}
+			);
+		},
+		updatePartyMode() {
+			return this.socket.emit(
+				"stations.updatePartyMode",
+				this.editing._id,
+				this.editing.partyMode,
+				res => {
+					if (res.status === "success") {
+						if (this.station) {
+							this.station.partyMode = this.editing.partyMode;
+							return this.editing.partyMode;
 						}
+
+						this.$parent.stations.forEach((station, index) => {
+							if (station._id === this.editing._id) {
+								this.$parent.stations[
+									index
+								].partyMode = this.editing.partyMode;
+								return this.editing.partyMode;
+							}
+
+							return false;
+						});
+
 						return Toast.methods.addToast(res.message, 4000);
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			deleteStation: function() {
-				let _this = this;
-				this.socket.emit('stations.remove', this.editing._id, res => {
-					Toast.methods.addToast(res.message, 8000);
-					if (res.status === 'success' && _this.$parent.station) location.href = '/';
-				});
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-			});
-		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.editStation = false;
-			},
-			editStation: function(station) {
-				for (let prop in station) {
-					this.editing[prop] = station[prop];
+
+					return Toast.methods.addToast(res.message, 8000);
 				}
-				this.$parent.modals.editStation = true;
-			}
+			);
 		},
-		components: { Modal }
-	}
-</script>
-
-<style type='scss' scoped>
-	.controls {
-		display: flex;
-
-		a {
-			display: flex;
-    		align-items: center;
+		deleteStation() {
+			this.socket.emit("stations.remove", this.editing._id, res => {
+				Toast.methods.addToast(res.message, 8000);
+			});
 		}
-	}
+	},
+	components: { Modal }
+};
+</script>
 
-	.table { margin-bottom: 0; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	h5 { padding: 20px 0; }
+.controls {
+	display: flex;
 
-	.party-mode-inner, .party-mode-outer {
+	a {
 		display: flex;
 		align-items: center;
 	}
-</style>
+}
+
+.table {
+	margin-bottom: 0;
+}
+
+h5 {
+	padding: 20px 0;
+}
+
+.party-mode-inner,
+.party-mode-outer {
+	display: flex;
+	align-items: center;
+}
+
+.select:after {
+	border-color: $primary-color;
+}
+</style>

+ 191 - 58
frontend/components/Modals/EditUser.vue

@@ -1,14 +1,30 @@
 <template>
 	<div>
-		<modal title='Edit User'>
-			<div slot='body'>
+		<modal title="Edit User">
+			<div slot="body">
 				<p class="control has-addons">
-					<input class='input is-expanded' type='text' placeholder='Username' v-model='editing.username' autofocus>
-					<a class="button is-info" @click='updateUsername()'>Update Username</a>
+					<input
+						v-model="editing.username"
+						class="input is-expanded"
+						type="text"
+						placeholder="Username"
+						autofocus
+					/>
+					<a class="button is-info" v-on:click="updateUsername()"
+						>Update Username</a
+					>
 				</p>
 				<p class="control has-addons">
-					<input class='input is-expanded' type='text' placeholder='Username' v-model='editing.email' autofocus>
-					<a class="button is-info" @click='updateEmail()'>Update Email Address</a>
+					<input
+						v-model="editing.email.address"
+						class="input is-expanded"
+						type="text"
+						placeholder="Email Address"
+						autofocus
+					/>
+					<a class="button is-info" v-on:click="updateEmail()"
+						>Update Email Address</a
+					>
 				</p>
 				<p class="control has-addons">
 					<span class="select">
@@ -17,17 +33,55 @@
 							<option>admin</option>
 						</select>
 					</span>
-					<a class="button is-info" @click='updateRole()'>Update Role</a>
+					<a class="button is-info" v-on: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
+						v-model="ban.reason"
+						class="input is-expanded"
+						type="text"
+						placeholder="Ban reason"
+						autofocus
+					/>
+					<a class="button is-error" v-on:click="banUser()"
+						>Ban user</a
+					>
 				</p>
 			</div>
-			<div slot='footer'>
+			<div slot="footer">
 				<!--button class='button is-warning'>
 					<span>&nbsp;Send Verification Email</span>
 				</button>
 				<button class='button is-warning'>
 					<span>&nbsp;Send Password Reset Email</span>
-				</button-->
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+        </button-->
+				<button class="button is-warning" v-on:click="removeSessions()">
+					<span>&nbsp;Remove all sessions</span>
+				</button>
+				<button
+					class="button is-danger"
+					@click="
+						closeModal({
+							sector: 'admin',
+							modal: 'editUser'
+						})
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
@@ -36,63 +90,142 @@
 </template>
 
 <script>
-	import io from '../../io';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
+import { mapState, mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+import io from "../../io";
+import Modal from "./Modal.vue";
+import validation from "../../validation";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				editing: {}
+export default {
+	components: { Modal },
+	data() {
+		return {
+			ban: {
+				expiresAt: "1h"
 			}
-		},
-		methods: {
-			updateUsername: function () {
-				this.socket.emit(`users.updateUsername`, this.editing._id, this.editing.username, res => {
+		};
+	},
+	computed: {
+		...mapState("admin/users", {
+			editing: state => state.editing
+		}),
+		...mapState({
+			userId: state => state.user.auth.userId
+		})
+	},
+	methods: {
+		updateUsername() {
+			const { username } = this.editing;
+			if (!validation.isLength(username, 2, 32))
+				return Toast.methods.addToast(
+					"Username must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(username))
+				return Toast.methods.addToast(
+					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				`users.updateUsername`,
+				this.editing._id,
+				username,
+				res => {
 					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			updateEmail: function () {
-				this.socket.emit(`users.updateEmail`, this.editing._id, this.editing.email, res => {
+				}
+			);
+		},
+		updateEmail() {
+			const { email } = this.editing;
+			if (!validation.isLength(email, 3, 254))
+				return Toast.methods.addToast(
+					"Email must have between 3 and 254 characters.",
+					8000
+				);
+			if (
+				email.indexOf("@") !== email.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(email)
+			)
+				return Toast.methods.addToast("Invalid email format.", 8000);
+
+			return this.socket.emit(
+				`users.updateEmail`,
+				this.editing._id,
+				email,
+				res => {
 					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			updateRole: function () {
-				let _this = this;
-				this.socket.emit(`users.updateRole`, this.editing._id, this.editing.role, res => {
+				}
+			);
+		},
+		updateRole() {
+			this.socket.emit(
+				`users.updateRole`,
+				this.editing._id,
+				this.editing.role,
+				res => {
 					Toast.methods.addToast(res.message, 4000);
 					if (
-							res.status === 'success' &&
-							_this.editing.role === 'default' &&
-							_this.editing._id === _this.$parent.$parent.$parent.userId
-					) location.reload();
-				});
-			}
+						res.status === "success" &&
+						this.editing.role === "default" &&
+						this.editing._id === this.userId
+					)
+						window.location.reload();
+				}
+			);
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => _this.socket = socket );
+		banUser() {
+			const { reason } = this.ban;
+			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
+				);
+
+			return this.socket.emit(
+				`users.banUserById`,
+				this.editing._id,
+				this.ban.reason,
+				this.ban.expiresAt,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.editUser = false;
-			},
-			editUser: function (user) {
-				this.editing = {
-					_id: user._id,
-					username: user.username,
-					email: user.email.address,
-					role: user.role
-				};
-				this.$parent.toggleModal();
-			}
-		}
+		removeSessions() {
+			this.socket.emit(`users.removeSessions`, this.editing._id, res => {
+				Toast.methods.addToast(res.message, 4000);
+			});
+		},
+		...mapActions("modals", ["closeModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			return socket;
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.save-changes { color: #fff; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.save-changes {
+	color: $white;
+}
+
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
 
-	.tag:not(:last-child) { margin-right: 5px; }
-</style>
+.select:after {
+	border-color: $primary-color;
+}
+</style>

+ 71 - 19
frontend/components/Modals/IssuesModal.vue

@@ -1,15 +1,35 @@
 <template>
-	<modal title='Report'>
-		<div slot='body'>
+	<modal title="Report">
+		<div slot="body">
+			<router-link
+				v-if="$route.query.returnToSong"
+				class="button is-dark back-to-song"
+				:to="{
+					path: '/admin/songs',
+					query: { id: report.songId }
+				}"
+			>
+				<i class="material-icons">keyboard_return</i> &nbsp; Edit Song
+			</router-link>
+
 			<article class="message">
 				<div class="message-body">
-					<strong>Song ID: </strong>{{ $parent.editing.songId }}<br/ >
-					<strong>Created By: </strong>{{ $parent.editing.createdBy }}<br/ >
-					<strong>Created At: </strong>{{ $parent.editing.createdAt }}<br/ >
-					<span v-if='$parent.editing.description'><strong>Description: </strong>{{ $parent.editing.description }}</span>
+					<strong>Song ID:</strong>
+					{{ report.songId }}
+					<br />
+					<strong>Created By:</strong>
+					{{ report.createdBy }}
+					<br />
+					<strong>Created At:</strong>
+					{{ report.createdAt }}
+					<br />
+					<span v-if="report.description">
+						<strong>Description:</strong>
+						{{ report.description }}
+					</span>
 				</div>
 			</article>
-			<table class='table is-narrow' v-if='$parent.editing.issues.length > 0'>
+			<table v-if="report.issues.length > 0" class="table is-narrow">
 				<thead>
 					<tr>
 						<td>Issue</td>
@@ -17,7 +37,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for='(index, issue) in $parent.editing.issues' track-by='$index'>
+					<tr v-for="(issue, index) in report.issues" :key="index">
 						<td>
 							<span>{{ issue.name }}</span>
 						</td>
@@ -28,11 +48,24 @@
 				</tbody>
 			</table>
 		</div>
-		<div slot='footer'>
-			<a class='button is-primary' @click='$parent.resolve($parent.editing._id)' href='#'>
+		<div slot="footer">
+			<a
+				class="button is-primary"
+				href="#"
+				@click="$parent.resolve(report._id)"
+			>
 				<span>Resolve</span>
 			</a>
-			<a class='button is-danger' @click='$parent.toggleModal()' href='#'>
+			<a
+				class="button is-danger"
+				@click="
+					closeModal({
+						sector: 'admin',
+						modal: 'viewReport'
+					})
+				"
+				href="#"
+			>
 				<span>Cancel</span>
 			</a>
 		</div>
@@ -40,14 +73,33 @@
 </template>
 
 <script>
-	import Modal from './Modal.vue';
+import { mapActions, mapState } from "vuex";
+
+import Modal from "./Modal.vue";
 
-	export default {
-		components: { Modal },
-		events: {
-			closeModal: function () {
-				this.$parent.modals.report = false;
-			}
+export default {
+	computed: {
+		...mapState("admin/reports", {
+			report: state => state.report
+		})
+	},
+	mounted() {
+		if (this.$route.query.returnToSong) {
+			this.closeModal({ sector: "admin", modal: "editSong" });
 		}
-	}
+	},
+	methods: {
+		...mapActions("modals", ["closeModal"])
+	},
+	components: { Modal }
+};
 </script>
+
+<style lang="scss">
+@import "styles/global.scss";
+
+.back-to-song {
+	display: flex;
+	margin-bottom: 20px;
+}
+</style>

+ 123 - 48
frontend/components/Modals/Login.vue

@@ -1,69 +1,144 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Login</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal is-active">
+		<div
+			class="modal-background"
+			@click="
+				closeModal({
+					sector: 'header',
+					modal: 'login'
+				})
+			"
+		/>
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					Login
+				</p>
+				<button
+					class="delete"
+					@click="
+						closeModal({
+							sector: 'header',
+							modal: 'login'
+						})
+					"
+				/>
 			</header>
-			<section class='modal-card-body'>
+			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class='label'>Email</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Email...' v-model='$parent.login.email'>
+				<label class="label">Email</label>
+				<p class="control">
+					<input
+						v-model="email"
+						class="input"
+						type="text"
+						placeholder="Email..."
+					/>
+				</p>
+				<label class="label">Password</label>
+				<p class="control">
+					<input
+						v-model="password"
+						class="input"
+						type="password"
+						placeholder="Password..."
+						@keypress="$parent.submitOnEnter(submitModal, $event)"
+					/>
 				</p>
-				<label class='label'>Password</label>
-				<p class='control'>
-					<input class='input' type='password' placeholder='Password...' v-model='$parent.login.password' v-on:keypress='$parent.submitOnEnter(submitModal, $event)'>
+				<p>
+					By logging in/registering you agree to our
+					<router-link to="/terms"> Terms of Service </router-link
+					>&nbsp;and
+					<router-link to="/privacy"> Privacy Policy </router-link>.
 				</p>
-				<p>By logging in/registering you agree to our <a href="/terms" v-link="{ path: '/terms' }">Terms of Service</a> and <a href="/privacy" v-link="{ path: '/privacy' }">Privacy Policy</a>.</p>
 			</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"'>
-					<div class='icon'>
-						<img class='invert' src='/assets/social/github.svg'/>
+			<footer class="modal-card-foot">
+				<a
+					class="button is-primary"
+					href="#"
+					@click="submitModal('login')"
+					>Submit</a
+				>
+				<a
+					class="button is-github"
+					:href="serverDomain + '/auth/github/authorize'"
+					@click="githubRedirect()"
+				>
+					<div class="icon">
+						<img class="invert" src="/assets/social/github.svg" />
 					</div>
 					&nbsp;&nbsp;Login with GitHub
 				</a>
-				<a href='#' @click='resetPassword()'>Forgot password?</a>
+				<a href="/reset_password" v-on:click="resetPassword()"
+					>Forgot password?</a
+				>
 			</footer>
 		</div>
 	</div>
 </template>
 
 <script>
-	export default {
-		methods: {
-			toggleModal: function () {
-				if (this.$router._currentRoute.path === '/login') location.href = '/';
-				else this.$dispatch('toggleModal', 'login');
-			},
-			submitModal: function () {
-				this.$dispatch('login');
-				this.toggleModal();
-			},
-			resetPassword: function () {
-				this.toggleModal();
-				this.$router.go('/reset_password');
-			}
+import { mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+
+export default {
+	data() {
+		return {
+			email: "",
+			password: "",
+			serverDomain: ""
+		};
+	},
+	methods: {
+		submitModal() {
+			this.login({
+				email: this.email,
+				password: this.password
+			})
+				.then(res => {
+					if (res.status === "success") window.location.reload();
+				})
+				.catch(err => Toast.methods.addToast(err.message, 5000));
+		},
+		resetPassword() {
+			this.closeModal({ sector: "header", modal: "login" });
+			this.$router.go("/reset_password");
 		},
-		events: {
-			closeModal: function() {
-				this.$dispatch('toggleModal', 'login');
-			}
-		}
+		githubRedirect() {
+			localStorage.setItem("github_redirect", this.$route.path);
+		},
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("user/auth", ["login"])
+	},
+	mounted() {
+		lofig.get("serverDomain", res => {
+			this.serverDomain = res;
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.button.is-github {
-		background-color: #333;
-		color: #fff !important;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.button.is-github {
+	background-color: $dark-grey-2;
+	color: $white !important;
+}
+
+.is-github:focus {
+	background-color: $dark-grey-3;
+}
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 
-	.is-github:focus { background-color: #1a1a1a; }
-	.is-primary:focus { background-color: #029ce3 !important; }
+.invert {
+	filter: brightness(5);
+}
 
-	.invert { filter: brightness(5); }
-</style>
+a {
+	color: $primary-color;
+}
+</style>

+ 81 - 0
frontend/components/Modals/MobileAlert.vue

@@ -0,0 +1,81 @@
+<template>
+	<div class="modal" :class="{ 'is-active': isModalActive }">
+		<div class="modal-background" />
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<button class="delete" @click="toggleModal()" />
+			</header>
+			<section class="modal-card-body">
+				<h5>
+					Musare doesn't work very well on mobile right now, we are
+					working on this!
+				</h5>
+			</section>
+		</div>
+	</div>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			isModalActive: false
+		};
+	},
+	mounted() {
+		if (!localStorage.getItem("mobileOptimization")) {
+			this.toggleModal();
+			localStorage.setItem("mobileOptimization", true);
+		}
+	},
+	methods: {
+		toggleModal() {
+			this.isModalActive = !this.isModalActive;
+			if (this.isModalActive) {
+				setTimeout(() => {
+					this.isModalActive = false;
+				}, 4000);
+			}
+		}
+	},
+	events: {
+		closeModal() {
+			this.isModalActive = false;
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+@media (min-width: 735px) {
+	.modal {
+		display: none;
+	}
+}
+
+.modal-card {
+	margin: 0 20px !important;
+}
+
+.modal-card-head {
+	border-bottom: none;
+	background-color: ghostwhite;
+	padding: 15px;
+}
+
+.delete {
+	background: transparent;
+	right: 0;
+	position: absolute;
+	&:hover {
+		background: transparent;
+	}
+
+	&:before,
+	&:after {
+		background-color: #bbb;
+	}
+}
+</style>

+ 31 - 25
frontend/components/Modals/Modal.vue

@@ -1,37 +1,43 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>{{ title }}</p>
-				<button class='delete' @click='$parent.$parent.modals[this.type] = !$parent.$parent.modals[this.type]'></button>
+	<div class="modal is-active">
+		<div class="modal-background" @click="closeCurrentModal()" />
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					{{ title }}
+				</p>
+				<button class="delete" @click="closeCurrentModal()" />
 			</header>
-			<section class='modal-card-body'>
-				<slot name='body'></slot>
+			<section class="modal-card-body">
+				<slot name="body" />
 			</section>
-			<footer class='modal-card-foot' v-if='_slotContents["footer"] != null'>
-				<slot name='footer'></slot>
+			<footer class="modal-card-foot" v-if="$slots['footer'] != null">
+				<slot name="footer" />
 			</footer>
 		</div>
 	</div>
 </template>
 
 <script>
-	export default {
-		props: {
-			title: { type: String }
-		},
-		methods: {
-			toCamelCase: str => {
-				return str.toLowerCase()
-					.replace(/[-_]+/g, ' ')
-					.replace(/[^\w\s]/g, '')
-					.replace(/ (.)/g, function($1) { return $1.toUpperCase(); })
-					.replace(/ /g, '');
-			}
+import { mapActions } from "vuex";
+
+export default {
+	props: {
+		title: { type: String }
+	},
+	methods: {
+		toCamelCase: str => {
+			return str
+				.toLowerCase()
+				.replace(/[-_]+/g, " ")
+				.replace(/[^\w\s]/g, "")
+				.replace(/ (.)/g, $1 => $1.toUpperCase())
+				.replace(/ /g, "");
 		},
-		ready: function () {
-			this.type = this.toCamelCase(this.title);
-		}
+		...mapActions("modals", ["closeCurrentModal"])
+	},
+	mounted() {
+		this.type = this.toCamelCase(this.title);
 	}
+};
 </script>

+ 98 - 62
frontend/components/Modals/Playlists/Create.vue

@@ -1,83 +1,119 @@
 <template>
-	<modal title='Create Playlist'>
-		<div slot='body'>
-			<p class='control is-expanded'>
-				<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' autofocus @keyup.enter='createPlaylist()'>
+	<modal title="Create Playlist">
+		<template v-slot:body>
+			<p class="control is-expanded">
+				<input
+					v-model="playlist.displayName"
+					class="input"
+					type="text"
+					placeholder="Playlist Display Name"
+					autofocus
+					@keyup.enter="createPlaylist()"
+				/>
 			</p>
-		</div>
-		<div slot='footer'>
-			<a class='button is-info' @click='createPlaylist()'>Create Playlist</a>
-		</div>
+		</template>
+		<template v-slot:footer>
+			<a class="button is-info" v-on:click="createPlaylist()"
+				>Create Playlist</a
+			>
+		</template>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from '../Modal.vue';
-	import io from '../../../io';
+import { mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				playlist: {
-					displayName: null,
-					songs: [],
-					createdBy: this.$parent.$parent.username,
-					createdAt: Date.now()
-				}
-			}
-		},
-		methods: {
-			createPlaylist: function () {
-				let _this = this;
-				_this.socket.emit('playlists.create', _this.playlist, res => {
-					Toast.methods.addToast(res.message, 3000);
-				});
-				this.$parent.modals.createPlaylist = !this.$parent.modals.createPlaylist;
+import { Toast } from "vue-roaster";
+import Modal from "../Modal.vue";
+import io from "../../../io";
+import validation from "../../../validation";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			playlist: {
+				displayName: null,
+				songs: []
 			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
+	methods: {
+		createPlaylist() {
+			const { displayName } = this.playlist;
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit("playlists.create", this.playlist, res => {
+				Toast.methods.addToast(res.message, 3000);
+
+				if (res.status === "success") {
+					this.closeModal({
+						sector: "station",
+						modal: "createPlaylist"
+					});
+					this.editPlaylist(res.data._id);
+					this.openModal({
+						sector: "station",
+						modal: "editPlaylist"
+					});
+				}
 			});
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.createPlaylist = !this.$parent.modals.createPlaylist;
-			}
-		}
+		...mapActions("modals", ["closeModal", "openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.menu { padding: 0 20px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.menu-list li {
-		display: flex;
-		justify-content: space-between;
-	}
+.menu {
+	padding: 0 20px;
+}
 
-	.menu-list a:hover { color: #000 !important; }
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+}
 
-	li a {
-		display: flex;
-    	align-items: center;
-	}
+.menu-list a:hover {
+	color: $black !important;
+}
 
-	.controls {
-		display: flex;
+li a {
+	display: flex;
+	align-items: center;
+}
 
-		a {
-			display: flex;
-    		align-items: center;
-		}
-	}
+.controls {
+	display: flex;
 
-	.table {
-		margin-bottom: 0;
+	a {
+		display: flex;
+		align-items: center;
 	}
+}
+
+.table {
+	margin-bottom: 0;
+}
 
-	h5 { padding: 20px 0; }
-</style>
+h5 {
+	padding: 20px 0;
+}
+</style>

+ 363 - 192
frontend/components/Modals/Playlists/Edit.vue

@@ -1,239 +1,410 @@
 <template>
-	<modal title='Edit Playlist'>
-		<div slot='body'>
-			<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'>
-						<a :href='' target='_blank'>{{ song.title }}</a>
-						<div class='controls'>
-							<a href='#' @click='promoteSong(song.songId)'>
-								<i class='material-icons' v-if='$index > 0'>keyboard_arrow_up</i>
-								<i class='material-icons' style='opacity: 0' v-else>error</i>
+	<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">
+				<ul class="menu-list">
+					<li v-for="(song, index) in playlist.songs" :key="index">
+						<a href="#" target="_blank">{{ song.title }}</a>
+						<div class="controls">
+							<a href="#" v-on:click="promoteSong(song.songId)">
+								<i class="material-icons" v-if="index > 0"
+									>keyboard_arrow_up</i
+								>
+								<i
+									v-else
+									class="material-icons"
+									style="opacity: 0"
+									>error</i
+								>
 							</a>
-							<a href='#' @click='demoteSong(song.songId)'>
-								<i class='material-icons' v-if='playlist.songs.length - 1 !== $index'>keyboard_arrow_down</i>
-								<i class='material-icons' style='opacity: 0' v-else>error</i>
+							<a href="#" v-on:click="demoteSong(song.songId)">
+								<i
+									v-if="playlist.songs.length - 1 !== index"
+									class="material-icons"
+									>keyboard_arrow_down</i
+								>
+								<i
+									v-else
+									class="material-icons"
+									style="opacity: 0"
+									>error</i
+								>
+							</a>
+							<a
+								href="#"
+								v-on:click="removeSongFromPlaylist(song.songId)"
+							>
+								<i class="material-icons">delete</i>
 							</a>
-							<a href='#' @click='removeSongFromPlaylist(song.songId)'><i class='material-icons'>delete</i></a>
 						</div>
 					</li>
 				</ul>
 				<br />
 			</aside>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Search for Song to add' v-model='songQuery' autofocus @keyup.enter='searchForSongs()'>
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="songQuery"
+						class="input"
+						type="text"
+						placeholder="Search for Song to add"
+						autofocus
+						@keyup.enter="searchForSongs()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='searchForSongs()' href="#">Search</a>
+				<p class="control">
+					<a class="button is-info" @click="searchForSongs()" href="#"
+						>Search</a
+					>
 				</p>
 			</div>
-			<table class='table' v-if='songQueryResults.length > 0'>
+			<table v-if="songQueryResults.length > 0" class="table">
 				<tbody>
-				<tr v-for='result in songQueryResults'>
-					<td>
-						<img :src='result.thumbnail' />
-					</td>
-					<td>{{ result.title }}</td>
-					<td>
-						<a class='button is-success' @click='addSongToPlaylist(result.id)' href='#'>
-							Add
-						</a>
-					</td>
-				</tr>
+					<tr
+						v-for="(result, index) in songQueryResults"
+						:key="index"
+					>
+						<td>
+							<img :src="result.thumbnail" />
+						</td>
+						<td>{{ result.title }}</td>
+						<td>
+							<a
+								class="button is-success"
+								href="#"
+								@click="addSongToPlaylist(result.id)"
+								>Add</a
+							>
+						</td>
+					</tr>
 				</tbody>
 			</table>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='YouTube Playlist URL' v-model='importQuery' @keyup.enter="importPlaylist()">
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="importQuery"
+						class="input"
+						type="text"
+						placeholder="YouTube Playlist URL"
+						@keyup.enter="importPlaylist()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='importPlaylist()' href="#">Import</a>
+				<p class="control">
+					<a class="button is-info" @click="importPlaylist()" href="#"
+						>Import</a
+					>
 				</p>
 			</div>
 			<h5>Edit playlist details:</h5>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' @keyup.enter="renamePlaylist()">
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="playlist.displayName"
+						class="input"
+						type="text"
+						placeholder="Playlist Display Name"
+						@keyup.enter="renamePlaylist()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='renamePlaylist()' href="#">Rename</a>
+				<p class="control">
+					<a class="button is-info" @click="renamePlaylist()" href="#"
+						>Rename</a
+					>
 				</p>
 			</div>
 		</div>
-		<div slot='footer'>
-			<a class='button is-danger' @click='removePlaylist()' href="#">Remove Playlist</a>
+		<div slot="footer">
+			<a class="button is-danger" v-on:click="removePlaylist()" href="#"
+				>Remove Playlist</a
+			>
 		</div>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from '../Modal.vue';
-	import io from '../../../io';
-
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				playlist: {},
-				songQueryResults: [],
-				songQuery: '',
-				importQuery: ''
-			}
-		},
-		methods: {
-			searchForSongs: function () {
-				let _this = this;
-				let query = _this.songQuery;
-				if (query.indexOf('&index=') !== -1) {
-					query = query.split('&index=');
-					query.pop();
-					query = query.join('');
+import { mapState, mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+import Modal from "../Modal.vue";
+import io from "../../../io";
+import validation from "../../../validation";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			playlist: { songs: [] },
+			songQueryResults: [],
+			songQuery: "",
+			importQuery: ""
+		};
+	},
+	computed: mapState("user/playlists", {
+		editing: state => state.editing
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.getPlaylist", this.editing, res => {
+				if (res.status === "success") this.playlist = res.data;
+				this.playlist.oldId = res.data._id;
+			});
+			this.socket.on("event:playlist.addSong", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.songs.push(data.song);
+			});
+			this.socket.on("event:playlist.removeSong", data => {
+				if (this.playlist._id === data.playlistId) {
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId)
+							this.playlist.songs.splice(index, 1);
+					});
 				}
-				if (query.indexOf('&list=') !== -1) {
-					query = query.split('&list=');
-					query.pop();
-					query = query.join('');
+			});
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.displayName = data.displayName;
+			});
+			this.socket.on("event:playlist.moveSongToBottom", data => {
+				if (this.playlist._id === data.playlistId) {
+					let songIndex;
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId) songIndex = index;
+					});
+					const song = this.playlist.songs.splice(songIndex, 1)[0];
+					this.playlist.songs.push(song);
 				}
-				_this.socket.emit('apis.searchYoutube', query, res => {
-					if (res.status == 'success') {
-						_this.songQueryResults = [];
-						for (let i = 0; i < res.data.items.length; i++) {
-							_this.songQueryResults.push({
-								id: res.data.items[i].id.videoId,
-								url: `https://www.youtube.com/watch?v=${this.id}`,
-								title: res.data.items[i].snippet.title,
-								thumbnail: res.data.items[i].snippet.thumbnails.default.url
-							});
-						}
-					} else if (res.status === 'error') Toast.methods.addToast(res.message, 3000);
-				});
-			},
-			addSongToPlaylist: function (id) {
-				let _this = this;
-				_this.socket.emit('playlists.addSongToPlaylist', id, _this.playlist._id, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			importPlaylist: function () {
-				let _this = this;
-				Toast.methods.addToast('Starting to import your playlist. This can take some time to do.', 4000);
-				this.socket.emit('playlists.addSetToPlaylist', _this.importQuery, _this.playlist._id, res => {
-					if (res.status === 'success') _this.playlist.songs = res.data;
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			removeSongFromPlaylist: function (id) {
-				let _this = this;
-				this.socket.emit('playlists.removeSongFromPlaylist', id, _this.playlist._id, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			renamePlaylist: function () {
-				this.socket.emit('playlists.updateDisplayName', this.playlist._id, this.playlist.displayName, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			removePlaylist: function () {
-				let _this = this;
-				_this.socket.emit('playlists.remove', _this.playlist._id, res => {
-					Toast.methods.addToast(res.message, 3000);
-					if (res.status === 'success') {
-						_this.$parent.modals.editPlaylist = !_this.$parent.modals.editPlaylist;
+			});
+			this.socket.on("event:playlist.moveSongToTop", data => {
+				if (this.playlist._id === data.playlistId) {
+					let songIndex;
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId) songIndex = index;
+					});
+					const song = this.playlist.songs.splice(songIndex, 1)[0];
+					this.playlist.songs.unshift(song);
+				}
+			});
+		});
+	},
+	methods: {
+		formatTime(duration) {
+			if (duration <= 0) return "0 seconds";
+
+			const hours = Math.floor(duration / (60 * 60));
+			const formatHours = () => {
+				if (hours > 0) {
+					if (hours > 1) {
+						if (hours < 10) return `0${hours} hours `;
+						return `${hours} hours `;
 					}
-				});
-			},
-			promoteSong: function (songId) {
-				let _this = this;
-				_this.socket.emit('playlists.moveSongToTop', _this.playlist._id, songId, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			demoteSong: function (songId) {
-				let _this = this;
-				_this.socket.emit('playlists.moveSongToBottom', _this.playlist._id, songId, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			}
-		},
-		ready: function () {
-			let _this = this;
-			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;
-				});
-				_this.socket.on('event:playlist.addSong', (data) => {
-					if (_this.playlist._id === data.playlistId) _this.playlist.songs.push(data.song);
-				});
-				_this.socket.on('event:playlist.removeSong', (data) => {
-					if (_this.playlist._id === data.playlistId) {
-						_this.playlist.songs.forEach((song, index) => {
-							if (song.songId === data.songId) _this.playlist.songs.splice(index, 1);
-						});
+					return `0${hours} hour `;
+				}
+				return "";
+			};
+
+			const minutes = Math.floor((duration - hours) / 60);
+			const formatMinutes = () => {
+				if (minutes > 0) {
+					if (minutes > 1) {
+						if (minutes < 10) return `0${minutes} minutes `;
+						return `${minutes} minutes `;
 					}
-				});
-				_this.socket.on('event:playlist.updateDisplayName', (data) => {
-					if (_this.playlist._id === data.playlistId) _this.playlist.displayName = data.displayName;
-				});
-				_this.socket.on('event:playlist.moveSongToBottom', (data) => {
-					if (_this.playlist._id === data.playlistId) {
-						let songIndex;
-						_this.playlist.songs.forEach((song, index) => {
-							if (song.songId === data.songId) songIndex = index;
-						});
-						let song = _this.playlist.songs.splice(songIndex, 1)[0];
-						_this.playlist.songs.push(song);
+					return `0${minutes} minute `;
+				}
+				return "";
+			};
+
+			const seconds = Math.floor(
+				duration - hours * 60 * 60 - minutes * 60
+			);
+			const formatSeconds = () => {
+				if (seconds > 0) {
+					if (seconds > 1) {
+						if (seconds < 10) return `0${seconds} seconds `;
+						return `${seconds} seconds `;
 					}
-				});
-				_this.socket.on('event:playlist.moveSongToTop', (data) => {
-					if (_this.playlist._id === data.playlistId) {
-						let songIndex;
-						_this.playlist.songs.forEach((song, index) => {
-							if (song.songId === data.songId) songIndex = index;
+					return `0${seconds} second `;
+				}
+				return "";
+			};
+
+			return formatHours() + formatMinutes() + formatSeconds();
+		},
+		totalLength() {
+			let length = 0;
+			this.playlist.songs.forEach(song => {
+				length += song.duration;
+			});
+			return this.formatTime(length);
+		},
+		searchForSongs() {
+			let query = this.songQuery;
+			if (query.indexOf("&index=") !== -1) {
+				query = query.split("&index=");
+				query.pop();
+				query = query.join("");
+			}
+			if (query.indexOf("&list=") !== -1) {
+				query = query.split("&list=");
+				query.pop();
+				query = query.join("");
+			}
+			this.socket.emit("apis.searchYoutube", query, res => {
+				if (res.status === "success") {
+					this.songQueryResults = [];
+					for (let i = 0; i < res.data.items.length; i += 1) {
+						this.songQueryResults.push({
+							id: res.data.items[i].id.videoId,
+							url: `https://www.youtube.com/watch?v=${this.id}`,
+							title: res.data.items[i].snippet.title,
+							thumbnail:
+								res.data.items[i].snippet.thumbnails.default.url
 						});
-						let song = _this.playlist.songs.splice(songIndex, 1)[0];
-						_this.playlist.songs.unshift(song);
 					}
-				});
+				} else if (res.status === "error")
+					Toast.methods.addToast(res.message, 3000);
 			});
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.editPlaylist = !this.$parent.modals.editPlaylist;
-			}
-		}
+		addSongToPlaylist(id) {
+			this.socket.emit(
+				"playlists.addSongToPlaylist",
+				id,
+				this.playlist._id,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		importPlaylist() {
+			Toast.methods.addToast(
+				"Starting to import your playlist. This can take some time to do.",
+				4000
+			);
+			this.socket.emit(
+				"playlists.addSetToPlaylist",
+				this.importQuery,
+				this.playlist._id,
+				res => {
+					if (res.status === "success")
+						this.playlist.songs = res.data;
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		removeSongFromPlaylist(id) {
+			this.socket.emit(
+				"playlists.removeSongFromPlaylist",
+				id,
+				this.playlist._id,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		renamePlaylist() {
+			const { displayName } = this.playlist;
+			if (!validation.isLength(displayName, 2, 32))
+				return Toast.methods.addToast(
+					"Display name must have between 2 and 32 characters.",
+					8000
+				);
+			if (!validation.regex.azAZ09_.test(displayName))
+				return Toast.methods.addToast(
+					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					8000
+				);
+
+			return this.socket.emit(
+				"playlists.updateDisplayName",
+				this.playlist._id,
+				this.playlist.displayName,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		removePlaylist() {
+			this.socket.emit("playlists.remove", this.playlist._id, res => {
+				Toast.methods.addToast(res.message, 3000);
+				if (res.status === "success") {
+					this.closeModal();
+				}
+			});
+		},
+		promoteSong(songId) {
+			this.socket.emit(
+				"playlists.moveSongToTop",
+				this.playlist._id,
+				songId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		demoteSong(songId) {
+			this.socket.emit(
+				"playlists.moveSongToBottom",
+				this.playlist._id,
+				songId,
+				res => {
+					Toast.methods.addToast(res.message, 4000);
+				}
+			);
+		},
+		...mapActions("modals", ["closeModal"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.menu { padding: 0 20px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.menu-list li {
-		display: flex;
-		justify-content: space-between;
-	}
+.menu {
+	padding: 0 20px;
+}
 
-	.menu-list a:hover { color: #000 !important; }
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+}
 
-	li a {
-		display: flex;
-    	align-items: center;
-	}
+.menu-list a:hover {
+	color: $black !important;
+}
 
-	.controls {
-		display: flex;
+li a {
+	display: flex;
+	align-items: center;
+}
 
-		a {
-			display: flex;
-    		align-items: center;
-		}
-	}
+.controls {
+	display: flex;
 
-	.table {
-		margin-bottom: 0;
+	a {
+		display: flex;
+		align-items: center;
 	}
+}
+
+.table {
+	margin-bottom: 0;
+}
 
-	h5 { padding: 20px 0; }
-</style>
+h5 {
+	padding: 20px 0;
+}
+</style>

+ 161 - 62
frontend/components/Modals/Register.vue

@@ -1,33 +1,78 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Register</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal is-active">
+		<div
+			class="modal-background"
+			@click="
+				closeModal({
+					sector: 'header',
+					modal: 'register'
+				})
+			"
+		/>
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					Register
+				</p>
+				<button
+					class="delete"
+					@click="
+						closeModal({
+							sector: 'header',
+							modal: 'register'
+						})
+					"
+				/>
 			</header>
-			<section class='modal-card-body'>
+			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class='label'>Email</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Email...' v-model='$parent.register.email' autofocus>
+				<label class="label">Email</label>
+				<p class="control">
+					<input
+						v-model="email"
+						class="input"
+						type="text"
+						placeholder="Email..."
+						autofocus
+					/>
+				</p>
+				<label class="label">Username</label>
+				<p class="control">
+					<input
+						v-model="username"
+						class="input"
+						type="text"
+						placeholder="Username..."
+					/>
 				</p>
-				<label class='label'>Username</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Username...' v-model='$parent.register.username'>
+				<label class="label">Password</label>
+				<p class="control">
+					<input
+						v-model="password"
+						class="input"
+						type="password"
+						placeholder="Password..."
+						@keypress="$parent.submitOnEnter(submitModal, $event)"
+					/>
 				</p>
-				<label class='label'>Password</label>
-				<p class='control'>
-					<input class='input' type='password' placeholder='Password...' v-model='$parent.register.password' v-on:keypress='$parent.submitOnEnter(submitModal, $event)'>
+				<p>
+					By logging in/registering you agree to our
+					<router-link to="/terms"> Terms of Service </router-link
+					>&nbsp;and
+					<router-link to="/privacy"> Privacy Policy </router-link>.
 				</p>
-				<div id="recaptcha"></div>
-				<p>By logging in/registering you agree to our <a href="/terms" v-link="{ path: '/terms' }">Terms of Service</a> and <a href="/privacy" v-link="{ path: '/privacy' }">Privacy Policy</a>.</p>
 			</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"'>
-					<div class='icon'>
-						<img class='invert' src='/assets/social/github.svg'/>
+			<footer class="modal-card-foot">
+				<a class="button is-primary" href="#" @click="submitModal()"
+					>Submit</a
+				>
+				<a
+					class="button is-github"
+					:href="serverDomain + '/auth/github/authorize'"
+					@click="githubRedirect()"
+				>
+					<div class="icon">
+						<img class="invert" src="/assets/social/github.svg" />
 					</div>
 					&nbsp;&nbsp;Register with GitHub
 				</a>
@@ -37,51 +82,105 @@
 </template>
 
 <script>
-	export default {
-		data() {
-			return {
-				recaptcha: {
-					key: ''
-				}
-			}
-		},
-		ready: function () {
-			let _this = this;
-			lofig.get('recaptcha', obj => {
-				_this.recaptcha.key = obj.key;
-				grecaptcha.render('recaptcha', {
-					'sitekey' : _this.recaptcha.key
+import { mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+
+export default {
+	data() {
+		return {
+			username: "",
+			email: "",
+			password: "",
+			recaptcha: {
+				key: "",
+				token: ""
+			},
+			serverDomain: ""
+		};
+	},
+	mounted() {
+		lofig.get("serverDomain", res => {
+			this.serverDomain = res;
+		});
+
+		lofig.get("recaptcha", obj => {
+			this.recaptcha.key = obj.key;
+
+			const recaptchaScript = document.createElement("script");
+			recaptchaScript.onload = () => {
+				grecaptcha.ready(() => {
+					grecaptcha
+						.execute(this.recaptcha.key, { action: "login" })
+						.then(token => {
+							this.recaptcha.token = token;
+						});
 				});
-			});
+			};
+
+			recaptchaScript.setAttribute(
+				"src",
+				`https://www.google.com/recaptcha/api.js?render=${this.recaptcha.key}`
+			);
+			document.head.appendChild(recaptchaScript);
+		});
+	},
+	methods: {
+		submitModal() {
+			console.log(this.recaptcha.token);
+
+			this.register({
+				username: this.username,
+				email: this.email,
+				password: this.password,
+				recaptchaToken: this.recaptcha.token
+			})
+				.then(res => {
+					if (res.status === "success") window.location.reload();
+				})
+				.catch(err => Toast.methods.addToast(err.message, 5000));
 		},
-		methods: {
-			toggleModal: function () {
-				if (this.$router._currentRoute.path === '/register') location.href = '/';
-				else this.$dispatch('toggleModal', 'register');
-			},
-			submitModal: function () {
-				this.$dispatch('register');
-				this.toggleModal();
-			}
+		githubRedirect() {
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
-		events: {
-			closeModal: function() {
-				this.$dispatch('toggleModal', 'register');
-			}
-		}
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("user/auth", ["register"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.button.is-github {
-		background-color: #333;
-		color: #fff !important;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.button.is-github {
+	background-color: $dark-grey-2;
+	color: $white !important;
+}
+
+.is-github:focus {
+	background-color: $dark-grey-3;
+}
+.is-primary:focus {
+	background-color: #028bca !important;
+}
+
+.invert {
+	filter: brightness(5);
+}
+
+#recaptcha {
+	padding: 10px 0;
+}
 
-	.is-github:focus { background-color: #1a1a1a; }
-	.is-primary:focus { background-color: #028bca !important; }
+a {
+	color: $primary-color;
+}
+</style>
 
-	.invert { filter: brightness(5); }
+<style lang="scss">
+@import "styles/global.scss";
 
-	#recaptcha { padding: 10px 0; }
-</style>
+.grecaptcha-badge {
+	z-index: 2000;
+}
+</style>

+ 241 - 175
frontend/components/Modals/Report.vue

@@ -1,89 +1,147 @@
 <template>
-	<modal title='Report'>
-		<div slot='body'>
-			<div class='columns song-types'>
-				<div class='column song-type' v-if='$parent.previousSong !== null'>
-					<div class='card is-fullwidth' :class="{ 'is-highlight-active': isPreviousSongActive }" @click="highlight('previousSong')">
-						<header class='card-header'>
-							<p class='card-header-title'>
+	<modal title="Report">
+		<div slot="body">
+			<div class="columns song-types">
+				<div v-if="previousSong !== null" class="column song-type">
+					<div
+						class="card is-fullwidth"
+						:class="{ 'is-highlight-active': isPreviousSongActive }"
+						@click="highlight('previousSong')"
+					>
+						<header class="card-header">
+							<p class="card-header-title">
 								Previous Song
 							</p>
 						</header>
-						<div class='card-content'>
-							<article class='media'>
-								<figure class='media-left'>
-									<p class='image is-64x64'>
-										<img :src='$parent.previousSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+						<div class="card-content">
+							<article class="media">
+								<figure class="media-left">
+									<p class="image is-64x64">
+										<img
+											:src="previousSong.thumbnail"
+											onerror='this.src="/assets/notes-transparent.png"'
+										/>
 									</p>
 								</figure>
-								<div class='media-content'>
-									<div class='content'>
+								<div class="media-content">
+									<div class="content">
 										<p>
-											<strong>{{ $parent.previousSong.title }}</strong>
-											<br>
-											<small>{{ $parent.previousSong.artists.split(' ,') }}</small>
+											<strong>{{
+												previousSong.title
+											}}</strong>
+											<br />
+											<small>{{
+												previousSong.artists.split(" ,")
+											}}</small>
 										</p>
 									</div>
 								</div>
 							</article>
 						</div>
-						<a @click=highlight('previousSong') href='#' class='absolute-a'></a>
+						<a
+							href="#"
+							class="absolute-a"
+							@click="highlight('previousSong')"
+						/>
 					</div>
 				</div>
-				<div class='column song-type' v-if='$parent.currentSong !== {}'>
-					<div class='card is-fullwidth'  :class="{ 'is-highlight-active': isCurrentSongActive }" @click="highlight('currentSong')">
-						<header class='card-header'>
-							<p class='card-header-title'>
+				<div v-if="currentSong !== {}" class="column song-type">
+					<div
+						class="card is-fullwidth"
+						:class="{ 'is-highlight-active': isCurrentSongActive }"
+						@click="highlight('currentSong')"
+					>
+						<header class="card-header">
+							<p class="card-header-title">
 								Current Song
 							</p>
 						</header>
-						<div class='card-content'>
-							<article class='media'>
-								<figure class='media-left'>
-									<p class='image is-64x64'>
-										<img :src='$parent.currentSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+						<div class="card-content">
+							<article class="media">
+								<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'>
+								<div class="media-content">
+									<div class="content">
 										<p>
-											<strong>{{ $parent.currentSong.title }}</strong>
-											<br>
-											<small>{{ $parent.currentSong.artists.split(' ,') }}</small>
+											<strong>{{
+												currentSong.title
+											}}</strong>
+											<br />
+											<small>{{
+												currentSong.artists.split(" ,")
+											}}</small>
 										</p>
 									</div>
 								</div>
 							</article>
 						</div>
-						<a @click=highlight('currentSong') href='#' class='absolute-a'></a>
+						<a
+							href="#"
+							class="absolute-a"
+							@click="highlight('currentSong')"
+						/>
 					</div>
 				</div>
 			</div>
-			<div class='edit-report-wrapper'>
-				<div class='columns is-multiline'>
-					<div class='column is-half' v-for='issue in issues'>
-						<label class='label'>{{ issue.name }}</label>
-						<p class='control' v-for='reason in issue.reasons' track-by='$index'>
-							<label class='checkbox'>
-								<input type='checkbox' @click='toggleIssue(issue.name, reason)'>
+			<div class="edit-report-wrapper">
+				<div class="columns is-multiline">
+					<div
+						v-for="(issue, index) in issues"
+						class="column is-half"
+						:key="index"
+					>
+						<label class="label">{{ issue.name }}</label>
+						<p
+							v-for="(reason, index) in issue.reasons"
+							class="control"
+							:key="index"
+						>
+							<label class="checkbox">
+								<input
+									type="checkbox"
+									@click="toggleIssue(issue.name, reason)"
+								/>
 								{{ reason }}
 							</label>
 						</p>
 					</div>
-					<div class='column'>
-						<label class='label'>Other</label>
-						<textarea class='textarea' maxlength='400' placeholder='Any other details...' @keyup='updateCharactersRemaining()' v-model='report.description'></textarea>
-						<div class='textarea-counter'>{{ charactersRemaining }}</div>
+					<div class="column">
+						<label class="label">Other</label>
+						<textarea
+							v-model="report.description"
+							class="textarea"
+							maxlength="400"
+							placeholder="Any other details..."
+							@keyup="updateCharactersRemaining()"
+						/>
+						<div class="textarea-counter">
+							{{ charactersRemaining }}
+						</div>
 					</div>
 				</div>
 			</div>
 		</div>
-		<div slot='footer'>
-			<a class='button is-success' @click='create()' href='#'>
-				<i class='material-icons save-changes'>done</i>
+		<div slot="footer">
+			<a class="button is-success" @click="create()" href="#">
+				<i class="material-icons save-changes">done</i>
 				<span>&nbsp;Create</span>
 			</a>
-			<a class='button is-danger' @click='$parent.modals.report = !$parent.modals.report' href='#'>
+			<a
+				class="button is-danger"
+				href="#"
+				@click="
+					closeModal({
+						sector: 'station',
+						modal: 'report'
+					})
+				"
+			>
 				<span>&nbsp;Cancel</span>
 			</a>
 		</div>
@@ -91,151 +149,159 @@
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				charactersRemaining: 400,
-				isPreviousSongActive: false,
-				isCurrentSongActive: true,
-				report: {
-					resolved: false,
-					songId: this.$parent.currentSong.songId,
-					description: '',
-					issues: [
-						{ name: 'Video', reasons: [] },
-						{ name: 'Title', reasons: [] },
-						{ name: 'Duration', reasons: [] },
-						{ name: 'Artists', reasons: [] },
-						{ name: 'Thumbnail', reasons: [] }
-					]
-				},
+import { Toast } from "vue-roaster";
+import Modal from "./Modal.vue";
+import io from "../../io";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			charactersRemaining: 400,
+			isPreviousSongActive: false,
+			isCurrentSongActive: true,
+			report: {
+				resolved: false,
+				songId: "",
+				description: "",
 				issues: [
-					{
-						name: 'Video',
-						reasons: [
-							'Doesn\'t exist',
-							'It\'s private',
-							'It\'s not available in my country'
-						]
-					},
-					{
-						name: 'Title',
-						reasons: [
-							'Incorrect',
-							'Inappropriate'
-						]
-					},
-					{
-						name: 'Duration',
-						reasons: [
-							'Skips too soon',
-							'Skips too late',
-							'Starts too soon',
-							'Starts too late'
-						]
-					},
-					{
-						name: 'Artists',
-						reasons: [
-							'Incorrect',
-							'Inappropriate'
-						]
-					},
-					{
-						name: 'Thumbnail',
-						reasons: [
-							'Incorrect',
-							'Inappropriate',
-							'Doesn\'t exist'
-						]
-					}
+					{ name: "Video", reasons: [] },
+					{ name: "Title", reasons: [] },
+					{ name: "Duration", reasons: [] },
+					{ name: "Artists", reasons: [] },
+					{ name: "Thumbnail", reasons: [] }
 				]
-			}
-		},
-		methods: {
-			create: function () {
-				let _this = this;
-				console.log(this.report);
-				_this.socket.emit('reports.create', _this.report, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status == 'success') _this.$parent.modals.report = !_this.$parent.modals.report;
-				});
-			},
-			updateCharactersRemaining: function () {
-				this.charactersRemaining = 400 - $('.textarea').val().length;
 			},
-			highlight: function (type) {
-				if (type == 'currentSong') {
-					this.report.songId = this.$parent.currentSong.songId
-					this.isPreviousSongActive = false;
-					this.isCurrentSongActive = true;
-				} else if (type == 'previousSong') {
-					this.report.songId = this.$parent.previousSong.songId
-					this.isCurrentSongActive = false;
-					this.isPreviousSongActive = true;
-				}
-			},
-			toggleIssue: function (name, reason) {
-				for (let z = 0; z < this.report.issues.length; z++) {
-					if (this.report.issues[z].name == name) {
-						if (this.report.issues[z].reasons.indexOf(reason) > -1) {
-							this.report.issues[z].reasons.splice(
-								this.report.issues[z].reasons.indexOf(reason), 1
-							);
-						} else this.report.issues[z].reasons.push(reason);
-					}
+			issues: [
+				{
+					name: "Video",
+					reasons: [
+						"Doesn't exist",
+						"It's private",
+						"It's not available in my country"
+					]
+				},
+				{
+					name: "Title",
+					reasons: ["Incorrect", "Inappropriate"]
+				},
+				{
+					name: "Duration",
+					reasons: [
+						"Skips too soon",
+						"Skips too late",
+						"Starts too soon",
+						"Starts too late"
+					]
+				},
+				{
+					name: "Artists",
+					reasons: ["Incorrect", "Inappropriate"]
+				},
+				{
+					name: "Thumbnail",
+					reasons: ["Incorrect", "Inappropriate", "Doesn't exist"]
 				}
-			}
+			]
+		};
+	},
+	computed: mapState({
+		currentSong: state => state.station.currentSong,
+		previousSong: state => state.station.previousSong
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+
+		this.report.songId = this.currentSong.songId;
+	},
+	methods: {
+		create() {
+			console.log(this.report);
+			this.socket.emit("reports.create", this.report, res => {
+				Toast.methods.addToast(res.message, 4000);
+				if (res.status === "success")
+					this.closeModal({
+						sector: "station",
+						modal: "report"
+					});
+			});
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.report = !this.$parent.modals.report;
+		updateCharactersRemaining() {
+			this.charactersRemaining =
+				400 - document.getElementsByClassName("textarea").value.length;
+		},
+		highlight(type) {
+			if (type === "currentSong") {
+				this.report.songId = this.currentSong.songId;
+				this.isPreviousSongActive = false;
+				this.isCurrentSongActive = true;
+			} else if (type === "previousSong") {
+				this.report.songId = this.previousSong.songId;
+				this.isCurrentSongActive = false;
+				this.isPreviousSongActive = true;
 			}
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-			});
+		toggleIssue(name, reason) {
+			for (let z = 0; z < this.report.issues.length; z += 1) {
+				if (this.report.issues[z].name === name) {
+					if (this.report.issues[z].reasons.indexOf(reason) > -1) {
+						this.report.issues[z].reasons.splice(
+							this.report.issues[z].reasons.indexOf(reason),
+							1
+						);
+					} else this.report.issues[z].reasons.push(reason);
+				}
+			}
 		},
+		...mapActions("modals", ["closeModal"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	h6 { margin-bottom: 15px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.song-type:first-of-type { padding-left: 0; }
-	.song-type:last-of-type { padding-right: 0; }
+h6 {
+	margin-bottom: 15px;
+}
 
-	.media-content {
-		display: flex;
-		align-items: center;
-		height: 64px;
-	}
+.song-type:first-of-type {
+	padding-left: 0;
+}
+.song-type:last-of-type {
+	padding-right: 0;
+}
 
-	.radio-controls .control {
-		display: flex;
-		align-items: center;
-	}
+.media-content {
+	display: flex;
+	align-items: center;
+	height: 64px;
+}
 
-	.textarea-counter {
-		text-align: right;
-	}
+.radio-controls .control {
+	display: flex;
+	align-items: center;
+}
 
-	@media screen and (min-width: 769px) {
-		.radio-controls .control-label { padding-top: 0 !important; }
-	}
+.textarea-counter {
+	text-align: right;
+}
 
-	.edit-report-wrapper {
-		padding: 20px;
+@media screen and (min-width: 769px) {
+	.radio-controls .control-label {
+		padding-top: 0 !important;
 	}
+}
 
-	.is-highlight-active {
-		border: 3px #03a9f4 solid;
-	}
+.edit-report-wrapper {
+	padding: 20px;
+}
+
+.is-highlight-active {
+	border: 3px $primary-color solid;
+}
 </style>

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

@@ -0,0 +1,108 @@
+<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>
+						{{
+							format(
+								parseISO(punishment.expiresAt),
+								"MMMM do yyyy, h:mm:ss a"
+							)
+						}}
+						({{
+							formatDistance(
+								parseISO(punishment.expiresAt),
+								new Date(),
+								{ addSuffix: true }
+							)
+						}})
+						<br />
+						<strong>Punished at:</strong>
+						{{
+							format(
+								parseISO(punishment.punishedAt),
+								"MMMM do yyyy, h:mm:ss a"
+							)
+						}}
+						({{
+							formatDistance(
+								parseISO(punishment.punishedAt),
+								new Date(),
+								{ addSuffix: true }
+							)
+						}})
+						<br />
+						<strong>Punished by:</strong>
+						<user-id-to-username
+							:userId="punishment.punishedBy"
+							:alt="punishment.punishedBy"
+						/>
+						<br />
+					</div>
+				</article>
+			</div>
+			<div slot="footer">
+				<button
+					class="button is-danger"
+					@click="
+						closeModal({
+							sector: 'admin',
+							modal: 'viewPunishment'
+						})
+					"
+				>
+					<span>&nbsp;Close</span>
+				</button>
+			</div>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import { format, formatDistance, parseISO } from "date-fns"; // eslint-disable-line no-unused-vars
+
+import io from "../../io";
+import Modal from "./Modal.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	components: { Modal, UserIdToUsername },
+	data() {
+		return {
+			ban: {}
+		};
+	},
+	computed: {
+		...mapState("admin/punishments", {
+			punishment: state => state.punishment
+		})
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			return socket;
+		});
+	},
+	methods: {
+		...mapActions("modals", ["closeModal"]),
+		format,
+		formatDistance,
+		parseISO
+	}
+};
+</script>

+ 148 - 83
frontend/components/Modals/WhatIsNew.vue

@@ -1,37 +1,69 @@
 <template>
-	<div class='modal' :class='{ "is-active": isModalActive }' v-if='news !== null'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'><strong>{{ news.title }}</strong> ({{ formatDate(news.createdAt) }})</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div
+		v-if="news !== null"
+		class="modal"
+		:class="{ 'is-active': isModalActive }"
+	>
+		<div class="modal-background" />
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					<strong>{{ news.title }}</strong>
+					({{ formatDate(news.createdAt) }})
+				</p>
+				<button class="delete" v-on:click="toggleModal()" />
 			</header>
-			<section class='modal-card-body'>
-				<div class='content'>
+			<section class="modal-card-body">
+				<div class="content">
 					<p>{{ news.description }}</p>
 				</div>
-				<div class='sect' v-show='news.features.length > 0'>
-					<div class='sect-head-features'>The features are so great</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.features'>{{ li }}</li>
+				<div v-show="news.features.length > 0" class="sect">
+					<div class="sect-head-features">
+						The features are so great
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(feature, index) in news.features"
+							:key="index"
+						>
+							{{ feature }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.improvements.length > 0'>
-					<div class='sect-head-improvements'>Improvements</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.improvements'>{{ li }}</li>
+				<div v-show="news.improvements.length > 0" class="sect">
+					<div class="sect-head-improvements">
+						Improvements
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(improvement, index) in news.improvements"
+							:key="index"
+						>
+							{{ improvement }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.bugs.length > 0'>
-					<div class='sect-head-bugs'>Bugs Smashed</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.bugs'>{{ li }}</li>
+				<div v-show="news.bugs.length > 0" class="sect">
+					<div class="sect-head-bugs">
+						Bugs Smashed
+					</div>
+					<ul class="sect-body">
+						<li v-for="(bug, index) in news.bugs" :key="index">
+							{{ bug }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.upcoming.length > 0'>
-					<div class='sect-head-upcoming'>Coming Soon to a Musare near you</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.upcoming'>{{ li }}</li>
+				<div v-show="news.upcoming.length > 0" class="sect">
+					<div class="sect-head-upcoming">
+						Coming Soon to a Musare near you
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(upcoming, index) in news.upcoming"
+							:key="index"
+						>
+							{{ upcoming }}
+						</li>
 					</ul>
 				</div>
 			</section>
@@ -40,84 +72,117 @@
 </template>
 
 <script>
-	import io from '../../io';
+import { format } from "date-fns";
 
-	export default {
-		data() {
-			return {
-				isModalActive: false,
-				news: null
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(true, (socket) => {
-				_this.socket = socket;
-				_this.socket.emit('news.newest', res => {
-					_this.news = res.data;
-					if (_this.news) {
-						if (localStorage.getItem('whatIsNew')) {
-							if (parseInt(localStorage.getItem('whatIsNew')) < res.data.createdAt) {
-								this.toggleModal();
-								localStorage.setItem('whatIsNew', res.data.createdAt);
-							}
-						} else {
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			isModalActive: false,
+			news: null
+		};
+	},
+	mounted() {
+		io.getSocket(true, socket => {
+			this.socket = socket;
+			this.socket.emit("news.newest", res => {
+				this.news = res.data;
+				if (this.news && localStorage.getItem("firstVisited")) {
+					if (localStorage.getItem("whatIsNew")) {
+						if (
+							parseInt(localStorage.getItem("whatIsNew")) <
+							res.data.createdAt
+						) {
+							this.toggleModal();
+							localStorage.setItem(
+								"whatIsNew",
+								res.data.createdAt
+							);
+						}
+					} else {
+						if (
+							parseInt(localStorage.getItem("firstVisited")) <
+							res.data.createdAt
+						) {
 							this.toggleModal();
-							localStorage.setItem('whatIsNew', res.data.createdAt);
 						}
+						localStorage.setItem("whatIsNew", res.data.createdAt);
 					}
-				});
+				} else if (!localStorage.getItem("firstVisited"))
+					localStorage.setItem("firstVisited", Date.now());
 			});
+		});
+	},
+	methods: {
+		toggleModal() {
+			this.isModalActive = !this.isModalActive;
 		},
-		methods: {
-			toggleModal: function () {
-				this.isModalActive = !this.isModalActive;
-			},
-			formatDate: unix => {
-				return moment(unix).format('DD-MM-YYYY');
-			}
-		},
-		events: {
-			closeModal: function() {
-				this.isModalActive = false;
-			}
+		formatDate: unix => {
+			return format(unix, "dd-MM-yyyy");
+		}
+	},
+	events: {
+		closeModal() {
+			this.isModalActive = false;
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.modal-card-head {
-		border-bottom: none;
-		background-color: ghostwhite;
-		padding: 15px;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.modal-card-head {
+	border-bottom: none;
+	background-color: ghostwhite;
+	padding: 15px;
+}
 
-	.modal-card-title { font-size: 14px; }
+.modal-card-title {
+	font-size: 14px;
+}
 
-	.delete {
+.delete {
+	background: transparent;
+	&:hover {
 		background: transparent;
-		&:hover { background: transparent; }
+	}
 
-		&:before, &:after { background-color: #bbb; }
+	&:before,
+	&:after {
+		background-color: #bbb;
 	}
+}
 
-	.sect {
-		div[class^='sect-head'], div[class*=' sect-head']{
-			padding: 12px;
-			text-transform: uppercase;
-			font-weight: bold;
-			color: #fff;
-		}
+.sect {
+	div[class^="sect-head"],
+	div[class*=" sect-head"] {
+		padding: 12px;
+		text-transform: uppercase;
+		font-weight: bold;
+		color: $white;
+	}
 
-		.sect-head-features { background-color: dodgerblue; }
-		.sect-head-improvements { background-color: seagreen; }
-		.sect-head-bugs { background-color: brown; }
-		.sect-head-upcoming { background-color: mediumpurple; }
+	.sect-head-features {
+		background-color: dodgerblue;
+	}
+	.sect-head-improvements {
+		background-color: seagreen;
+	}
+	.sect-head-bugs {
+		background-color: brown;
+	}
+	.sect-head-upcoming {
+		background-color: mediumpurple;
+	}
 
-		.sect-body {
-			padding: 15px 25px;
+	.sect-body {
+		padding: 15px 25px;
 
-			li { list-style-type: disc; }
+		li {
+			list-style-type: disc;
 		}
 	}
+}
 </style>

+ 181 - 131
frontend/components/Sidebars/Playlist.vue

@@ -1,160 +1,210 @@
 <template>
-	<div class='sidebar' transition='slide' v-if='$parent.sidebars.playlist'>
-		<div class='inner-wrapper'>
-			<div class='title'>Playlists</div>
-
-			<aside class='menu' v-if='playlists.length > 0'>
-				<ul class='menu-list'>
-					<li v-for='playlist in playlists'>
+	<div class="sidebar" transition="slide">
+		<div class="inner-wrapper">
+			<div class="title">
+				Playlists
+			</div>
+
+			<aside v-if="playlists.length > 0" class="menu">
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlists" :key="index">
 						<span>{{ playlist.displayName }}</span>
 						<!--Will play playlist in community station Kris-->
-						<div class='icons-group'>
-							<a href='#' @click='selectPlaylist(playlist._id)' v-if="isNotSelected(playlist._id)">
-								<i class='material-icons'>play_arrow</i>
+						<div class="icons-group">
+							<a
+								v-if="
+									isNotSelected(playlist._id) &&
+										!station.partyMode
+								"
+								href="#"
+								@click="selectPlaylist(playlist._id)"
+							>
+								<i class="material-icons">play_arrow</i>
 							</a>
-							<a href='#' @click='editPlaylist(playlist._id)'>
-								<i class='material-icons'>edit</i>
+							<a href="#" v-on:click="edit(playlist._id)">
+								<i class="material-icons">edit</i>
 							</a>
 						</div>
 					</li>
 				</ul>
 			</aside>
 
-			<div class='none-found' v-else>No Playlists found</div>
-
-			<a class='button create-playlist' href='#' @click='$parent.modals.createPlaylist = !$parent.modals.createPlaylist'>Create Playlist</a>
+			<div v-else class="none-found">
+				No Playlists found
+			</div>
+
+			<a
+				class="button create-playlist"
+				href="#"
+				@click="
+					openModal({ sector: 'station', modal: 'createPlaylist' })
+				"
+				>Create Playlist</a
+			>
 		</div>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import { Edit } from '../Modals/Playlists/Edit.vue';
-	import io from '../../io';
-
-	export default {
-		data() {
-			return {
-				playlists: []
-			}
+import { mapState, mapActions } from "vuex";
+
+import { Toast } from "vue-roaster";
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			playlists: []
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		}),
+		...mapState({
+			station: state => state.station.station
+		})
+	},
+	methods: {
+		edit(id) {
+			this.editPlaylist(id);
+			this.openModal({ sector: "station", modal: "editPlaylist" });
 		},
-		methods: {
-			editPlaylist: function(id) {
-				this.$parent.editPlaylist(id);
-			},
-			selectPlaylist: function(id) {
-				this.socket.emit('stations.selectPrivatePlaylist', this.$parent.stationId, id, (res) => {
-					if (res.status === 'failure') return Toast.methods.addToast(res.message, 8000);
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			isNotSelected: function(id) {
-				let _this = this;
-				//TODO Also change this once it changes for a station
-				if (_this.$parent.station && _this.$parent.station.privatePlaylist === id) return false;
-				return true;
-			}
+		selectPlaylist(id) {
+			this.socket.emit(
+				"stations.selectPrivatePlaylist",
+				this.station._id,
+				id,
+				res => {
+					if (res.status === "failure")
+						return Toast.methods.addToast(res.message, 8000);
+					return Toast.methods.addToast(res.message, 4000);
+				}
+			);
 		},
-		ready: function () {
-			// TODO: Update when playlist is removed/created
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('playlists.indexForUser', res => {
-					if (res.status == 'success') _this.playlists = res.data;
-				});
-				_this.socket.on('event:playlist.create', (playlist) => {
-					_this.playlists.push(playlist);
-				});
-				_this.socket.on('event:playlist.delete', (playlistId) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === playlistId) {
-							_this.playlists.splice(index, 1);
-						}
-					});
+		isNotSelected(id) {
+			// TODO Also change this once it changes for a station
+			if (this.station && this.station.privatePlaylist === id)
+				return false;
+			return true;
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
+	},
+	mounted() {
+		// TODO: Update when playlist is removed/created
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") this.playlists = res.data;
+			});
+			this.socket.on("event:playlist.create", playlist => {
+				this.playlists.push(playlist);
+			});
+			this.socket.on("event:playlist.delete", playlistId => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === playlistId) {
+						this.playlists.splice(index, 1);
+					}
 				});
-				_this.socket.on('event:playlist.addSong', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].songs.push(data.song);
-						}
-					});
+			});
+			this.socket.on("event:playlist.addSong", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						this.playlists[index].songs.push(data.song);
+					}
 				});
-				_this.socket.on('event:playlist.removeSong', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].songs.forEach((song, index2) => {
-								if (song._id === data.songId) {
-									_this.playlists[index].songs.splice(index2, 1);
-								}
-							});
-						}
-					});
+			});
+			this.socket.on("event:playlist.removeSong", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						this.playlists[index].songs.forEach((song, index2) => {
+							if (song._id === data.songId) {
+								this.playlists[index].songs.splice(index2, 1);
+							}
+						});
+					}
 				});
-				_this.socket.on('event:playlist.updateDisplayName', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].displayName = data.displayName;
-						}
-					});
+			});
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						this.playlists[index].displayName = data.displayName;
+					}
 				});
 			});
-		}
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		right: 0;
-		width: 300px;
-		height: 100vh;
-		background-color: #fff;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-	}
-
-	.icons-group a {
-		display: flex;
-    	align-items: center;
-	}
-
-	.menu-list li { align-items: center; }
-
-	.inner-wrapper {	
-		top: 64px;
-		position: relative;
-	}
-
-	.slide-transition {
-		transition: transform 0.6s ease-in-out;
-		transform: translateX(0);
-	}
-
-	.slide-enter, .slide-leave { transform: translateX(100%); }
-
-	.title {
-		background-color: rgb(3, 169, 244);
-		text-align: center;
-		padding: 10px;
-		color: white;
-		font-weight: 600;
-	}
-
-	.create-playlist {
-		width: 100%;
-    	margin-top: 20px;
-		height: 40px;
-		border-radius: 0;
-		background: rgba(3, 169, 244, 1);
-    	color: #fff !important;
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.sidebar {
+	position: fixed;
+	z-index: 1;
+	top: 0;
+	right: 0;
+	width: 300px;
+	height: 100vh;
+	background-color: $white;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+}
+
+.icons-group a {
+	display: flex;
+	align-items: center;
+}
+
+.menu-list li {
+	align-items: center;
+}
+
+.inner-wrapper {
+	top: 60px;
+	position: relative;
+}
+
+.slide-transition {
+	transition: transform 0.6s ease-in-out;
+	transform: translateX(0);
+}
+
+.slide-enter,
+.slide-leave {
+	transform: translateX(100%);
+}
+
+.title {
+	background-color: rgb(3, 169, 244);
+	text-align: center;
+	padding: 10px;
+	color: $white;
+	font-weight: 600;
+}
+
+.create-playlist {
+	width: 100%;
+	margin-top: 20px;
+	height: 40px;
+	border-radius: 0;
+	background: rgba(3, 169, 244, 1);
+	color: $white !important;
+	border: 0;
+
+	&:active,
+	&:focus {
 		border: 0;
-
-		&:active, &:focus { border: 0; }
 	}
+}
 
-	.create-playlist:focus { background: #029ce3; }
+.create-playlist:focus {
+	background: $primary-color;
+}
 
-	.none-found { text-align: center; }
-</style>
+.none-found {
+	text-align: center;
+}
+</style>

+ 243 - 83
frontend/components/Sidebars/SongsList.vue

@@ -1,126 +1,286 @@
 <template>
-	<div class='sidebar' transition='slide' v-if='$parent.sidebars.songslist'>
-		<div class='inner-wrapper'>
-			<div class='title' v-if='$parent.type === "community"'>Queue</div>
-			<div class='title' v-else>Playlist</div>
+	<div class="sidebar" transition="slide">
+		<div class="inner-wrapper">
+			<div v-if="station.type === 'community'" class="title">
+				Queue
+			</div>
+			<div v-else class="title">
+				Playlist
+			</div>
 
-			<article class="media" v-if="!$parent.noSong">
-				<figure class="media-left">
+			<article v-if="!noSong" class="media">
+				<figure v-if="currentSong.thumbnail" class="media-left">
 					<p class="image is-64x64">
-						<img :src="$parent.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+						<img
+							:src="currentSong.thumbnail"
+							onerror="this.src='/assets/notes-transparent.png'"
+						/>
 					</p>
 				</figure>
 				<div class="media-content">
 					<div class="content">
 						<p>
-							Current Song: <strong>{{ $parent.currentSong.title }}</strong>
-							<br>
-							<small>{{ $parent.currentSong.artists }}</small>
+							Current Song:
+							<strong>{{ currentSong.title }}</strong>
+							<br />
+							<small>{{ currentSong.artists }}</small>
 						</p>
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.formatTime($parent.currentSong.duration) }}
+					{{ $parent.formatTime(currentSong.duration) }}
 				</div>
 			</article>
-			<p v-if="$parent.noSong" class="center">There is currently no song playing.</p>
+			<p v-if="noSong" class="center">
+				There is currently no song playing.
+			</p>
 
-			<article class="media" v-for='song in $parent.songsList'>
+			<article
+				v-else
+				v-for="(song, index) in songsList"
+				:key="index"
+				class="media"
+			>
 				<div class="media-content">
-					<div class="content">
-						<p>
-							<strong>{{ song.title }}</strong>
-							<br>
-							<small>{{ song.artists.join(', ') }}</small>
-						</p>
+					<div
+						class="content"
+						style="display: block;padding-top: 10px;"
+					>
+						<strong class="songTitle">{{ song.title }}</strong>
+						<small>{{ song.artists.join(", ") }}</small>
+						<div
+							v-if="
+								station.type === 'community' &&
+									station.partyMode === true
+							"
+						>
+							<small>
+								Requested by
+								<b>
+									<user-id-to-username
+										:userId="song.requestedBy"
+										:link="true"
+									/>
+								</b>
+							</small>
+							<i
+								v-if="isOwnerOnly() || isAdminOnly()"
+								class="material-icons"
+								style="vertical-align: middle;"
+								@click="removeFromQueue(song.songId)"
+								>delete_forever</i
+							>
+						</div>
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.$parent.formatTime(song.duration) }}
+					{{ $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="
+					station.type === 'community' &&
+						loggedIn &&
+						station.partyMode === true
+				"
+			>
+				<button
+					v-if="
+						(station.locked && isOwnerOnly()) ||
+							!station.locked ||
+							(station.locked &&
+								isAdminOnly() &&
+								dismissedWarning)
+					"
+					class="button add-to-queue"
+					@click="
+						openModal({
+							sector: 'station',
+							modal: 'addSongToQueue'
+						})
+					"
+				>
+					Add Song to Queue
+				</button>
+				<button
+					v-if="
+						station.locked &&
+							isAdminOnly() &&
+							!isOwnerOnly() &&
+							!dismissedWarning
+					"
+					class="button add-to-queue add-to-queue-warning"
+					@click="dismissedWarning = true"
+				>
+					THIS STATION'S QUEUE IS LOCKED.
+				</button>
+				<button
+					v-if="station.locked && !isAdminOnly() && !isOwnerOnly()"
+					class="button add-to-queue add-to-queue-disabled"
+				>
+					THIS STATION'S QUEUE IS LOCKED.
+				</button>
+			</div>
 		</div>
 	</div>
 </template>
 
 <script>
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		data: function () {
-			return {
+import { Toast } from "vue-roaster";
 
-			}
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	data() {
+		return {
+			dismissedWarning: false
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		userId: state => state.user.auth.userId,
+		role: state => state.user.auth.role,
+		station: state => state.station.station,
+		currentSong: state => state.station.currentSong,
+		songsList: state => state.station.songsList,
+		noSong: state => state.station.noSong
+	}),
+	methods: {
+		isOwnerOnly() {
+			return this.loggedIn && this.userId === this.station.owner;
+		},
+		isAdminOnly() {
+			return this.loggedIn && this.role === "admin";
 		},
-		ready: function () {
-			/*let _this = this;
+		removeFromQueue(songId) {
+			window.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);
+				}
+			);
+		},
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		/*
 			io.getSocket((socket) => {
-				_this.socket = socket;
+				this.socket = socket;
 
-			});*/
-		}
-	}
+			}); */
+	},
+	components: { UserIdToUsername }
+};
 </script>
 
-<style type='scss' scoped>
-	.sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		right: 0;
-		width: 300px;
-		height: 100vh;
-		background-color: #fff;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.inner-wrapper {	
-		top: 64px;
-		position: relative;
-		overflow: auto;
-		height: 100%;
-	}
+.sidebar {
+	position: fixed;
+	z-index: 1;
+	top: 0;
+	right: 0;
+	width: 300px;
+	height: 100vh;
+	background-color: $white;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+}
 
-	.slide-transition {
-		transition: transform 0.6s ease-in-out;
-		transform: translateX(0);
-	}
+.inner-wrapper {
+	top: 60px;
+	position: relative;
+	overflow: auto;
+	height: 100%;
+}
 
-	.slide-enter, .slide-leave { transform: translateX(100%); }
+.slide-transition {
+	transition: transform 0.6s ease-in-out;
+	transform: translateX(0);
+}
 
-	.title {
-		background-color: rgb(3, 169, 244);
-		text-align: center;
-		padding: 10px;
-		color: white;
-		font-weight: 600;
-	}
+.slide-enter,
+.slide-leave {
+	transform: translateX(100%);
+}
 
-	.media { padding: 0 25px; }
+.title {
+	background-color: rgb(3, 169, 244);
+	text-align: center;
+	padding: 10px;
+	color: $white;
+	font-weight: 600;
+}
 
-	.media-content .content {
-		min-height: 64px;
-		display: flex;
-		align-items: center;
-	}
+.media {
+	padding: 0 25px;
+}
+
+.media-content .content {
+	min-height: 64px;
+	display: flex;
+	align-items: center;
+}
+
+.content p strong {
+	word-break: break-word;
+}
+
+.content p small {
+	word-break: break-word;
+}
 
-	.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;
+.add-to-queue {
+	width: 100%;
+	margin-top: 25px;
+	height: 40px;
+	border-radius: 0;
+	background: rgb(3, 169, 244);
+	color: $white !important;
+	border: 0;
+	&:active,
+	&:focus {
 		border: 0;
-		&:active, &:focus { border: 0; }
 	}
-	
-	.add-to-queue:focus { background: #029ce3; }
+}
+
+.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: $primary-color;
+}
+
+.media-right {
+	line-height: 64px;
+}
 
-	.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>

Some files were not shown because too many files changed in this diff