Owen Diffey 3 місяців тому
батько
коміт
d4ddc6e03e
85 змінених файлів з 3105 додано та 3183 видалено
  1. 5 13
      .env.example
  2. 3 14
      .github/workflows/automated-tests.yml
  3. 22 0
      .github/workflows/build.yml
  4. 1 1
      .github/workflows/codeql-analysis.yml
  5. 7 20
      .github/workflows/lint.yml
  6. 1 1
      .gitignore
  7. 9 0
      .vscode/extensions.json
  8. 21 0
      .vscode/launch.json
  9. 6 0
      .vscode/settings.json
  10. 45 33
      .wiki/Configuration.md
  11. 62 0
      CHANGELOG.md
  12. 83 0
      Dockerfile
  13. 82 0
      Dockerfile.dev
  14. 1 1
      README.md
  15. 0 26
      backend/Dockerfile
  16. 9 7
      backend/config/default.json
  17. 1 1
      backend/config/template.json
  18. 20 0
      backend/entrypoint.dev.sh
  19. 7 3
      backend/index.js
  20. 5 4
      backend/logic/actions/activities.js
  21. 3 2
      backend/logic/actions/apis.js
  22. 3 2
      backend/logic/actions/media.js
  23. 8 6
      backend/logic/actions/news.js
  24. 7 6
      backend/logic/actions/playlists.js
  25. 93 90
      backend/logic/actions/stations.js
  26. 80 779
      backend/logic/actions/users.js
  27. 100 467
      backend/logic/app.js
  28. 2 2
      backend/logic/db/index.js
  29. 11 8
      backend/logic/db/schemas/user.js
  30. 1 1
      backend/logic/hooks/hasPermission.js
  31. 50 0
      backend/logic/hooks/loginSometimesRequired.js
  32. 19 0
      backend/logic/mail/index.js
  33. 35 0
      backend/logic/migration/migrations/migration26.js
  34. 3 3
      backend/logic/spotify.js
  35. 571 0
      backend/logic/users.js
  36. 4 2
      backend/logic/ws.js
  37. 7 0
      backend/nodemon.json
  38. 287 355
      backend/package-lock.json
  39. 27 28
      backend/package.json
  40. 25 0
      compose.dev.yml
  41. 12 0
      compose.local.yml
  42. 4 0
      compose.override.yml.example
  43. 29 33
      compose.yml
  44. 0 24
      docker-compose.dev.yml
  45. 0 50
      frontend/Dockerfile
  46. 0 46
      frontend/dev.nginx.conf
  47. 18 0
      frontend/entrypoint.dev.sh
  48. 0 15
      frontend/entrypoint.sh
  49. 31 0
      frontend/nginx.dev.conf
  50. 24 0
      frontend/nginx.prod.conf
  51. 435 195
      frontend/package-lock.json
  52. 35 36
      frontend/package.json
  53. 0 39
      frontend/prod.nginx.conf
  54. 21 5
      frontend/src/App.vue
  55. 27 6
      frontend/src/components/AdvancedTable.vue
  56. 1 1
      frontend/src/components/MainFooter.vue
  57. 25 3
      frontend/src/components/MainHeader.vue
  58. 10 5
      frontend/src/components/modals/CreatePlaylist.vue
  59. 18 4
      frontend/src/components/modals/CreateStation.vue
  60. 1 1
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  61. 1 1
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  62. 5 5
      frontend/src/components/modals/EditSong/index.vue
  63. 1 30
      frontend/src/components/modals/Login.vue
  64. 1 32
      frontend/src/components/modals/Register.vue
  65. 9 119
      frontend/src/components/modals/RemoveAccount.vue
  66. 1 1
      frontend/src/composables/useYoutubeDirect.ts
  67. 2 2
      frontend/src/index.html
  68. 10 11
      frontend/src/main.ts
  69. 32 37
      frontend/src/pages/Admin/Users/index.vue
  70. 19 2
      frontend/src/pages/Home.vue
  71. 9 30
      frontend/src/pages/ResetPassword.vue
  72. 10 23
      frontend/src/pages/Settings/Tabs/Account.vue
  73. 40 3
      frontend/src/pages/Settings/Tabs/Preferences.vue
  74. 14 11
      frontend/src/pages/Settings/Tabs/Profile.vue
  75. 5 82
      frontend/src/pages/Settings/Tabs/Security.vue
  76. 7 22
      frontend/src/pages/Settings/index.vue
  77. 1 1
      frontend/src/pages/Station/Sidebar/Users.vue
  78. 4 2
      frontend/src/stores/config.ts
  79. 0 4
      frontend/src/stores/settings.ts
  80. 1 1
      frontend/src/stores/station.ts
  81. 11 1
      frontend/src/stores/userPreferences.ts
  82. 5 3
      frontend/src/types/user.ts
  83. 2 2
      frontend/vite.config.js
  84. 496 420
      musare.sh
  85. 2 0
      types/models/User.ts

+ 5 - 13
.env.example

@@ -1,30 +1,22 @@
 COMPOSE_PROJECT_NAME=musare
 COMPOSE_PROJECT_NAME=musare
-RESTART_POLICY=unless-stopped
-CONTAINER_MODE=production
 DOCKER_COMMAND=docker
 DOCKER_COMMAND=docker
+CONTAINER_MODE=production
+
+APP_ENV=production
 
 
-BACKEND_HOST=127.0.0.1
-BACKEND_PORT=8080
+BACKEND_DEBUG=false
+BACKEND_DEBUG_PORT=9229
 
 
-FRONTEND_HOST=127.0.0.1
-FRONTEND_PORT=80
 FRONTEND_CLIENT_PORT=80
 FRONTEND_CLIENT_PORT=80
 FRONTEND_DEV_PORT=81
 FRONTEND_DEV_PORT=81
-FRONTEND_MODE=production
 FRONTEND_PROD_DEVTOOLS=false
 FRONTEND_PROD_DEVTOOLS=false
 
 
-MONGO_HOST=127.0.0.1
-MONGO_PORT=27017
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_USER_USERNAME=musare
 MONGO_USER_USERNAME=musare
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
-MONGO_DATA_LOCATION=.db
 MONGO_VERSION=6
 MONGO_VERSION=6
 
 
-REDIS_HOST=127.0.0.1
-REDIS_PORT=6379
 REDIS_PASSWORD=PASSWORD
 REDIS_PASSWORD=PASSWORD
-REDIS_DATA_LOCATION=.redis
 
 
 BACKUP_LOCATION=
 BACKUP_LOCATION=
 BACKUP_NAME=
 BACKUP_NAME=

+ 3 - 14
.github/workflows/automated-tests.yml

@@ -4,33 +4,22 @@ on: [ push, pull_request, workflow_dispatch ]
 
 
 env:
 env:
     COMPOSE_PROJECT_NAME: musare
     COMPOSE_PROJECT_NAME: musare
-    RESTART_POLICY: unless-stopped
-    CONTAINER_MODE: production
-    BACKEND_HOST: 127.0.0.1
-    BACKEND_PORT: 8080
-    FRONTEND_HOST: 127.0.0.1
-    FRONTEND_PORT: 80
-    FRONTEND_MODE: production
-    MONGO_HOST: 127.0.0.1
-    MONGO_PORT: 27017
+    APP_ENV: development
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_USER_USERNAME: musare
     MONGO_USER_USERNAME: musare
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
-    MONGO_DATA_LOCATION: .db
     MONGO_VERSION: 5.0
     MONGO_VERSION: 5.0
-    REDIS_HOST: 127.0.0.1
-    REDIS_PORT: 6379
     REDIS_PASSWORD: PASSWORD
     REDIS_PASSWORD: PASSWORD
-    REDIS_DATA_LOCATION: .redis
 
 
 jobs:
 jobs:
     tests:
     tests:
         runs-on: ubuntu-latest
         runs-on: ubuntu-latest
         steps:
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
             - name: Build Musare
             - name: Build Musare
               run: |
               run: |
                   cp .env.example .env
                   cp .env.example .env
+                  sed -i 's/APP_ENV=production/APP_ENV=development/g' .env
                   ./musare.sh build
                   ./musare.sh build
             - name: Start Musare
             - name: Start Musare
               run: ./musare.sh start
               run: ./musare.sh start

+ 22 - 0
.github/workflows/build.yml

@@ -0,0 +1,22 @@
+name: Musare Build
+
+on: [ push, pull_request, workflow_dispatch ]
+
+env:
+    COMPOSE_PROJECT_NAME: musare
+    APP_ENV: production
+    MONGO_ROOT_PASSWORD: PASSWORD_HERE
+    MONGO_USER_USERNAME: musare
+    MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
+    MONGO_VERSION: 5.0
+    REDIS_PASSWORD: PASSWORD
+
+jobs:
+    build:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/checkout@v4
+            - name: Build Musare
+              run: |
+                  cp .env.example .env
+                  ./musare.sh build

+ 1 - 1
.github/workflows/codeql-analysis.yml

@@ -18,7 +18,7 @@ jobs:
 
 
     steps:
     steps:
     - name: Checkout repository
     - name: Checkout repository
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
 
     - name: Initialize CodeQL
     - name: Initialize CodeQL
       uses: github/codeql-action/init@v2
       uses: github/codeql-action/init@v2

+ 7 - 20
.github/workflows/build-lint.yml → .github/workflows/lint.yml

@@ -1,46 +1,33 @@
-name: Musare Build and Lint
+name: Musare Lint
 
 
 on: [ push, pull_request, workflow_dispatch ]
 on: [ push, pull_request, workflow_dispatch ]
 
 
 env:
 env:
     COMPOSE_PROJECT_NAME: musare
     COMPOSE_PROJECT_NAME: musare
-    RESTART_POLICY: unless-stopped
-    CONTAINER_MODE: production
-    BACKEND_HOST: 127.0.0.1
-    BACKEND_PORT: 8080
-    FRONTEND_HOST: 127.0.0.1
-    FRONTEND_PORT: 80
-    FRONTEND_MODE: production
-    MONGO_HOST: 127.0.0.1
-    MONGO_PORT: 27017
+    APP_ENV: development
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_USER_USERNAME: musare
     MONGO_USER_USERNAME: musare
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
-    MONGO_DATA_LOCATION: .db
     MONGO_VERSION: 5.0
     MONGO_VERSION: 5.0
-    REDIS_HOST: 127.0.0.1
-    REDIS_PORT: 6379
     REDIS_PASSWORD: PASSWORD
     REDIS_PASSWORD: PASSWORD
-    REDIS_DATA_LOCATION: .redis
 
 
 jobs:
 jobs:
-    build-lint:
+    lint:
         runs-on: ubuntu-latest
         runs-on: ubuntu-latest
         steps:
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
             - name: Build Musare
             - name: Build Musare
               run: |
               run: |
                   cp .env.example .env
                   cp .env.example .env
+                  sed -i 's/APP_ENV=production/APP_ENV=development/g' .env
                   ./musare.sh build
                   ./musare.sh build
             - name: Start Musare
             - name: Start Musare
               run: ./musare.sh start
               run: ./musare.sh start
             - name: Backend Lint
             - name: Backend Lint
               run: ./musare.sh lint backend
               run: ./musare.sh lint backend
-            - name: Backend Typescript
-              run: ./musare.sh typescript backend
             - name: Frontend Lint
             - name: Frontend Lint
               run: ./musare.sh lint frontend
               run: ./musare.sh lint frontend
-#            - name: Frontend Typescript
-#              run: ./musare.sh typescript frontend
             - name: Docs Lint
             - name: Docs Lint
               run: ./musare.sh lint docs
               run: ./musare.sh lint docs
+            - name: Shell Lint
+              run: ./musare.sh lint shell

+ 1 - 1
.gitignore

@@ -2,7 +2,6 @@ Thumbs.db
 .DS_Store
 .DS_Store
 *.swp
 *.swp
 .idea/
 .idea/
-.vscode/
 .vagrant/
 .vagrant/
 
 
 .env
 .env
@@ -13,6 +12,7 @@ startMongo.cmd
 .redis
 .redis
 *.rdb
 *.rdb
 backups/
 backups/
+compose.override.yml
 docker-compose.override.yml
 docker-compose.override.yml
 
 
 npm-debug.log
 npm-debug.log

+ 9 - 0
.vscode/extensions.json

@@ -0,0 +1,9 @@
+{
+    "recommendations": [
+        "ms-azuretools.vscode-docker",
+        "dbaeumer.vscode-eslint",
+        "rvest.vs-code-prettier-eslint",
+        "Vue.vscode-typescript-vue-plugin",
+        "Vue.volar"
+    ]
+}

+ 21 - 0
.vscode/launch.json

@@ -0,0 +1,21 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Docker: Attach to Node",
+            "type": "node",
+            "request": "attach",
+            "port": 9229,
+            "address": "localhost",
+            "localRoot": "${workspaceFolder}/backend",
+            "remoteRoot": "/opt/app/",
+            "autoAttachChildProcesses": true,
+            "restart": true,
+            "continueOnAttach": true,
+            "smartStep": true
+        }
+    ]
+}

+ 6 - 0
.vscode/settings.json

@@ -0,0 +1,6 @@
+{
+	"javascript.suggest.completeJSDocs": false,
+	"typescript.suggest.completeJSDocs": false,
+	"typescript.suggest.jsdoc.generateReturns": false,
+	"javascript.suggest.jsdoc.generateReturns": false
+}

+ 45 - 33
.wiki/Configuration.md

@@ -12,41 +12,22 @@ After updating values in `.env`, containers should be restarted or rebuilt.
 If you are using a different setup, you will need to define the relevant
 If you are using a different setup, you will need to define the relevant
 environment variables yourself.
 environment variables yourself.
 
 
-In the table below, the `[SERVICE]_HOST` properties refer to the IP address that
-the Docker container listens on. Setting this to `127.0.0.1` for will only expose
-the configured port to localhost, whereas setting this to `0.0.0.0` will expose the
-port on all interfaces.  
-The `[SERVICE]_PORT` properties refer to the external Docker container port, used
-to access services from outside the container. Changing this does not require any
-changes to configuration within container. For example, setting the `MONGO_PORT`
-to `21018` will allow you to access the mongo database through that port on your
-machine, even though the application within the container is listening on `21017`.
-
 | Property | Description |
 | Property | Description |
 | --- | --- |
 | --- | --- |
 | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
 | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
-| `RESTART_POLICY` | Restart policy for Docker containers, values can be found [here](https://docs.docker.com/config/containers/start-containers-automatically/). |
-| `CONTAINER_MODE` | Should be either `production` or `development`.  |
 | `DOCKER_COMMAND` | Should be either `docker` or `podman`.  |
 | `DOCKER_COMMAND` | Should be either `docker` or `podman`.  |
-| `BACKEND_HOST` | Backend container host. Only used for development mode. |
-| `BACKEND_PORT` | Backend container port. Only used for development mode. |
-| `FRONTEND_HOST` | Frontend container host. |
-| `FRONTEND_PORT` | Frontend container port. |
+| `CONTAINER_MODE` | Should be either `production` or `local`.  |
+| `APP_ENV` | Should be either `production` or `development`.  |
+| `BACKEND_DEBUG` | Should be either `true` or `false`. If enabled backend will await debugger connection and trigger to start. |
+| `BACKEND_DEBUG_PORT` | Backend container debug port, if enabled. |
 | `FRONTEND_CLIENT_PORT` | Should be the port on which the frontend will be accessible from, usually port `80`, or `443` if using SSL. Only used when running in development mode. |
 | `FRONTEND_CLIENT_PORT` | Should be the port on which the frontend will be accessible from, usually port `80`, or `443` if using SSL. Only used when running in development mode. |
 | `FRONTEND_DEV_PORT` | Should be the port where Vite's dev server will be accessible from, should always be port `81` for Docker since nginx listens on port 80, and is recommended to be port `80` for non-Docker. Only used when running in development mode. |
 | `FRONTEND_DEV_PORT` | Should be the port where Vite's dev server will be accessible from, should always be port `81` for Docker since nginx listens on port 80, and is recommended to be port `80` for non-Docker. Only used when running in development mode. |
-| `FRONTEND_MODE` | Should be either `production` or `development`. |
 | `FRONTEND_PROD_DEVTOOLS` | Whether to enable Vue dev tools in production builds. [^1] |
 | `FRONTEND_PROD_DEVTOOLS` | Whether to enable Vue dev tools in production builds. [^1] |
-| `MONGO_HOST` | Mongo container host. |
-| `MONGO_PORT` | Mongo container port. |
 | `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
 | `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
 | `MONGO_USER_USERNAME` | Application username for MongoDB. |
 | `MONGO_USER_USERNAME` | Application username for MongoDB. |
 | `MONGO_USER_PASSWORD` | Application password for MongoDB. |
 | `MONGO_USER_PASSWORD` | Application password for MongoDB. |
-| `MONGO_DATA_LOCATION` | The location where MongoDB stores its data. Usually the `.db` folder inside the `Musare` folder. |
 | `MONGO_VERSION` | The MongoDB version to use for scripts and docker compose. Must be numerical. Currently supported MongoDB versions are 4.0+. Always make a backup before changing this value. |
 | `MONGO_VERSION` | The MongoDB version to use for scripts and docker compose. Must be numerical. Currently supported MongoDB versions are 4.0+. Always make a backup before changing this value. |
-| `REDIS_HOST` | Redis container host. |
-| `REDIS_PORT` | Redis container port. |
 | `REDIS_PASSWORD` | Redis password. |
 | `REDIS_PASSWORD` | Redis password. |
-| `REDIS_DATA_LOCATION` | The location where Redis stores its data. Usually the `.redis` folder inside the `Musare` folder. |
 | `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
 | `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
 | `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |
 | `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |
 | `MUSARE_SITENAME` | Should be the name of the site. [^1] |
 | `MUSARE_SITENAME` | Should be the name of the site. [^1] |
@@ -111,10 +92,11 @@ For more information on configuration files please refer to the
 | `apis.recaptcha.enabled` | Whether to enable ReCaptcha in the regular (email) registration form. |
 | `apis.recaptcha.enabled` | Whether to enable ReCaptcha in the regular (email) registration form. |
 | `apis.recaptcha.key` | ReCaptcha Site v3 key, obtained from [here](https://www.google.com/recaptcha/admin). |
 | `apis.recaptcha.key` | ReCaptcha Site v3 key, obtained from [here](https://www.google.com/recaptcha/admin). |
 | `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained with key. |
 | `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained with key. |
-| `apis.github.enabled` | Whether to enable GitHub authentication. |
-| `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
-| `apis.github.secret` | GitHub OAuth Application secret, obtained with client. |
-| `apis.github.redirect_uri` | The backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. This is configured based on the `url` config option by default. |
+| `apis.oidc.enabled` | Whether to enable OIDC authentication. |
+| `apis.oidc.client_id` | OIDC client id. |
+| `apis.oidc.client_secret` | OIDC client secret. |
+| `apis.oidc.openid_configuration_url` | The URL that points to the openid_configuration resource of the OIDC provider. |
+| `apis.oidc.redirect_uri` | The backend url with `/auth/oidc/authorize/callback` appended, for example `http://localhost/backend/auth/oidc/authorize/callback`. This is configured based on the `url` config option by default, so this is optional. |
 | `apis.discogs.enabled` | Whether to enable Discogs API usage. |
 | `apis.discogs.enabled` | Whether to enable Discogs API usage. |
 | `apis.discogs.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
 | `apis.discogs.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
 | `apis.discogs.secret` | Discogs Application secret, obtained with client. |
 | `apis.discogs.secret` | Discogs Application secret, obtained with client. |
@@ -142,6 +124,7 @@ For more information on configuration files please refer to the
 | `primaryColor` | Primary color of the application, in hex format. |
 | `primaryColor` | Primary color of the application, in hex format. |
 | `registrationDisabled` | If set to `true`, users can't register accounts. |
 | `registrationDisabled` | If set to `true`, users can't register accounts. |
 | `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. Requires mail to be enabled and configured. |
 | `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. Requires mail to be enabled and configured. |
+| `restrictToUsers` | If `true` only logged-in users will be able to visit user profiles, see news, see stations on the homepage or enter stations (even public stations) - any interactive thing except logging in/registering, and some public config info (site name, experimental features enabled, footer mail/oidc/password enabled, account removal message, etc.) |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
 | `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capture all jobs specified in `debug.captureJobs`. |
 | `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capture all jobs specified in `debug.captureJobs`. |
@@ -168,18 +151,47 @@ For more information on configuration files please refer to the
 | `experimental.soundcloud` | Experimental SoundCloud integration. |
 | `experimental.soundcloud` | Experimental SoundCloud integration. |
 | `experimental.spotify` | Experimental Spotify integration. |
 | `experimental.spotify` | Experimental Spotify integration. |
 
 
-## Docker-compose override
+## Docker
+
+Below are some snippets that may help you get started with Docker.
+For more information please see the [Docker documentation](https://docs.docker.com).
+
+### Compose override
 
 
-You may want to override the docker-compose files in some specific cases.  
-For this, you can create a `docker-compose.override.yml` file.
+You may want to override the docker compose files in some specific cases.
+For this, you can create a `compose.override.yml` file.
+An example is available at [compose.override.yml.example](../compose.override.yml.example).
+
+For example, to expose the frontend port:
+
+```yml
+services:
+  frontend:
+    ports:
+      - "127.0.0.1:1234:80"
+```
 
 
-For example, to expose the backend port:
+...and to expose the backend debug port:
 
 
 ```yml
 ```yml
 services:
 services:
   backend:
   backend:
     ports:
     ports:
-      - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
+      - "127.0.0.1:9229:9229"
 ```
 ```
 
 
-This assumes that you have also set `BACKEND_PORT` inside your `.env` file.
+### Daemon configuration
+
+The below is an example `daemon.json` configured to bind to a specific IP,
+and setup log rotation.
+
+```json
+{
+  "ip": "127.0.0.1",
+  "log-driver": "json-file",
+  "log-opts": {
+    "max-size": "10m",
+    "max-file": "10"
+  }
+}
+```

+ 62 - 0
CHANGELOG.md

@@ -1,5 +1,67 @@
 # Changelog
 # Changelog
 
 
+## [v3.12.0] - 2025-02-09
+
+This release includes all changes from v3.12.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Changed
+
+- refactor: Hide OIDC sub from admin users list if OIDC disabled
+
+### Fixed
+
+- fix: Pull images during musare.sh build
+- fix: Reordering AdvancedTable table headers ineffective
+- fix: Settings page had race condition where inputs wouldn't be filled
+
+## [v3.12.0-rc1] - 2025-01-19
+
+### **Breaking Changes**
+
+This release includes breaking changes to our docker setup, in particular the
+usage of named volumes and the removal of many redundant configuration options.
+
+In addition to this, GitHub authentication has been removed. If your instance
+has GitHub users, keep this in mind. If a user only has GitHub currently, you
+could instruct them to set a password before updating, or they can reset their
+password after updating if this is enabled on your instance.
+
+Before updating or pulling changes please make a full backup,
+and after updating restore using the [Utility Script](./.wiki/Utility_Script.md).
+Please refer to the [Configuration documentation](.wiki/Configuration.md)
+for more information on how you should now configure docker.
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Add env config change check to musare.sh update
+- chore: Add backend debug
+- chore: Add vscode settings and extensions
+- feat: OIDC authentication
+- feat: Add default station and playlist privacy preferences
+- feat: Add privacy option to create station modal
+- feat: Add configuration option to retrict site to logged in users
+
+### Changed
+
+- refactor: Use node alpine docker images
+- refactor: Use non-root user in docker
+- refactor: Separates docker environment builds and combines modes into APP_ENV
+- refactor: Remove unnecessary configuration options
+- refactor: Split docker networks
+- refactor: Improve musare.sh handling and styling
+- chore: Update to node 22
+- refactor: Move users actions logic to module jobs
+- refactor: Remove GitHub authentication
+
+### Fixed
+
+- fix: Station undefined in autorequestExcludedMediaSources
+- fix: Advanced table hidden columns table header visible
+- fix: Adding song to playlist from YouTube search in EditPlaylist wouldn't work
+
 ## [v3.11.0] - 2024-03-02
 ## [v3.11.0] - 2024-03-02
 
 
 This release includes all changes from v3.11.0-rc1, in addition to the following.
 This release includes all changes from v3.11.0-rc1, in addition to the following.

+ 83 - 0
Dockerfile

@@ -0,0 +1,83 @@
+# Common base image
+FROM node:22-alpine AS common_base
+
+ARG UID=1000
+ARG GID=1000
+
+RUN deluser --remove-home node \
+    && addgroup -S -g ${GID} musare \
+    && adduser -SD -u ${UID} musare \
+    && adduser musare musare
+
+RUN mkdir -p /opt/.git /opt/types /opt/app \
+    && chown -R musare:musare /opt/app
+
+WORKDIR /opt/app
+
+USER musare
+
+# Backend node modules
+FROM common_base AS backend_node_modules
+
+COPY --chown=musare:musare --link backend/package.json backend/package-lock.json /opt/app/
+
+RUN npm install
+
+# Backend production image
+FROM common_base AS backend
+
+ENV APP_ENV=production
+
+COPY --chown=musare:musare --link .git /opt/.git
+COPY --chown=musare:musare --link backend /opt/app
+COPY --chown=musare:musare --link --from=backend_node_modules /opt/app/node_modules node_modules
+
+ENTRYPOINT npm run prod
+
+EXPOSE 8080
+
+# Frontend node modules
+FROM common_base AS frontend_node_modules
+
+COPY --chown=musare:musare --link frontend/package.json frontend/package-lock.json /opt/app/
+
+RUN npm install
+
+# Frontend build
+FROM common_base AS frontend_build
+
+ARG FRONTEND_PROD_DEVTOOLS=false
+ARG MUSARE_SITENAME=Musare
+ARG MUSARE_PRIMARY_COLOR="#03a9f4"
+ARG MUSARE_DEBUG_VERSION=true
+ARG MUSARE_DEBUG_GIT_REMOTE=false
+ARG MUSARE_DEBUG_GIT_REMOTE_URL=false
+ARG MUSARE_DEBUG_GIT_BRANCH=true
+ARG MUSARE_DEBUG_GIT_LATEST_COMMIT=true
+ARG MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=true
+
+ENV APP_ENV=production \
+    FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS} \
+    MUSARE_SITENAME=${MUSARE_SITENAME} \
+    MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR} \
+    MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION} \
+    MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE} \
+    MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL} \
+    MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH} \
+    MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT} \
+    MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT}
+
+COPY --chown=musare:musare --link .git /opt/.git
+COPY --chown=musare:musare --link types /opt/types
+COPY --chown=musare:musare --link frontend /opt/app
+COPY --chown=musare:musare --from=frontend_node_modules --link /opt/app/node_modules node_modules
+
+RUN npm run prod
+
+# Frontend production image
+FROM nginx AS frontend
+
+COPY --chown=root:root --link frontend/nginx.prod.conf /etc/nginx/conf.d/default.conf
+COPY --from=frontend_build --chown=nginx:nginx --link /opt/app/build /usr/share/nginx/html
+
+EXPOSE 80

+ 82 - 0
Dockerfile.dev

@@ -0,0 +1,82 @@
+# Common base image
+FROM node:22-alpine AS common_base
+
+ARG UID=1000
+ARG GID=1000
+
+RUN deluser --remove-home node \
+    && addgroup -S -g ${GID} musare \
+    && adduser -SD -u ${UID} musare \
+    && adduser musare musare
+
+RUN mkdir -p /opt/.git /opt/types /opt/app \
+    && chown -R musare:musare /opt/app
+
+WORKDIR /opt/app
+
+USER musare
+
+# Backend node modules
+FROM common_base AS backend_node_modules
+
+COPY --chown=musare:musare --link backend/package.json backend/package-lock.json /opt/app/
+
+RUN npm install
+
+# Backend development image
+FROM common_base AS backend
+
+ENV APP_ENV=development
+
+COPY --chown=musare:musare --link .git /opt/.git
+COPY --chown=musare:musare --link backend /opt/app
+COPY --chown=musare:musare --link --from=backend_node_modules /opt/app/node_modules node_modules
+
+ENTRYPOINT sh /opt/app/entrypoint.dev.sh
+
+EXPOSE 8080
+
+# Frontend node modules
+FROM common_base AS frontend_node_modules
+
+COPY --chown=musare:musare --link frontend/package.json frontend/package-lock.json /opt/app/
+
+RUN npm install
+
+# Frontend development image
+FROM common_base AS frontend
+
+ARG MUSARE_SITENAME=Musare
+ARG MUSARE_PRIMARY_COLOR="#03a9f4"
+ARG MUSARE_DEBUG_VERSION=true
+ARG MUSARE_DEBUG_GIT_REMOTE=false
+ARG MUSARE_DEBUG_GIT_REMOTE_URL=false
+ARG MUSARE_DEBUG_GIT_BRANCH=true
+ARG MUSARE_DEBUG_GIT_LATEST_COMMIT=true
+ARG MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=true
+
+ENV APP_ENV=development \
+    MUSARE_SITENAME=${MUSARE_SITENAME} \
+    MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR} \
+    MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION} \
+    MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE} \
+    MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL} \
+    MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH} \
+    MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT} \
+    MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT}
+
+USER root
+RUN apk update \
+    && apk add nginx \
+    && sed -i 's/user nginx;/user musare;/' /etc/nginx/nginx.conf \
+    && chown -R musare:musare /etc/nginx/http.d /run/nginx /var/lib/nginx /var/log/nginx
+USER musare
+
+COPY --chown=musare:musare --link .git /opt/.git
+COPY --chown=musare:musare --link types /opt/types
+COPY --chown=musare:musare --link frontend /opt/app
+COPY --chown=musare:musare --from=frontend_node_modules --link /opt/app/node_modules node_modules
+
+ENTRYPOINT sh /opt/app/entrypoint.dev.sh
+
+EXPOSE 80

+ 1 - 1
README.md

@@ -71,7 +71,7 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
   - Activity logs
   - Activity logs
   - Profile page showing public playlists and activity logs
   - Profile page showing public playlists and activity logs
   - Text or gravatar profile pictures
   - Text or gravatar profile pictures
-  - Email or Github login/registration
+  - Email or OIDC login/registration
   - Preferences to tailor site usage
   - Preferences to tailor site usage
   - Password reset
   - Password reset
   - Data deletion management
   - Data deletion management

+ 0 - 26
backend/Dockerfile

@@ -1,26 +0,0 @@
-FROM node:18 AS backend_node_modules
-
-RUN mkdir -p /opt/app
-WORKDIR /opt/app
-
-COPY backend/package.json backend/package-lock.json /opt/app/
-
-RUN npm install
-
-FROM node:18 AS musare_backend
-
-ARG CONTAINER_MODE=production
-ENV CONTAINER_MODE=${CONTAINER_MODE}
-
-RUN mkdir -p /opt/.git /opt/types /opt/app
-WORKDIR /opt/app
-
-COPY .git /opt/.git
-COPY types /opt/types
-COPY backend /opt/app
-COPY --from=backend_node_modules /opt/app/node_modules node_modules
-
-ENTRYPOINT bash -c '([[ "${CONTAINER_MODE}" == "development" ]] && npm install); npm run docker:dev'
-
-EXPOSE 8080/tcp
-EXPOSE 8080/udp

+ 9 - 7
backend/config/default.json

@@ -1,5 +1,5 @@
 {
 {
-	"configVersion": 12,
+	"configVersion": 13,
 	"migration": false,
 	"migration": false,
 	"secret": "default",
 	"secret": "default",
 	"port": 8080,
 	"port": 8080,
@@ -52,16 +52,17 @@
 			"key": "",
 			"key": "",
 			"secret": ""
 			"secret": ""
 		},
 		},
-		"github": {
-			"enabled": false,
-			"client": "",
-			"secret": "",
-			"redirect_uri": ""
-		},
 		"discogs": {
 		"discogs": {
 			"enabled": false,
 			"enabled": false,
 			"client": "",
 			"client": "",
 			"secret": ""
 			"secret": ""
+		},
+		"oidc": {
+			"enabled": false,
+			"client_id": "",
+			"secret_secret": "",
+			"openid_configuration_url": "",
+			"redirect_uri": ""
 		}
 		}
 	},
 	},
 	"cors": {
 	"cors": {
@@ -108,6 +109,7 @@
 	"shortcutOverrides": {},
 	"shortcutOverrides": {},
 	"registrationDisabled": false,
 	"registrationDisabled": false,
 	"sendDataRequestEmails": true,
 	"sendDataRequestEmails": true,
+	"restrictToUsers": false,
 	"skipConfigVersionCheck": false,
 	"skipConfigVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"debug": {
 	"debug": {

+ 1 - 1
backend/config/template.json

@@ -1,5 +1,5 @@
 {
 {
-	"configVersion": 12,
+	"configVersion": 13,
 	"migration": false,
 	"migration": false,
 	"secret": "CHANGE_ME",
 	"secret": "CHANGE_ME",
 	"url": {
 	"url": {

+ 20 - 0
backend/entrypoint.dev.sh

@@ -0,0 +1,20 @@
+#!/bin/sh
+
+set -e
+
+# Install node modules if not found
+if [ ! -d node_modules ]; then
+    npm install
+fi
+
+if [ "${BACKEND_DEBUG}" = "true" ]; then
+    export INSPECT_BRK="--inspect-brk=0.0.0.0:${BACKEND_DEBUG_PORT:-9229}"
+else
+    export INSPECT_BRK=""
+fi
+
+if [ "${APP_ENV}" = "development" ]; then
+    npm run dev
+else
+    npm run prod
+fi

+ 7 - 3
backend/index.js

@@ -5,9 +5,9 @@ import config from "config";
 import fs from "fs";
 import fs from "fs";
 
 
 import * as readline from "node:readline";
 import * as readline from "node:readline";
-import packageJson from "./package.json" assert { type: "json" };
+import packageJson from "./package.json" with { type: "json" };
 
 
-const REQUIRED_CONFIG_VERSION = 12;
+const REQUIRED_CONFIG_VERSION = 13;
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {
 Array.prototype.remove = function (item) {
@@ -194,7 +194,10 @@ class ModuleManager {
 	 */
 	 */
 	onFail(module) {
 	onFail(module) {
 		if (this.modulesNotInitialized.indexOf(module) !== -1) {
 		if (this.modulesNotInitialized.indexOf(module) !== -1) {
-			this.log("ERROR", "A module failed to initialize!");
+			this.log(
+				"ERROR",
+				`Module "${module.name}" failed to initialize at stage ${module.getStage()}! Check error above.`
+			);
 		}
 		}
 	}
 	}
 
 
@@ -260,6 +263,7 @@ if (!config.get("migration")) {
 	moduleManager.addModule("stations");
 	moduleManager.addModule("stations");
 	moduleManager.addModule("media");
 	moduleManager.addModule("media");
 	moduleManager.addModule("tasks");
 	moduleManager.addModule("tasks");
+	moduleManager.addModule("users");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");
 	moduleManager.addModule("youtube");
 	if (config.get("experimental.soundcloud")) moduleManager.addModule("soundcloud");
 	if (config.get("experimental.soundcloud")) moduleManager.addModule("soundcloud");

+ 5 - 4
backend/logic/actions/activities.js

@@ -1,6 +1,7 @@
 import async from "async";
 import async from "async";
 
 
 import isLoginRequired from "../hooks/loginRequired";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
 import moduleManager from "../../index";
 import moduleManager from "../../index";
@@ -37,7 +38,7 @@ export default {
 	 * @param {string} userId - the id of the user in question
 	 * @param {string} userId - the id of the user in question
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	async length(session, userId, cb) {
+	length: isLoginSometimesRequired(async function length(session, userId, cb) {
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
 
 		async.waterfall(
 		async.waterfall(
@@ -66,7 +67,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets a set of activities
 	 * Gets a set of activities
@@ -76,7 +77,7 @@ export default {
 	 * @param {number} offset - how many activities to skip (keeps frontend and backend in sync)
 	 * @param {number} offset - how many activities to skip (keeps frontend and backend in sync)
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	async getSet(session, userId, set, offset, cb) {
+	getSet: isLoginSometimesRequired(async function getSet(session, userId, set, offset, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
 
@@ -121,7 +122,7 @@ export default {
 				return cb({ status: "success", data: { activities } });
 				return cb({ status: "success", data: { activities } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Hides an activity for a user
 	 * Hides an activity for a user

+ 3 - 2
backend/logic/actions/apis.js

@@ -3,6 +3,7 @@ import async from "async";
 import axios from "axios";
 import axios from "axios";
 
 
 import isLoginRequired from "../hooks/loginRequired";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
@@ -314,7 +315,7 @@ export default {
 	 * @param {string} room - the room to join
 	 * @param {string} room - the room to join
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	joinRoom(session, room, cb) {
+	joinRoom: isLoginSometimesRequired(function joinRoom(session, room, cb) {
 		const roomName = room.split(".")[0];
 		const roomName = room.split(".")[0];
 		// const roomId = room.split(".")[1];
 		// const roomId = room.split(".")[1];
 		const rooms = {
 		const rooms = {
@@ -352,7 +353,7 @@ export default {
 				.then(() => join("success"))
 				.then(() => join("success"))
 				.catch(err => join("error", err));
 				.catch(err => join("error", err));
 		else join("error", "Room not found");
 		else join("error", "Room not found");
-	},
+	}),
 
 
 	/**
 	/**
 	 * Leaves a room
 	 * Leaves a room

+ 3 - 2
backend/logic/actions/media.js

@@ -1,6 +1,7 @@
 import async from "async";
 import async from "async";
 
 
 import isLoginRequired from "../hooks/loginRequired";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { useHasPermission } from "../hooks/hasPermission";
 import { useHasPermission } from "../hooks/hasPermission";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
@@ -706,7 +707,7 @@ export default {
 	 * @param cb
 	 * @param cb
 	 */
 	 */
 
 
-	async getRatings(session, mediaSource, cb) {
+	getRatings: isLoginSometimesRequired(async function getRatings(session, mediaSource, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -744,7 +745,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets user's own ratings
 	 * Gets user's own ratings

+ 8 - 6
backend/logic/actions/news.js

@@ -1,5 +1,6 @@
 import async from "async";
 import async from "async";
 
 
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { useHasPermission } from "../hooks/hasPermission";
 import { useHasPermission } from "../hooks/hasPermission";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
@@ -166,7 +167,7 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	async getPublished(session, cb) {
+	getPublished: isLoginSometimesRequired(async function getPublished(session, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		async.waterfall(
 		async.waterfall(
 			[
 			[
@@ -186,7 +187,7 @@ export default {
 				return cb({ status: "success", data: { news } });
 				return cb({ status: "success", data: { news } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets a news item by id
 	 * Gets a news item by id
@@ -194,7 +195,7 @@ export default {
 	 * @param {string} newsId - the news item id
 	 * @param {string} newsId - the news item id
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	async getNewsFromId(session, newsId, cb) {
+	getNewsFromId: isLoginSometimesRequired(async function getNewsFromId(session, newsId, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 
 
 		async.waterfall(
 		async.waterfall(
@@ -215,7 +216,8 @@ export default {
 				return cb({ status: "success", data: { news } });
 				return cb({ status: "success", data: { news } });
 			}
 			}
 		);
 		);
-	},
+	}),
+
 	/**
 	/**
 	 * Creates a news item
 	 * Creates a news item
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {object} session - the session object automatically added by the websocket
@@ -257,7 +259,7 @@ export default {
 	 * @param {boolean} newUser - whether the user requesting the newest news is a new user
 	 * @param {boolean} newUser - whether the user requesting the newest news is a new user
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	async newest(session, newUser, cb) {
+	newest: isLoginSometimesRequired(async function newest(session, newUser, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		const query = { status: "published" };
 		const query = { status: "published" };
 		if (newUser) query.showToNewUsers = true;
 		if (newUser) query.showToNewUsers = true;
@@ -274,7 +276,7 @@ export default {
 				return cb({ status: "success", data: { news } });
 				return cb({ status: "success", data: { news } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Removes a news item
 	 * Removes a news item

+ 7 - 6
backend/logic/actions/playlists.js

@@ -2,6 +2,7 @@ import async from "async";
 import config from "config";
 import config from "config";
 
 
 import isLoginRequired from "../hooks/loginRequired";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
@@ -629,7 +630,7 @@ export default {
 	 * @param {string} userId - the user id in question
 	 * @param {string} userId - the user id in question
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	indexForUser: async function indexForUser(session, userId, cb) {
+	indexForUser: isLoginSometimesRequired(async function indexForUser(session, userId, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 
@@ -698,7 +699,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets all playlists for the user requesting it
 	 * Gets all playlists for the user requesting it
@@ -896,7 +897,7 @@ export default {
 	 * @param {string} playlistId - the id of the playlist we are getting
 	 * @param {string} playlistId - the id of the playlist we are getting
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	getPlaylist: function getPlaylist(session, playlistId, cb) {
+	getPlaylist: isLoginSometimesRequired(function getPlaylist(session, playlistId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -937,7 +938,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets a playlist from station id
 	 * Gets a playlist from station id
@@ -946,7 +947,7 @@ export default {
 	 * @param {string} includeSongs - include songs
 	 * @param {string} includeSongs - include songs
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	getPlaylistForStation: function getPlaylist(session, stationId, includeSongs, cb) {
+	getPlaylistForStation: isLoginSometimesRequired(function getPlaylist(session, stationId, includeSongs, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -987,7 +988,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Shuffles songs in a private playlist
 	 * Shuffles songs in a private playlist

+ 93 - 90
backend/logic/actions/stations.js

@@ -4,6 +4,7 @@ import config from "config";
 
 
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 import isLoginRequired from "../hooks/loginRequired";
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 
 
 // eslint-disable-next-line
 // eslint-disable-next-line
 import moduleManager from "../../index";
 import moduleManager from "../../index";
@@ -359,7 +360,7 @@ export default {
 	 * @param {boolean} adminFilter - whether to filter out stations admins do not own
 	 * @param {boolean} adminFilter - whether to filter out stations admins do not own
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	async index(session, adminFilter, cb) {
+	index: isLoginSometimesRequired(async function index(session, adminFilter, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 
 
 		async.waterfall(
 		async.waterfall(
@@ -454,7 +455,7 @@ export default {
 				return cb({ status: "success", data: { stations, favorited } });
 				return cb({ status: "success", data: { stations, favorited } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets stations, used in the admin stations page by the AdvancedTable component
 	 * Gets stations, used in the admin stations page by the AdvancedTable component
@@ -564,7 +565,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	getStationForActivity(session, stationId, cb) {
+	getStationForActivity: isLoginSometimesRequired(function getStationForActivity(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -599,7 +600,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Verifies that a station exists from its name
 	 * Verifies that a station exists from its name
@@ -607,7 +608,7 @@ export default {
 	 * @param {string} stationName - the station name
 	 * @param {string} stationName - the station name
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	existsByName(session, stationName, cb) {
+	existsByName: isLoginSometimesRequired(function existsByName(session, stationName, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -643,7 +644,7 @@ export default {
 				return cb({ status: "success", data: { exists } });
 				return cb({ status: "success", data: { exists } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Verifies that a station exists from its id
 	 * Verifies that a station exists from its id
@@ -651,7 +652,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	existsById(session, stationId, cb) {
+	existsById: isLoginSometimesRequired(function existsById(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -687,7 +688,7 @@ export default {
 				return cb({ status: "success", data: { exists } });
 				return cb({ status: "success", data: { exists } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets the official playlist for a station
 	 * Gets the official playlist for a station
@@ -695,7 +696,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	getPlaylist(session, stationId, cb) {
+	getPlaylist: isLoginSometimesRequired(function getPlaylist(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -769,7 +770,7 @@ export default {
 				return cb({ status: "success", data: { songs: playlist.songs } });
 				return cb({ status: "success", data: { songs: playlist.songs } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Joins the station by its name
 	 * Joins the station by its name
@@ -777,7 +778,7 @@ export default {
 	 * @param {string} stationIdentifier - the station name or station id
 	 * @param {string} stationIdentifier - the station name or station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	async join(session, stationIdentifier, cb) {
+	join: isLoginSometimesRequired(async function join(session, stationIdentifier, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		async.waterfall(
 		async.waterfall(
 			[
 			[
@@ -897,7 +898,7 @@ export default {
 				return cb({ status: "success", data });
 				return cb({ status: "success", data });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets a station by id
 	 * Gets a station by id
@@ -905,7 +906,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	getStationById(session, stationId, cb) {
+	getStationById: isLoginSometimesRequired(function getStationById(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -986,7 +987,7 @@ export default {
 				return cb({ status: "success", data: { station: data } });
 				return cb({ status: "success", data: { station: data } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Gets station history
 	 * Gets station history
@@ -994,7 +995,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	getHistory(session, stationId, cb) {
+	getHistory: isLoginSometimesRequired(function getHistory(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -1054,80 +1055,82 @@ export default {
 				return cb({ status: "success", data: { history } });
 				return cb({ status: "success", data: { history } });
 			}
 			}
 		);
 		);
-	},
-
-	getStationAutofillPlaylistsById(session, stationId, cb) {
-		async.waterfall(
-			[
-				next => {
-					StationsModule.runJob("GET_STATION", { stationId }, this)
-						.then(station => {
-							next(null, station);
-						})
-						.catch(next);
-				},
-
-				(station, next) => {
-					if (!station) return next("Station not found.");
-					return StationsModule.runJob(
-						"CAN_USER_VIEW_STATION",
-						{
-							station,
-							userId: session.userId
-						},
-						this
-					)
-						.then(canView => {
-							if (!canView) next("Not allowed to get station.");
-							else next(null, station);
-						})
-						.catch(err => next(err));
-				},
+	}),
 
 
-				(station, next) => {
-					const playlists = [];
+	getStationAutofillPlaylistsById: isLoginSometimesRequired(
+		function getStationAutofillPlaylistsById(session, stationId, cb) {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
 
 
-					async.eachLimit(
-						station.autofill.playlists,
-						1,
-						(playlistId, next) => {
-							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-								.then(playlist => {
-									playlists.push(playlist);
-									next();
-								})
-								.catch(() => {
-									playlists.push(null);
-									next();
-								});
-						},
-						err => {
-							next(err, playlists);
-						}
-					);
-				}
-			],
-			async (err, playlists) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						return StationsModule.runJob(
+							"CAN_USER_VIEW_STATION",
+							{
+								station,
+								userId: session.userId
+							},
+							this
+						)
+							.then(canView => {
+								if (!canView) next("Not allowed to get station.");
+								else next(null, station);
+							})
+							.catch(err => next(err));
+					},
+
+					(station, next) => {
+						const playlists = [];
+
+						async.eachLimit(
+							station.autofill.playlists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										playlists.push(playlist);
+										next();
+									})
+									.catch(() => {
+										playlists.push(null);
+										next();
+									});
+							},
+							err => {
+								next(err, playlists);
+							}
+						);
+					}
+				],
+				async (err, playlists) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
+							`Getting station "${stationId}"'s autofilling playlists failed. "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
 					this.log(
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
 						"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
-						`Getting station "${stationId}"'s autofilling playlists failed. "${err}"`
+						`Got station "${stationId}"'s autofilling playlists successfully.`
 					);
 					);
-					return cb({ status: "error", message: err });
+					return cb({ status: "success", data: { playlists } });
 				}
 				}
-				this.log(
-					"SUCCESS",
-					"GET_STATION_AUTOFILL_PLAYLISTS_BY_ID",
-					`Got station "${stationId}"'s autofilling playlists successfully.`
-				);
-				return cb({ status: "success", data: { playlists } });
-			}
-		);
-	},
+			);
+		}
+	),
 
 
-	getStationBlacklistById(session, stationId, cb) {
+	getStationBlacklistById: isLoginSometimesRequired(function getStationBlacklistById(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -1196,7 +1199,7 @@ export default {
 				return cb({ status: "success", data: { playlists } });
 				return cb({ status: "success", data: { playlists } });
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Toggle votes to skip a station
 	 * Toggle votes to skip a station
@@ -1331,7 +1334,7 @@ export default {
 	 * @param {string} stationId - id of station to leave
 	 * @param {string} stationId - id of station to leave
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	leave(session, stationId, cb) {
+	leave: isLoginSometimesRequired(function leave(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -1366,7 +1369,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Updates a station's settings
 	 * Updates a station's settings
@@ -1810,7 +1813,7 @@ export default {
 				},
 				},
 
 
 				(playlist, stationId, next) => {
 				(playlist, stationId, next) => {
-					const { name, displayName, description, type } = data;
+					const { name, displayName, description, type, privacy } = data;
 					if (type === "official") {
 					if (type === "official") {
 						stationModel.create(
 						stationModel.create(
 							{
 							{
@@ -1820,7 +1823,7 @@ export default {
 								description,
 								description,
 								playlist: playlist._id,
 								playlist: playlist._id,
 								type,
 								type,
-								privacy: "private",
+								privacy,
 								queue: [],
 								queue: [],
 								currentSong: null
 								currentSong: null
 							},
 							},
@@ -1835,7 +1838,7 @@ export default {
 								description,
 								description,
 								playlist: playlist._id,
 								playlist: playlist._id,
 								type,
 								type,
-								privacy: "private",
+								privacy,
 								owner: session.userId,
 								owner: session.userId,
 								queue: [],
 								queue: [],
 								currentSong: null
 								currentSong: null
@@ -2034,7 +2037,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 * @param {Function} cb - callback
 	 */
 	 */
-	getQueue(session, stationId, cb) {
+	getQueue: isLoginSometimesRequired(function getQueue(session, stationId, cb) {
 		async.waterfall(
 		async.waterfall(
 			[
 			[
 				next => {
 				next => {
@@ -2079,7 +2082,7 @@ export default {
 				});
 				});
 			}
 			}
 		);
 		);
-	},
+	}),
 
 
 	/**
 	/**
 	 * Reposition a song in station queue
 	 * Reposition a song in station queue

Різницю між файлами не показано, бо вона завелика
+ 80 - 779
backend/logic/actions/users.js


+ 100 - 467
backend/logic/app.js

@@ -1,23 +1,14 @@
 import config from "config";
 import config from "config";
-import axios from "axios";
-import async from "async";
 import cors from "cors";
 import cors from "cors";
 import cookieParser from "cookie-parser";
 import cookieParser from "cookie-parser";
 import bodyParser from "body-parser";
 import bodyParser from "body-parser";
 import express from "express";
 import express from "express";
-import oauth from "oauth";
 import http from "http";
 import http from "http";
-import CoreClass from "../core";
 
 
-const { OAuth2 } = oauth;
+import CoreClass from "../core";
 
 
 let AppModule;
 let AppModule;
-let MailModule;
-let CacheModule;
-let DBModule;
-let ActivitiesModule;
-let PlaylistsModule;
-let UtilsModule;
+let UsersModule;
 
 
 class _AppModule extends CoreClass {
 class _AppModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	// eslint-disable-next-line require-jsdoc
@@ -31,489 +22,131 @@ class _AppModule extends CoreClass {
 	 * Initialises the app module
 	 * Initialises the app module
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	initialize() {
-		return new Promise(resolve => {
-			MailModule = this.moduleManager.modules.mail;
-			CacheModule = this.moduleManager.modules.cache;
-			DBModule = this.moduleManager.modules.db;
-			ActivitiesModule = this.moduleManager.modules.activities;
-			PlaylistsModule = this.moduleManager.modules.playlists;
-			UtilsModule = this.moduleManager.modules.utils;
-
-			const app = (this.app = express());
-			const SIDname = config.get("cookie");
-			this.server = http.createServer(app).listen(config.get("port"));
-
-			app.use(cookieParser());
-
-			app.use(bodyParser.json());
-			app.use(bodyParser.urlencoded({ extended: true }));
-
-			let userModel;
-			DBModule.runJob("GET_MODEL", { modelName: "user" })
-				.then(model => {
-					userModel = model;
-				})
-				.catch(console.error);
-
-			const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
-
-			const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
-			corsOptions.origin.push(appUrl);
-			corsOptions.credentials = true;
-
-			app.use(cors(corsOptions));
-			app.options("*", cors(corsOptions));
-
-			/**
-			 * @param {object} res - response object from Express
-			 * @param {string} err - custom error message
-			 */
-			function redirectOnErr(res, err) {
-				res.redirect(`${appUrl}?err=${encodeURIComponent(err)}`);
-			}
-
-			if (config.get("apis.github.enabled")) {
-				const oauth2 = new OAuth2(
-					config.get("apis.github.client"),
-					config.get("apis.github.secret"),
-					"https://github.com/",
-					"login/oauth/authorize",
-					"login/oauth/access_token",
-					null
-				);
-
-				const redirectUri =
-					config.get("apis.github.redirect_uri").length > 0
-						? config.get("apis.github.redirect_uri")
-						: `${appUrl}/backend/auth/github/authorize/callback`;
-
-				app.get("/auth/github/authorize", async (req, res) => {
-					if (this.getStatus() !== "READY") {
-						this.log(
-							"INFO",
-							"APP_REJECTED_GITHUB_AUTHORIZE",
-							`A user tried to use github authorize, but the APP module is currently not ready.`
-						);
-						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-					}
-
-					const params = [
-						`client_id=${config.get("apis.github.client")}`,
-						`redirect_uri=${redirectUri}`,
-						`scope=user:email`
-					].join("&");
-					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-				});
-
-				app.get("/auth/github/link", async (req, res) => {
-					if (this.getStatus() !== "READY") {
-						this.log(
-							"INFO",
-							"APP_REJECTED_GITHUB_AUTHORIZE",
-							`A user tried to use github authorize, but the APP module is currently not ready.`
-						);
-						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-					}
-
-					const params = [
-						`client_id=${config.get("apis.github.client")}`,
-						`redirect_uri=${redirectUri}`,
-						`scope=user:email`,
-						`state=${req.cookies[SIDname]}`
-					].join("&");
-					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-				});
-
-				app.get("/auth/github/authorize/callback", async (req, res) => {
-					if (this.getStatus() !== "READY") {
-						this.log(
-							"INFO",
-							"APP_REJECTED_GITHUB_AUTHORIZE",
-							`A user tried to use github authorize, but the APP module is currently not ready.`
-						);
-
-						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-					}
-
-					const { code } = req.query;
-					let accessToken;
-					let body;
-					let address;
-
-					const { state } = req.query;
-
-					const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
-
-					return async.waterfall(
-						[
-							next => {
-								if (req.query.error) return next(req.query.error_description);
-								return next();
-							},
-
-							next => {
-								oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
-							},
-
-							(_accessToken, refreshToken, results, next) => {
-								if (results.error) return next(results.error_description);
-
-								accessToken = _accessToken;
-
-								const options = {
-									headers: {
-										"User-Agent": "request",
-										Authorization: `token ${accessToken}`
-									}
-								};
-
-								return axios
-									.get("https://api.github.com/user", options)
-									.then(github => next(null, github))
-									.catch(err => next(err));
-							},
-
-							(github, next) => {
-								if (github.status !== 200) return next(github.data.message);
-
-								if (state) {
-									return async.waterfall(
-										[
-											next => {
-												CacheModule.runJob("HGET", {
-													table: "sessions",
-													key: state
-												})
-													.then(session => next(null, session))
-													.catch(next);
-											},
-
-											(session, next) => {
-												if (!session) return next("Invalid session.");
-												return userModel.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.");
-
-												return userModel.updateOne(
-													{ _id: user._id },
-													{
-														$set: {
-															"services.github": {
-																id: github.data.id,
-																access_token: accessToken
-															}
-														}
-													},
-													{ runValidators: true },
-													err => {
-														if (err) return next(err);
-														return next(null, user, github.data);
-													}
-												);
-											},
-
-											user => {
-												CacheModule.runJob("PUB", {
-													channel: "user.linkGithub",
-													value: user._id
-												});
-
-												CacheModule.runJob("PUB", {
-													channel: "user.updated",
-													value: { userId: user._id }
-												});
-
-												res.redirect(`${appUrl}/settings?tab=security`);
-											}
-										],
-										next
-									);
-								}
+	async initialize() {
+		UsersModule = this.moduleManager.modules.users;
 
 
-								if (!github.data.id) return next("Something went wrong, no id.");
+		const app = (this.app = express());
+		const SIDname = config.get("cookie");
+		this.server = http.createServer(app).listen(config.get("port"));
 
 
-								return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
-									next(err, user, github.data);
-								});
-							},
+		app.use(cookieParser());
 
 
-							(user, _body, next) => {
-								body = _body;
+		app.use(bodyParser.json());
+		app.use(bodyParser.urlencoded({ extended: true }));
 
 
-								if (user) {
-									user.services.github.access_token = accessToken;
-									return user.save(() => next(true, user._id));
-								}
+		const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
 
 
-								return userModel.findOne(
-									{
-										username: new RegExp(`^${body.login}$`, "i")
-									},
-									(err, user) => next(err, user)
-								);
-							},
+		const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
+		corsOptions.origin.push(appUrl);
+		corsOptions.credentials = true;
 
 
-							(user, next) => {
-								if (user) return next(`An account with that username already exists.`);
+		app.use(cors(corsOptions));
+		app.options("*", cors(corsOptions));
 
 
-								return axios
-									.get("https://api.github.com/user/emails", {
-										headers: {
-											"User-Agent": "request",
-											Authorization: `token ${accessToken}`
-										}
-									})
-									.then(res => next(null, res.data))
-									.catch(err => next(err));
-							},
+		/**
+		 * @param {object} res - response object from Express
+		 * @param {string} err - custom error message
+		 */
+		function redirectOnErr(res, err) {
+			res.redirect(`${appUrl}?err=${encodeURIComponent(err)}`);
+		}
 
 
-							(body, next) => {
-								if (!Array.isArray(body)) return next(body.message);
-
-								body.forEach(email => {
-									if (email.primary) address = email.email.toLowerCase();
-								});
-
-								return userModel.findOne({ "email.address": address }, next);
-							},
-
-							(user, next) => {
-								UtilsModule.runJob("GENERATE_RANDOM_STRING", {
-									length: 12
-								}).then(_id => next(null, user, _id));
-							},
-
-							(user, _id, next) => {
-								if (user) {
-									if (Object.keys(JSON.parse(user.services.github)).length === 0)
-										return next(
-											`An account with that email address exists, but is not linked to GitHub.`
-										);
-									return next(`An account with that email address already exists.`);
-								}
-
-								return next(null, {
-									_id,
-									username: body.login,
-									name: body.name,
-									location: body.location,
-									bio: body.bio,
-									email: {
-										address,
-										verificationToken
-									},
-									services: {
-										github: {
-											id: body.id,
-											access_token: accessToken
-										}
-									}
-								});
-							},
-
-							// generate the url for gravatar avatar
-							(user, next) => {
-								UtilsModule.runJob("CREATE_GRAVATAR", {
-									email: user.email.address
-								}).then(url => {
-									user.avatar = { type: "gravatar", url };
-									next(null, user);
-								});
-							},
-
-							// save the new user to the database
-							(user, next) => {
-								userModel.create(user, next);
-							},
-
-							(user, next) => {
-								MailModule.runJob("GET_SCHEMA", {
-									schemaName: "verifyEmail"
-								}).then(verifyEmailSchema => {
-									verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
-										next(err, user._id);
-									});
-								});
-							},
-
-							// create a liked songs playlist for the new user
-							(userId, next) => {
-								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-									userId,
-									displayName: "Liked Songs",
-									type: "user-liked"
-								})
-									.then(likedSongsPlaylist => {
-										next(null, likedSongsPlaylist, userId);
-									})
-									.catch(err => next(err));
-							},
-
-							// create a disliked songs playlist for the new user
-							(likedSongsPlaylist, userId, next) => {
-								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-									userId,
-									displayName: "Disliked Songs",
-									type: "user-disliked"
-								})
-									.then(dislikedSongsPlaylist => {
-										next(
-											null,
-											{
-												likedSongsPlaylist,
-												dislikedSongsPlaylist
-											},
-											userId
-										);
-									})
-									.catch(err => next(err));
-							},
-
-							// associate liked + disliked songs playlist to the user object
-							({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
-								userModel.updateOne(
-									{ _id: userId },
-									{
-										$set: {
-											likedSongsPlaylist,
-											dislikedSongsPlaylist
-										}
-									},
-									{ runValidators: true },
-									err => {
-										if (err) return next(err);
-										return next(null, userId);
-									}
-								);
-							},
-
-							// add the activity of account creation
-							(userId, next) => {
-								ActivitiesModule.runJob("ADD_ACTIVITY", {
-									userId,
-									type: "user__joined",
-									payload: { message: "Welcome to Musare!" }
-								});
-
-								next(null, userId);
-							}
-						],
-						async (err, userId) => {
-							if (err && err !== true) {
-								err = await UtilsModule.runJob("GET_ERROR", {
-									error: err
-								});
-
-								this.log(
-									"ERROR",
-									"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-									`Failed to authorize with GitHub. "${err}"`
-								);
-
-								return redirectOnErr(res, err);
-							}
-
-							const sessionId = await UtilsModule.runJob("GUID", {});
-							const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
-								schemaName: "session"
-							});
-
-							return CacheModule.runJob("HSET", {
-								table: "sessions",
-								key: sessionId,
-								value: sessionSchema(sessionId, userId)
-							})
-								.then(() => {
-									const date = new Date();
-									date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
-
-									res.cookie(SIDname, sessionId, {
-										expires: date,
-										secure: config.get("url.secure"),
-										path: "/",
-										domain: config.get("url.host")
-									});
-
-									this.log(
-										"INFO",
-										"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-										`User "${userId}" successfully authorized with GitHub.`
-									);
-
-									res.redirect(appUrl);
-								})
-								.catch(err => redirectOnErr(res, err.message));
-						}
-					);
-				});
-			}
-
-			app.get("/auth/verify_email", async (req, res) => {
+		if (config.get("apis.oidc.enabled")) {
+			app.get("/auth/oidc/authorize", async (req, res) => {
 				if (this.getStatus() !== "READY") {
 				if (this.getStatus() !== "READY") {
 					this.log(
 					this.log(
 						"INFO",
 						"INFO",
-						"APP_REJECTED_VERIFY_EMAIL",
-						`A user tried to use verify email, but the APP module is currently not ready.`
+						"APP_REJECTED_OIDC_AUTHORIZE",
+						`A user tried to use OIDC authorize, but the APP module is currently not ready.`
 					);
 					);
 					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
 					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
 				}
 				}
 
 
-				const { code } = req.query;
+				const params = [
+					`client_id=${config.get("apis.oidc.client_id")}`,
+					`redirect_uri=${UsersModule.oidcRedirectUri}`,
+					`scope=basic openid`,
+					`response_type=code`
+				].join("&");
+				return res.redirect(`${UsersModule.oidcAuthorizationEndpoint}?${params}`);
+			});
+
+			app.get("/auth/oidc/authorize/callback", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_OIDC_AUTHORIZE",
+						`A user tried to use OIDC authorize, but the APP module is currently not ready.`
+					);
+
+					redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					return;
+				}
 
 
-				return async.waterfall(
-					[
-						next => {
-							if (!code) return next("Invalid code.");
-							return next();
-						},
+				const { code, state, error, error_description: errorDescription } = req.query;
 
 
-						next => {
-							userModel.findOne({ "email.verificationToken": code }, next);
-						},
+				// OIDC_AUTHORIZE_CALLBACK job handles login/register
+				UsersModule.runJob("OIDC_AUTHORIZE_CALLBACK", { code, state, error, errorDescription })
+					.then(({ redirectUrl, sessionId, userId }) => {
+						if (sessionId) {
+							const date = new Date();
+							date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
 
 
-						(user, next) => {
-							if (!user) return next("User not found.");
-							if (user.email.verified) return next("This email is already verified.");
+							res.cookie(SIDname, sessionId, {
+								expires: date,
+								secure: config.get("url.secure"),
+								path: "/",
+								domain: config.get("url.host")
+							});
 
 
-							return userModel.updateOne(
-								{ "email.verificationToken": code },
-								{
-									$set: { "email.verified": true },
-									$unset: { "email.verificationToken": "" }
-								},
-								{ runValidators: true },
-								next
+							this.log(
+								"INFO",
+								"AUTH_OIDC_AUTHORIZE_CALLBACK",
+								`User "${userId}" successfully authorized with OIDC.`
 							);
 							);
 						}
 						}
-					],
-					err => {
-						if (err) {
-							let error = "An error occurred.";
 
 
-							if (typeof err === "string") error = err;
-							else if (err.message) error = err.message;
+						res.redirect(redirectUrl);
+					})
+					.catch(err => {
+						this.log(
+							"ERROR",
+							"AUTH_OIDC_AUTHORIZE_CALLBACK",
+							`Failed to authorize with OIDC. "${err.message}"`
+						);
 
 
-							this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${error}"`);
+						return redirectOnErr(res, err.message);
+					});
+			});
+		}
+
+		app.get("/auth/verify_email", (req, res) => {
+			if (this.getStatus() !== "READY") {
+				this.log(
+					"INFO",
+					"APP_REJECTED_VERIFY_EMAIL",
+					`A user tried to use verify email, but the APP module is currently not ready.`
+				);
+				redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				return;
+			}
 
 
-							return res.json({
-								status: "error",
-								message: error
-							});
-						}
+			const { code } = req.query;
 
 
-						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
+			UsersModule.runJob("VERIFY_EMAIL", { code })
+				.then(() => {
+					this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
 
 
-						return res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
-					}
-				);
-			});
+					res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
+				})
+				.catch(err => {
+					this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${err.message}"`);
 
 
-			resolve();
+					res.json({
+						status: "error",
+						message: err.message
+					});
+				});
 		});
 		});
 	}
 	}
 
 

+ 2 - 2
backend/logic/db/index.js

@@ -14,7 +14,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	report: 7,
 	report: 7,
 	song: 10,
 	song: 10,
 	station: 10,
 	station: 10,
-	user: 4,
+	user: 5,
 	youtubeApiRequest: 1,
 	youtubeApiRequest: 1,
 	youtubeVideo: [1, 2],
 	youtubeVideo: [1, 2],
 	youtubeChannel: 1,
 	youtubeChannel: 1,
@@ -446,7 +446,7 @@ class _DBModule extends CoreClass {
 
 
 					// If a filter or property exists for a special property, add some custom pipeline steps
 					// If a filter or property exists for a special property, add some custom pipeline steps
 					(pipeline, next) => {
 					(pipeline, next) => {
-						const { properties, queries, specialProperties } = payload;
+						const { properties, queries, specialProperties = {} } = payload;
 
 
 						async.eachLimit(
 						async.eachLimit(
 							Object.entries(specialProperties),
 							Object.entries(specialProperties),

+ 11 - 8
backend/logic/db/schemas/user.js

@@ -19,14 +19,10 @@ export default {
 			reset: {
 			reset: {
 				code: { type: String, min: 8, max: 8 },
 				code: { type: String, min: 8, max: 8 },
 				expires: { type: Date }
 				expires: { type: Date }
-			},
-			set: {
-				code: { type: String, min: 8, max: 8 },
-				expires: { type: Date }
 			}
 			}
 		},
 		},
-		github: {
-			id: Number,
+		oidc: {
+			sub: String,
 			access_token: String
 			access_token: String
 		}
 		}
 	},
 	},
@@ -46,7 +42,14 @@ export default {
 		autoSkipDisliked: { type: Boolean, default: true, required: true },
 		autoSkipDisliked: { type: Boolean, default: true, required: true },
 		activityLogPublic: { type: Boolean, default: false, required: true },
 		activityLogPublic: { type: Boolean, default: false, required: true },
 		anonymousSongRequests: { type: Boolean, default: false, required: true },
 		anonymousSongRequests: { type: Boolean, default: false, required: true },
-		activityWatch: { type: Boolean, default: false, required: true }
+		activityWatch: { type: Boolean, default: false, required: true },
+		defaultStationPrivacy: {
+			type: String,
+			enum: ["public", "unlisted", "private"],
+			default: "private",
+			required: true
+		},
+		defaultPlaylistPrivacy: { type: String, enum: ["public", "private"], default: "public", required: true }
 	},
 	},
-	documentVersion: { type: Number, default: 4, required: true }
+	documentVersion: { type: Number, default: 5, required: true }
 };
 };

+ 1 - 1
backend/logic/hooks/hasPermission.js

@@ -63,7 +63,7 @@ permissions.moderator = {
 	"stations.remove": false,
 	"stations.remove": false,
 	"users.get": true,
 	"users.get": true,
 	"users.ban": true,
 	"users.ban": true,
-	"users.requestPasswordReset": config.get("mail.enabled"),
+	"users.requestPasswordReset": config.get("mail.enabled") && !config.get("apis.oidc.enabled"),
 	"users.resendVerifyEmail": config.get("mail.enabled"),
 	"users.resendVerifyEmail": config.get("mail.enabled"),
 	"users.update": true,
 	"users.update": true,
 	"youtube.requestSetAdmin": true,
 	"youtube.requestSetAdmin": true,

+ 50 - 0
backend/logic/hooks/loginSometimesRequired.js

@@ -0,0 +1,50 @@
+import async from "async";
+import config from "config";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+
+// This is for actions that are only restricted to logged-in users if restrictToUsers config option is true
+export default destination =>
+	function loginSometimesRequired(session, ...args) {
+		const cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					if (!config.get("restrictToUsers")) next(true);
+					else if (!session || !session.sessionId) next("Login required.");
+					else next();
+				},
+
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+				(session, next) => {
+					if (!session || !session.userId) return next("Login required.");
+					return next();
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("LOGIN_SOMETIMES_REQUIRED", `User failed to pass login required check.`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("LOGIN_SOMETIMES_REQUIRED", `User "${session.userId}" passed login required check.`);
+				return destination.apply(this, [session].concat(args));
+			}
+		);
+	};

+ 19 - 0
backend/logic/mail/index.js

@@ -83,6 +83,25 @@ class _MailModule extends CoreClass {
 			resolve(MailModule.schemas[payload.schemaName]);
 			resolve(MailModule.schemas[payload.schemaName]);
 		});
 		});
 	}
 	}
+
+	/**
+	 * Returns an email schema, but using async instead of callbacks
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.schemaName - name of the schema to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_SCHEMA_ASYNC(payload) {
+		const { schemaName } = payload;
+
+		return (...args) =>
+			new Promise((resolve, reject) => {
+				const cb = err => {
+					if (err) reject(err);
+					else resolve();
+				};
+				MailModule.schemas[schemaName](...args, cb);
+			});
+	}
 }
 }
 
 
 export default new _MailModule();
 export default new _MailModule();

+ 35 - 0
backend/logic/migration/migrations/migration26.js

@@ -0,0 +1,35 @@
+/**
+ * Migration 26
+ *
+ * Migration for setting new user preferences to default
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		this.log("INFO", `Migration 26. Updating users with document version 4.`);
+		userModel.updateMany(
+			{ documentVersion: 4 },
+			{
+				$set: {
+					documentVersion: 5,
+					"preferences.defaultStationPrivacy": "private",
+					"preferences.defaultPlaylistPrivacy": "public"
+				}
+			},
+			(err, res) => {
+				if (err) reject(new Error(err));
+				else {
+					this.log(
+						"INFO",
+						`Migration 26. Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+					);
+
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 3 - 3
backend/logic/spotify.js

@@ -773,7 +773,7 @@ class _SpotifyModule extends CoreClass {
 						result
 						result
 					}
 					}
 				});
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 				this.publishProgress({
 					status: "working",
 					status: "working",
 					message: `Failed to get alternative artist source for ${artistId}`,
 					message: `Failed to get alternative artist source for ${artistId}`,
@@ -862,7 +862,7 @@ class _SpotifyModule extends CoreClass {
 						result
 						result
 					}
 					}
 				});
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 				this.publishProgress({
 					status: "working",
 					status: "working",
 					message: `Failed to get alternative album source for ${albumId}`,
 					message: `Failed to get alternative album source for ${albumId}`,
@@ -935,7 +935,7 @@ class _SpotifyModule extends CoreClass {
 						result
 						result
 					}
 					}
 				});
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 				this.publishProgress({
 					status: "working",
 					status: "working",
 					message: `Failed to get alternative media for ${mediaSource}`,
 					message: `Failed to get alternative media for ${mediaSource}`,

+ 571 - 0
backend/logic/users.js

@@ -0,0 +1,571 @@
+import config from "config";
+import axios from "axios";
+import bcrypt from "bcrypt";
+import sha256 from "sha256";
+import CoreClass from "../core";
+
+let UsersModule;
+let MailModule;
+let CacheModule;
+let DBModule;
+let PlaylistsModule;
+let WSModule;
+let MediaModule;
+let UtilsModule;
+let ActivitiesModule;
+
+const avatarColors = ["blue", "orange", "green", "purple", "teal"];
+
+class _UsersModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("users");
+
+		UsersModule = this;
+	}
+
+	/**
+	 * Initialises the app module
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		DBModule = this.moduleManager.modules.db;
+		MailModule = this.moduleManager.modules.mail;
+		WSModule = this.moduleManager.modules.ws;
+		CacheModule = this.moduleManager.modules.cache;
+		MediaModule = this.moduleManager.modules.media;
+		UtilsModule = this.moduleManager.modules.utils;
+		ActivitiesModule = this.moduleManager.modules.activities;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+
+		this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+		this.dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" });
+		this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" });
+		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
+		this.activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" });
+
+		this.dataRequestEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
+		this.verifyEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "verifyEmail" });
+
+		this.sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
+			schemaName: "session"
+		});
+
+		this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
+
+		// getOAuthAccessToken uses callbacks by default, so make a helper function to turn it into a promise instead
+		this.getOAuthAccessToken = (...args) =>
+			new Promise((resolve, reject) => {
+				this.oauth2.getOAuthAccessToken(...args, (err, accessToken, refreshToken, results) => {
+					if (err) reject(err);
+					else resolve({ accessToken, refreshToken, results });
+				});
+			});
+
+		if (config.get("apis.oidc.enabled")) {
+			const openidConfigurationResponse = await axios.get(config.get("apis.oidc.openid_configuration_url"));
+
+			const {
+				authorization_endpoint: authorizationEndpoint,
+				token_endpoint: tokenEndpoint,
+				userinfo_endpoint: userinfoEndpoint
+			} = openidConfigurationResponse.data;
+
+			// TODO somehow make this endpoint immutable, if possible in some way
+			this.oidcAuthorizationEndpoint = authorizationEndpoint;
+			this.oidcTokenEndpoint = userinfoEndpoint;
+			this.oidcUserinfoEndpoint = userinfoEndpoint;
+			this.oidcRedirectUri =
+				config.get("apis.oidc.redirect_uri").length > 0
+					? config.get("apis.oidc.redirect_uri")
+					: `${this.appUrl}/backend/auth/oidc/authorize/callback`;
+
+			//
+			const clientId = config.get("apis.oidc.client_id");
+			const clientSecret = config.get("apis.oidc.client_secret");
+
+			this.getOIDCOAuthAccessToken = async code => {
+				const tokenResponse = await axios.post(
+					tokenEndpoint,
+					{
+						grant_type: "authorization_code",
+						code,
+						client_id: clientId,
+						client_secret: clientSecret,
+						redirect_uri: this.oidcRedirectUri
+					},
+					{
+						headers: {
+							"Content-Type": "application/x-www-form-urlencoded"
+						}
+					}
+				);
+
+				const { access_token: accessToken } = tokenResponse.data;
+
+				return { accessToken };
+			};
+		}
+	}
+
+	/**
+	 * Removes a user and associated data
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - id of the user to remove
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async REMOVE_USER(payload) {
+		const { userId } = payload;
+
+		// Create data request, in case the process fails halfway through. An admin can finish the removal manually
+		const dataRequest = await UsersModule.dataRequestModel.create({ userId, type: "remove" });
+		await WSModule.runJob(
+			"EMIT_TO_ROOM",
+			{
+				room: "admin.users",
+				args: ["event:admin.dataRequests.created", { data: { request: dataRequest } }]
+			},
+			this
+		);
+
+		if (config.get("sendDataRequestEmails")) {
+			const adminUsers = await UsersModule.userModel.find({ role: "admin" });
+			const to = adminUsers.map(adminUser => adminUser.email.address);
+			await UsersModule.dataRequestEmailSchema(to, userId, "remove");
+		}
+
+		// Delete activities
+		await UsersModule.activityModel.deleteMany({ userId });
+
+		// Delete stations and associated data
+		const stations = await UsersModule.stationModel.find({ owner: userId });
+		const stationJobs = stations.map(station => async () => {
+			const { _id: stationId } = station;
+
+			await UsersModule.stationModel.deleteOne({ _id: stationId });
+			await CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this);
+
+			if (!station.playlist) return;
+
+			await PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist }, this);
+		});
+		await Promise.all(stationJobs);
+
+		// Remove user as dj
+		await UsersModule.stationModel.updateMany({ djs: userId }, { $pull: { djs: userId } });
+
+		// Collect songs to adjust ratings for later
+		const likedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-liked" });
+		const dislikedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-disliked" });
+		const songsToAdjustRatings = [
+			...(likedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? []),
+			...(dislikedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? [])
+		];
+
+		// Delete playlists created by user
+		await UsersModule.playlistModel.deleteMany({ createdBy: userId });
+
+		// TODO Maybe we don't need to wait for this to finish?
+		// Recalculate ratings of songs the user liked/disliked
+		const recalculateRatingsJobs = songsToAdjustRatings.map(songsToAdjustRating =>
+			MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: songsToAdjustRating }, this)
+		);
+		await Promise.all(recalculateRatingsJobs);
+
+		// Delete user object
+		await UsersModule.userModel.deleteMany({ _id: userId });
+
+		// Remove sessions from Redis and MongoDB
+		await CacheModule.runJob("PUB", { channel: "user.removeSessions", value: userId }, this);
+
+		const sessions = await CacheModule.runJob("HGETALL", { table: "sessions" }, this);
+		const sessionIds = Object.keys(sessions);
+		const sessionJobs = sessionIds.map(sessionId => async () => {
+			const session = sessions[sessionId];
+			if (!session || session.userId !== userId) return;
+
+			await CacheModule.runJob("HDEL", { table: "sessions", key: sessionId }, this);
+		});
+		await Promise.all(sessionJobs);
+
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "user.removeAccount",
+				value: userId
+			},
+			this
+		);
+	}
+
+	/**
+	 * Tries to verify email from email verification token/code
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.code - email verification token/code
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async VERIFY_EMAIL(payload) {
+		const { code } = payload;
+		if (!code) throw new Error("Invalid code.");
+
+		const user = await UsersModule.userModel.findOne({ "email.verificationToken": code });
+		if (!user) throw new Error("User not found.");
+		if (user.email.verified) throw new Error("This email is already verified.");
+
+		await UsersModule.userModel.updateOne(
+			{ "email.verificationToken": code },
+			{
+				$set: { "email.verified": true },
+				$unset: { "email.verificationToken": "" }
+			},
+			{ runValidators: true }
+		);
+	}
+
+	/**
+	 * Handles callback route being accessed, which has data from OIDC during the oauth process
+	 * Will be used to either log the user in or register the user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.code - code we need to use to get the access token
+	 * @param {string} payload.error - error code if an error occured
+	 * @param {string} payload.errorDescription - error description if an error occured
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async OIDC_AUTHORIZE_CALLBACK(payload) {
+		const { code, error, errorDescription } = payload;
+		if (error) throw new Error(errorDescription);
+
+		// Tries to get access token. We don't use the refresh token currently
+		const { accessToken } = await UsersModule.getOIDCOAuthAccessToken(code);
+
+		// Gets user data
+		const userInfoResponse = await axios.post(
+			UsersModule.oidcUserinfoEndpoint,
+			{},
+			{
+				headers: {
+					Authorization: `Bearer ${accessToken}`
+				}
+			}
+		);
+		if (!userInfoResponse.data.preferred_username) throw new Error("Something went wrong, no preferred_username.");
+		// TODO verify sub from userinfo and token response, see 5.3.2 https://openid.net/specs/openid-connect-core-1_0.html
+
+		const user = await UsersModule.userModel.findOne({ "services.oidc.sub": userInfoResponse.data.sub });
+		let userId;
+		if (user) {
+			// Refresh access token, though it's pretty useless as it'll probably expire and then be useless,
+			// and we don't use it afterwards at all anyways
+			user.services.oidc.access_token = accessToken;
+			await user.save();
+			userId = user._id;
+		} else {
+			// Try to register the user. Will throw an error if it's unable to do so or any error occurs
+			({ userId } = await UsersModule.runJob(
+				"OIDC_AUTHORIZE_CALLBACK_REGISTER",
+				{ userInfoResponse: userInfoResponse.data, accessToken },
+				this
+			));
+		}
+
+		// Create session for the userId gotten above, as the user existed or was successfully registered
+		const sessionId = await UtilsModule.runJob("GUID", {}, this);
+		await CacheModule.runJob(
+			"HSET",
+			{
+				table: "sessions",
+				key: sessionId,
+				value: UsersModule.sessionSchema(sessionId, userId.toString())
+			},
+			this
+		);
+
+		return { sessionId, userId, redirectUrl: UsersModule.appUrl };
+	}
+
+	/**
+	 * Handles registering the user in the OIDC login/register callback/process
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userInfoResponse - data we got from the OIDC user info API endpoint
+	 * @param {string} payload.accessToken - access token for the OIDC user
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async OIDC_AUTHORIZE_CALLBACK_REGISTER(payload) {
+		const { userInfoResponse, accessToken } = payload;
+		let user;
+
+		// Check if username already exists
+		user = await UsersModule.userModel.findOne({
+			username: new RegExp(`^${userInfoResponse.preferred_username}$`, "i")
+		});
+		if (user) throw new Error(`An account with that username already exists.`); // TODO eventually we'll want users to be able to pick their own username maybe
+
+		const emailAddress = userInfoResponse.email;
+		if (!emailAddress) throw new Error("No email address found.");
+
+		user = await UsersModule.userModel.findOne({ "email.address": emailAddress });
+		if (user) throw new Error(`An account with that email address already exists.`);
+
+		const userId = await UtilsModule.runJob(
+			"GENERATE_RANDOM_STRING",
+			{
+				length: 12
+			},
+			this
+		);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+		const gravatarUrl = await UtilsModule.runJob(
+			"CREATE_GRAVATAR",
+			{
+				email: emailAddress
+			},
+			this
+		);
+		const likedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Liked Songs",
+				type: "user-liked"
+			},
+			this
+		);
+		const dislikedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Disliked Songs",
+				type: "user-disliked"
+			},
+			this
+		);
+
+		user = {
+			_id: userId,
+			username: userInfoResponse.preferred_username,
+			name: userInfoResponse.name,
+			location: "",
+			bio: "",
+			email: {
+				address: emailAddress,
+				verificationToken
+			},
+			services: {
+				oidc: {
+					sub: userInfoResponse.sub,
+					access_token: accessToken
+				}
+			},
+			avatar: {
+				type: "gravatar",
+				url: gravatarUrl
+			},
+			likedSongsPlaylist,
+			dislikedSongsPlaylist
+		};
+
+		await UsersModule.userModel.create(user);
+
+		await UsersModule.verifyEmailSchema(emailAddress, userInfoResponse.preferred_username, verificationToken);
+		await ActivitiesModule.runJob(
+			"ADD_ACTIVITY",
+			{
+				userId,
+				type: "user__joined",
+				payload: { message: "Welcome to Musare!" }
+			},
+			this
+		);
+
+		return {
+			userId
+		};
+	}
+
+	/**
+	 * Attempts to register a user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.email - email
+	 * @param {string} payload.username - username
+	 * @param {string} payload.password - plaintext password
+	 * @param {string} payload.recaptcha - recaptcha, if recaptcha is enabled
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async REGISTER(payload) {
+		const { username, password, recaptcha } = payload;
+		let { email } = payload;
+		email = email.toLowerCase().trim();
+
+		if (config.get("registrationDisabled") === true || config.get("apis.oidc.enabled") === true)
+			throw new Error("Registration is not allowed at this time.");
+		if (Array.isArray(config.get("experimental.registration_email_whitelist"))) {
+			const experimentalRegistrationEmailWhitelist = config.get("experimental.registration_email_whitelist");
+
+			const anyRegexPassed = experimentalRegistrationEmailWhitelist.find(regex => {
+				const emailWhitelistRegex = new RegExp(regex);
+				return emailWhitelistRegex.test(email);
+			});
+
+			if (!anyRegexPassed) throw new Error("Your email is not allowed to register.");
+		}
+
+		if (!DBModule.passwordValid(password))
+			throw new Error("Invalid password. Check if it meets all the requirements.");
+
+		if (config.get("apis.recaptcha.enabled") === true) {
+			const recaptchaBody = await axios.post("https://www.google.com/recaptcha/api/siteverify", {
+				data: {
+					secret: config.get("apis").recaptcha.secret,
+					response: recaptcha
+				}
+			});
+			if (recaptchaBody.success !== true) throw new Error("Response from recaptcha was not successful.");
+		}
+
+		let user = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
+		if (user) throw new Error("A user with that username already exists.");
+		user = await UsersModule.userModel.findOne({ "email.address": email });
+		if (user) throw new Error("A user with that email already exists.");
+
+		const salt = await bcrypt.genSalt(10);
+		const hash = await bcrypt.hash(sha256(password), salt);
+
+		const userId = await UtilsModule.runJob(
+			"GENERATE_RANDOM_STRING",
+			{
+				length: 12
+			},
+			this
+		);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+		const gravatarUrl = await UtilsModule.runJob(
+			"CREATE_GRAVATAR",
+			{
+				email
+			},
+			this
+		);
+		const likedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Liked Songs",
+				type: "user-liked"
+			},
+			this
+		);
+		const dislikedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Disliked Songs",
+				type: "user-disliked"
+			},
+			this
+		);
+
+		user = {
+			_id: userId,
+			name: username,
+			username,
+			email: {
+				address: email,
+				verificationToken
+			},
+			services: {
+				password: {
+					password: hash
+				}
+			},
+			avatar: {
+				type: "initials",
+				color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
+				url: gravatarUrl
+			},
+			likedSongsPlaylist,
+			dislikedSongsPlaylist
+		};
+
+		await UsersModule.userModel.create(user);
+
+		await UsersModule.verifyEmailSchema(email, username, verificationToken);
+		await ActivitiesModule.runJob(
+			"ADD_ACTIVITY",
+			{
+				userId,
+				type: "user__joined",
+				payload: { message: "Welcome to Musare!" }
+			},
+			this
+		);
+
+		return {
+			userId
+		};
+	}
+
+	/**
+	 * Attempts to update the email address of a user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - userId
+	 * @param {string} payload.email - new email
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_EMAIL(payload) {
+		const { userId } = payload;
+		let { email } = payload;
+		email = email.toLowerCase().trim();
+
+		const user = await UsersModule.userModel.findOne({ _id: userId });
+		if (!user) throw new Error("User not found.");
+		if (user.email.address === email) throw new Error("New email can't be the same as your the old email.");
+
+		const existingUser = UsersModule.userModel.findOne({ "email.address": email });
+		if (existingUser) throw new Error("That email is already in use.");
+
+		const gravatarUrl = await UtilsModule.runJob("CREATE_GRAVATAR", { email }, this);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+
+		await UsersModule.userModel.updateOne(
+			{ _id: userId },
+			{
+				$set: {
+					"avatar.url": gravatarUrl,
+					"email.address": email,
+					"email.verified": false,
+					"email.verificationToken": verificationToken
+				}
+			},
+			{ runValidators: true }
+		);
+
+		await UsersModule.verifyEmailSchema(email, user.username, verificationToken);
+	}
+
+	/**
+	 * Attempts to update the username of a user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - userId
+	 * @param {string} payload.username - new username
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_USERNAME(payload) {
+		const { userId, username } = payload;
+
+		const user = await UsersModule.userModel.findOne({ _id: userId });
+		if (!user) throw new Error("User not found.");
+		if (user.username === username) throw new Error("New username can't be the same as the old username.");
+
+		const existingUser = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
+		if (existingUser) throw new Error("That username is already in use.");
+
+		await UsersModule.userModel.updateOne({ _id: userId }, { $set: { username } }, { runValidators: true });
+	}
+
+	// async EXAMPLE_JOB() {
+	// 	if (true) return;
+	// 	else throw new Error("Nothing changed.");
+	// }
+}
+
+export default new _UsersModule();

+ 4 - 2
backend/logic/ws.js

@@ -575,15 +575,17 @@ class _WSModule extends CoreClass {
 						enabled: config.get("apis.recaptcha.enabled"),
 						enabled: config.get("apis.recaptcha.enabled"),
 						key: config.get("apis.recaptcha.key")
 						key: config.get("apis.recaptcha.key")
 					},
 					},
-					githubAuthentication: config.get("apis.github.enabled"),
+					oidcAuthentication: config.get("apis.oidc.enabled"),
 					messages: config.get("messages"),
 					messages: config.get("messages"),
 					christmas: config.get("christmas"),
 					christmas: config.get("christmas"),
 					footerLinks: config.get("footerLinks"),
 					footerLinks: config.get("footerLinks"),
 					primaryColor: config.get("primaryColor"),
 					primaryColor: config.get("primaryColor"),
 					shortcutOverrides: config.get("shortcutOverrides"),
 					shortcutOverrides: config.get("shortcutOverrides"),
-					registrationDisabled: config.get("registrationDisabled"),
+					registrationDisabled:
+						config.get("registrationDisabled") === true || config.get("apis.oidc.enabled") === true,
 					mailEnabled: config.get("mail.enabled"),
 					mailEnabled: config.get("mail.enabled"),
 					discogsEnabled: config.get("apis.discogs.enabled"),
 					discogsEnabled: config.get("apis.discogs.enabled"),
+					passwordResetEnabled: config.get("mail.enabled") && !config.get("apis.oidc.enabled"),
 					experimental: {
 					experimental: {
 						changable_listen_mode: config.get("experimental.changable_listen_mode"),
 						changable_listen_mode: config.get("experimental.changable_listen_mode"),
 						media_session: config.get("experimental.media_session"),
 						media_session: config.get("experimental.media_session"),

+ 7 - 0
backend/nodemon.json

@@ -0,0 +1,7 @@
+{
+    "ext": "js,json",
+	"stdin": true,
+	"stdout": true,
+	"signal": "SIGUSR2",
+	"exec": "node --import=extensionless/register -i ${INSPECT_BRK} /opt/app"
+}

Різницю між файлами не показано, бо вона завелика
+ 287 - 355
backend/package-lock.json


+ 27 - 28
backend/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "musare-backend",
   "name": "musare-backend",
   "private": true,
   "private": true,
-  "version": "3.11.0",
+  "version": "3.12.0",
   "type": "module",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
   "main": "index.js",
@@ -9,50 +9,49 @@
   "license": "GPL-3.0",
   "license": "GPL-3.0",
   "repository": "https://github.com/Musare/Musare",
   "repository": "https://github.com/Musare/Musare",
   "scripts": {
   "scripts": {
-    "dev": "nodemon --es-module-specifier-resolution=node",
-    "docker:dev": "nodemon --es-module-specifier-resolution=node --legacy-watch --no-stdin /opt/app",
-    "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
+    "dev": "nodemon",
+    "prod": "node --import=extensionless/register /opt/app",
     "lint": "eslint . --ext .js",
     "lint": "eslint . --ext .js",
     "typescript": "tsc --noEmit --skipLibCheck"
     "typescript": "tsc --noEmit --skipLibCheck"
   },
   },
   "dependencies": {
   "dependencies": {
-    "async": "^3.2.5",
-    "axios": "^1.6.7",
+    "async": "^3.2.6",
+    "axios": "^1.7.9",
     "bcrypt": "^5.1.1",
     "bcrypt": "^5.1.1",
     "bluebird": "^3.7.2",
     "bluebird": "^3.7.2",
-    "body-parser": "^1.20.2",
-    "config": "^3.3.11",
-    "cookie-parser": "^1.4.6",
+    "body-parser": "^1.20.3",
+    "config": "^3.3.12",
+    "cookie-parser": "^1.4.7",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
-    "express": "^4.18.2",
+    "express": "^4.21.2",
+    "extensionless": "^1.9.9",
     "moment": "^2.30.1",
     "moment": "^2.30.1",
-    "mongoose": "^6.12.6",
-    "nodemailer": "^6.9.10",
-    "oauth": "^0.10.0",
-    "redis": "^4.6.13",
+    "mongoose": "^6.13.6",
+    "nodemailer": "^6.9.16",
+    "redis": "^4.7.0",
     "retry-axios": "^3.1.3",
     "retry-axios": "^3.1.3",
     "sha256": "^0.2.0",
     "sha256": "^0.2.0",
-    "socks": "^2.8.1",
+    "socks": "^2.8.3",
     "soundcloud-key-fetch": "^1.0.13",
     "soundcloud-key-fetch": "^1.0.13",
-    "underscore": "^1.13.6",
-    "ws": "^8.16.0"
+    "underscore": "^1.13.7",
+    "ws": "^8.18.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^7.0.2",
-    "@typescript-eslint/parser": "^7.0.2",
-    "eslint": "^8.56.0",
+    "@typescript-eslint/eslint-plugin": "^8.20.0",
+    "@typescript-eslint/parser": "^8.20.0",
+    "eslint": "^8.57.1",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-airbnb-base": "^15.0.0",
-    "eslint-config-prettier": "^9.1.0",
-    "eslint-plugin-import": "^2.29.1",
-    "eslint-plugin-jsdoc": "^48.2.0",
-    "eslint-plugin-prettier": "^5.1.3",
-    "nodemon": "^3.1.0",
-    "prettier": "3.2.5",
+    "eslint-config-prettier": "^10.0.1",
+    "eslint-plugin-import": "^2.31.0",
+    "eslint-plugin-jsdoc": "^50.6.2",
+    "eslint-plugin-prettier": "^5.2.3",
+    "nodemon": "^3.1.9",
+    "prettier": "3.4.2",
     "trace-unhandled": "^2.0.1",
     "trace-unhandled": "^2.0.1",
     "ts-node": "^10.9.2",
     "ts-node": "^10.9.2",
-    "typescript": "^5.3.3"
+    "typescript": "^5.7.3"
   },
   },
   "overrides": {
   "overrides": {
     "@aws-sdk/credential-providers": "npm:dry-uninstall"
     "@aws-sdk/credential-providers": "npm:dry-uninstall"
   }
   }
-}
+}

+ 25 - 0
compose.dev.yml

@@ -0,0 +1,25 @@
+services:
+  backend:
+    build:
+      dockerfile: ./Dockerfile.dev
+    environment:
+      - APP_ENV=${APP_ENV:-development}
+      - BACKEND_DEBUG=${BACKEND_DEBUG:-false}
+      - BACKEND_DEBUG_PORT=${BACKEND_DEBUG_PORT:-9229}
+
+  frontend:
+    build:
+      dockerfile: ./Dockerfile.dev
+    environment:
+      - APP_ENV=${APP_ENV:-development}
+      - FRONTEND_CLIENT_PORT=${FRONTEND_CLIENT_PORT:-80}
+      - FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-81}
+      - FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS:-false}
+      - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
+      - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
+      - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
+      - MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE:-false}
+      - MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL:-false}
+      - MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH:-true}
+      - MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}
+      - MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}

+ 12 - 0
compose.local.yml

@@ -0,0 +1,12 @@
+services:
+  backend:
+    volumes:
+      - ./.git:/opt/.git:ro
+      - ./types:/opt/types
+      - ./backend:/opt/app
+
+  frontend:
+    volumes:
+      - ./.git:/opt/.git:ro
+      - ./types:/opt/types
+      - ./frontend:/opt/app

+ 4 - 0
compose.override.yml.example

@@ -0,0 +1,4 @@
+services:
+  frontend:
+    ports:
+      - "127.0.0.1:80:80"

+ 29 - 33
docker-compose.yml → compose.yml

@@ -1,16 +1,13 @@
-version: "3.8"
-
 services:
 services:
   backend:
   backend:
     build:
     build:
       context: .
       context: .
-      dockerfile: ./backend/Dockerfile
-      target: musare_backend
-    restart: ${RESTART_POLICY:-unless-stopped}
+      dockerfile: ./Dockerfile
+      target: backend
+    restart: unless-stopped
     volumes:
     volumes:
       - ./backend/config:/opt/app/config
       - ./backend/config:/opt/app/config
     environment:
     environment:
-      - CONTAINER_MODE=${CONTAINER_MODE:-production}
       - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
       - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
       - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
       - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
       - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
       - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
@@ -25,16 +22,18 @@ services:
     links:
     links:
       - mongo
       - mongo
       - redis
       - redis
+    networks:
+      - backend
+      - proxy
     stdin_open: true
     stdin_open: true
     tty: true
     tty: true
 
 
   frontend:
   frontend:
     build:
     build:
       context: .
       context: .
-      dockerfile: ./frontend/Dockerfile
-      target: musare_frontend
+      dockerfile: ./Dockerfile
+      target: frontend
       args:
       args:
-        FRONTEND_MODE: "${FRONTEND_MODE:-production}"
         FRONTEND_PROD_DEVTOOLS: "${FRONTEND_PROD_DEVTOOLS:-false}"
         FRONTEND_PROD_DEVTOOLS: "${FRONTEND_PROD_DEVTOOLS:-false}"
         MUSARE_SITENAME: "${MUSARE_SITENAME:-Musare}"
         MUSARE_SITENAME: "${MUSARE_SITENAME:-Musare}"
         MUSARE_PRIMARY_COLOR: "${MUSARE_PRIMARY_COLOR:-#03a9f4}"
         MUSARE_PRIMARY_COLOR: "${MUSARE_PRIMARY_COLOR:-#03a9f4}"
@@ -44,46 +43,43 @@ services:
         MUSARE_DEBUG_GIT_BRANCH: ${MUSARE_DEBUG_GIT_BRANCH:-true}
         MUSARE_DEBUG_GIT_BRANCH: ${MUSARE_DEBUG_GIT_BRANCH:-true}
         MUSARE_DEBUG_GIT_LATEST_COMMIT: "${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}"
         MUSARE_DEBUG_GIT_LATEST_COMMIT: "${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}"
         MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT: "${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}"
         MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT: "${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}"
-    restart: ${RESTART_POLICY:-unless-stopped}
-    user: root
-    ports:
-      - "${FRONTEND_HOST:-0.0.0.0}:${FRONTEND_PORT:-80}:80"
-    environment:
-      - CONTAINER_MODE=${CONTAINER_MODE:-production}
-      - FRONTEND_MODE=${FRONTEND_MODE:-production}
-      - FRONTEND_PORT=${FRONTEND_PORT:-80}
-      - FRONTEND_CLIENT_PORT=${FRONTEND_CLIENT_PORT:-80}
-      - FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-81}
-      - FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS:-false}
-      - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
-      - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
-      - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
-      - MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE:-false}
-      - MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL:-false}
-      - MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH:-true}
-      - MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT:-true}
-      - MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT:-true}
+    restart: unless-stopped
     links:
     links:
       - backend
       - backend
+    networks:
+      - proxy
 
 
   mongo:
   mongo:
     image: docker.io/mongo:${MONGO_VERSION}
     image: docker.io/mongo:${MONGO_VERSION}
-    restart: ${RESTART_POLICY:-unless-stopped}
+    restart: unless-stopped
     environment:
     environment:
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_INITDB_DATABASE=musare
       - MONGO_INITDB_DATABASE=musare
-      - MONGO_PORT=${MONGO_PORT:-27017}
       - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
       - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
+    networks:
+      - backend
     volumes:
     volumes:
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
-      - ${MONGO_DATA_LOCATION:-./db}:/data/db
+      - database:/data/db
 
 
   redis:
   redis:
     image: docker.io/redis:7
     image: docker.io/redis:7
-    restart: ${RESTART_POLICY:-unless-stopped}
+    restart: unless-stopped
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
+    networks:
+      - backend
     volumes:
     volumes:
-      - /data
+      - cache:/data
+
+networks:
+  proxy:
+
+  backend:
+
+volumes:
+  database:
+
+  cache:

+ 0 - 24
docker-compose.dev.yml

@@ -1,24 +0,0 @@
-services:
-  backend:
-    ports:
-      - "${BACKEND_HOST:-0.0.0.0}:${BACKEND_PORT:-8080}:8080"
-    volumes:
-      - ./.git:/opt/.git:ro
-      - ./types:/opt/types
-      - ./backend:/opt/app
-
-  frontend:
-    volumes:
-      - ./.git:/opt/.git:ro
-      - ./types:/opt/types
-      - ./frontend:/opt/app
-
-  mongo:
-    ports:
-      - "${MONGO_HOST:-0.0.0.0}:${MONGO_PORT:-27017}:${MONGO_PORT:-27017}"
-
-  redis:
-    ports:
-      - "${REDIS_HOST:-0.0.0.0}:${REDIS_PORT:-6379}:6379"
-    volumes:
-      - ${REDIS_DATA_LOCATION:-./redis}:/data

+ 0 - 50
frontend/Dockerfile

@@ -1,50 +0,0 @@
-FROM node:18 AS frontend_node_modules
-
-RUN mkdir -p /opt/app
-WORKDIR /opt/app
-
-COPY frontend/package.json frontend/package-lock.json /opt/app/
-
-RUN npm install --silent
-
-FROM node:18 AS musare_frontend
-
-ARG FRONTEND_MODE=production
-ARG FRONTEND_PROD_DEVTOOLS=false
-ARG MUSARE_SITENAME=Musare
-ARG MUSARE_PRIMARY_COLOR="#03a9f4"
-ARG MUSARE_DEBUG_VERSION=true
-ARG MUSARE_DEBUG_GIT_REMOTE=false
-ARG MUSARE_DEBUG_GIT_REMOTE_URL=false
-ARG MUSARE_DEBUG_GIT_BRANCH=true
-ARG MUSARE_DEBUG_GIT_LATEST_COMMIT=true
-ARG MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=true
-
-ENV FRONTEND_MODE=${FRONTEND_MODE} \
-    FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS} \
-    MUSARE_SITENAME=${MUSARE_SITENAME} \
-    MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR} \
-    MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION} \
-    MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE} \
-    MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL} \
-    MUSARE_DEBUG_GIT_BRANCH=${MUSARE_DEBUG_GIT_BRANCH} \
-    MUSARE_DEBUG_GIT_LATEST_COMMIT=${MUSARE_DEBUG_GIT_LATEST_COMMIT} \
-    MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=${MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT}
-
-RUN apt-get update && apt-get install nginx -y
-
-RUN mkdir -p /opt/.git /opt/types /opt/app /run/nginx
-WORKDIR /opt/app
-
-COPY .git /opt/.git
-COPY types /opt/types
-COPY frontend /opt/app
-COPY --from=frontend_node_modules /opt/app/node_modules node_modules
-
-RUN bash -c '([[ "${FRONTEND_MODE}" == "development" ]] && exit 0) || npm run prod'
-
-RUN chmod u+x entrypoint.sh
-
-ENTRYPOINT bash /opt/app/entrypoint.sh
-
-EXPOSE 80/tcp

+ 0 - 46
frontend/dev.nginx.conf

@@ -1,46 +0,0 @@
-worker_processes  1;
-
-events {
-    worker_connections  1024;
-}
-
-http {
-    include /etc/nginx/mime.types;
-    default_type application/octet-stream;
-
-    sendfile off;
-
-    keepalive_timeout  65;
-
-    server {
-        listen 80;
-        server_name localhost;
-
-        location / {
-            proxy_set_header X-Real-IP $remote_addr;
-			proxy_set_header X-Forwarded-For $remote_addr;
-			proxy_set_header Host $host;
-
-            proxy_http_version 1.1;
-			proxy_set_header Upgrade $http_upgrade;
-			proxy_set_header Connection "upgrade";
-            proxy_redirect off;
-
-			proxy_pass http://localhost:81; 
-        }
-
-        location /backend {
-            proxy_set_header X-Real-IP  $remote_addr;
-			proxy_set_header X-Forwarded-For $remote_addr;
-			proxy_set_header Host $host;
-
-            proxy_http_version 1.1;
-			proxy_set_header Upgrade $http_upgrade;
-			proxy_set_header Connection "upgrade";
-            proxy_redirect off;
-
-            rewrite ^/backend/?(.*) /$1 break;
-			proxy_pass http://backend:8080; 
-        }
-    }
-}

+ 18 - 0
frontend/entrypoint.dev.sh

@@ -0,0 +1,18 @@
+#!/bin/sh
+
+set -e
+
+# Install node modules if not found
+if [ ! -d node_modules ]; then
+    npm install
+fi
+
+if [ "${APP_ENV}" = "development" ]; then
+    ln -sf /opt/app/nginx.dev.conf /etc/nginx/http.d/default.conf
+    nginx
+
+    npm run dev
+else
+    ln -sf /opt/app/nginx.prod.conf /etc/nginx/http.d/default.conf
+    nginx -g "daemon off;"
+fi

+ 0 - 15
frontend/entrypoint.sh

@@ -1,15 +0,0 @@
-#!/bin/bash
-
-if [[ "${CONTAINER_MODE}" == "development" ]]; then
-    npm install --silent
-fi
-
-if [[ "${FRONTEND_MODE}" == "production" ]]; then
-    if [[ "${CONTAINER_MODE}" == "development" ]]; then
-        npm run prod
-    fi
-    nginx -c /opt/app/prod.nginx.conf -g "daemon off;"
-elif [ "${FRONTEND_MODE}" == "development" ]; then
-    nginx -c /opt/app/dev.nginx.conf
-    npm run dev
-fi

+ 31 - 0
frontend/nginx.dev.conf

@@ -0,0 +1,31 @@
+server {
+    listen 80;
+    server_name _;
+
+    location / {
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_redirect off;
+
+        proxy_pass http://localhost:81; 
+    }
+
+    location /backend {
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_redirect off;
+
+        rewrite ^/backend/?(.*) /$1 break;
+        proxy_pass http://backend:8080; 
+    }
+}

+ 24 - 0
frontend/nginx.prod.conf

@@ -0,0 +1,24 @@
+server {
+    listen 80;
+    server_name _;
+
+    root /usr/share/nginx/html;
+
+    location / {
+        try_files $uri /$uri /index.html =404;
+    }
+
+    location /backend {
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_redirect off;
+
+        rewrite ^/backend/?(.*) /$1 break;
+        proxy_pass http://backend:8080; 
+    }
+}

Різницю між файлами не показано, бо вона завелика
+ 435 - 195
frontend/package-lock.json


+ 35 - 36
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
     "*.vue"
   ],
   ],
   "private": true,
   "private": true,
-  "version": "3.11.0",
+  "version": "3.12.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "main": "main.js",
   "author": "Musare Team",
   "author": "Musare Team",
@@ -20,47 +20,46 @@
     "coverage": "vitest run --coverage"
     "coverage": "vitest run --coverage"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@pinia/testing": "^0.1.3",
-    "@types/can-autoplay": "^3.0.4",
-    "@types/dompurify": "^3.0.5",
-    "@typescript-eslint/eslint-plugin": "^7.0.2",
-    "@typescript-eslint/parser": "^7.0.2",
-    "@vitest/coverage-v8": "^1.3.1",
-    "@vue/test-utils": "^2.4.4",
-    "eslint": "^8.56.0",
-    "eslint-config-prettier": "^9.1.0",
-    "eslint-plugin-import": "^2.29.1",
-    "eslint-plugin-prettier": "^5.1.3",
-    "eslint-plugin-vue": "^9.22.0",
-    "jsdom": "^24.0.0",
-    "less": "^4.2.0",
-    "prettier": "^3.2.5",
-    "vite-plugin-dynamic-import": "^1.5.0",
-    "vitest": "^1.3.1",
-    "vue-eslint-parser": "^9.4.2",
-    "vue-tsc": "^1.8.27"
+    "@pinia/testing": "^0.1.7",
+    "@types/can-autoplay": "^3.0.5",
+    "@typescript-eslint/eslint-plugin": "^8.20.0",
+    "@typescript-eslint/parser": "^8.20.0",
+    "@vitest/coverage-v8": "^3.0.2",
+    "@vue/test-utils": "^2.4.6",
+    "eslint": "^8.57.1",
+    "eslint-config-prettier": "^10.0.1",
+    "eslint-plugin-import": "^2.31.0",
+    "eslint-plugin-prettier": "^5.2.3",
+    "eslint-plugin-vue": "^9.32.0",
+    "jsdom": "^26.0.0",
+    "less": "^4.2.1",
+    "prettier": "^3.4.2",
+    "vite-plugin-dynamic-import": "^1.6.0",
+    "vitest": "^3.0.2",
+    "vue-eslint-parser": "^9.4.3",
+    "vue-tsc": "^2.2.0"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@intlify/unplugin-vue-i18n": "^2.0.0",
-    "@vitejs/plugin-vue": "^5.0.4",
+    "@intlify/unplugin-vue-i18n": "^6.0.3",
+    "@vitejs/plugin-vue": "^5.2.1",
     "can-autoplay": "^3.0.2",
     "can-autoplay": "^3.0.2",
-    "chart.js": "^4.4.1",
-    "date-fns": "^3.3.1",
-    "dompurify": "^3.0.9",
+    "chart.js": "^4.4.7",
+    "date-fns": "^4.1.0",
+    "dompurify": "^3.2.3",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-airbnb-base": "^15.0.0",
-    "marked": "^12.0.0",
+    "marked": "^15.0.6",
     "normalize.css": "^8.0.1",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.1.7",
+    "pinia": "^2.3.0",
     "toasters": "^2.3.1",
     "toasters": "^2.3.1",
-    "typescript": "^5.3.3",
-    "vite": "^5.1.4",
-    "vue": "^3.3.2",
-    "vue-chartjs": "^5.3.0",
+    "typescript": "^5.7.3",
+    "vite": "^6.0.7",
+    "vue": "^3.5.13",
+    "vue-chartjs": "^5.3.2",
     "vue-content-loader": "^2.0.1",
     "vue-content-loader": "^2.0.1",
-    "vue-draggable-list": "^0.1.3",
-    "vue-i18n": "^9.9.1",
-    "vue-json-pretty": "^2.3.0",
-    "vue-router": "^4.3.0",
-    "vue-tippy": "^6.4.1"
+    "vue-draggable-list": "^0.2.0",
+    "vue-i18n": "^11.0.1",
+    "vue-json-pretty": "^2.4.0",
+    "vue-router": "^4.5.0",
+    "vue-tippy": "^6.6.0"
   }
   }
 }
 }

+ 0 - 39
frontend/prod.nginx.conf

@@ -1,39 +0,0 @@
-worker_processes  1;
-
-events {
-    worker_connections  1024;
-}
-
-http {
-    include /etc/nginx/mime.types;
-    default_type application/octet-stream;
-
-    sendfile off;
-
-    keepalive_timeout 65;
-
-    server {
-        listen 80;
-        server_name _;
-
-        root /opt/app/build;
-
-        location / {
-            try_files $uri /$uri /index.html =404;
-        }
-
-        location /backend {
-            proxy_set_header X-Real-IP $remote_addr;
-            proxy_set_header X-Forwarded-For $remote_addr;
-            proxy_set_header Host $host;
-
-            proxy_http_version 1.1;
-            proxy_set_header Upgrade $http_upgrade;
-            proxy_set_header Connection "upgrade";
-            proxy_redirect off;
-
-            rewrite ^/backend/?(.*) /$1 break;
-            proxy_pass http://backend:8080; 
-        }
-    }
-}

+ 21 - 5
frontend/src/App.vue

@@ -49,7 +49,9 @@ const {
 	changeAutoSkipDisliked,
 	changeAutoSkipDisliked,
 	changeActivityLogPublic,
 	changeActivityLogPublic,
 	changeAnonymousSongRequests,
 	changeAnonymousSongRequests,
-	changeActivityWatch
+	changeActivityWatch,
+	changeDefaultStationPrivacy,
+	changeDefaultPlaylistPrivacy
 } = userPreferencesStore;
 } = userPreferencesStore;
 const { activeModals } = storeToRefs(modalsStore);
 const { activeModals } = storeToRefs(modalsStore);
 const { openModal, closeCurrentModal } = modalsStore;
 const { openModal, closeCurrentModal } = modalsStore;
@@ -197,6 +199,12 @@ onMounted(async () => {
 						preferences.anonymousSongRequests
 						preferences.anonymousSongRequests
 					);
 					);
 					changeActivityWatch(preferences.activityWatch);
 					changeActivityWatch(preferences.activityWatch);
+					changeDefaultStationPrivacy(
+						preferences.defaultStationPrivacy
+					);
+					changeDefaultPlaylistPrivacy(
+						preferences.defaultPlaylistPrivacy
+					);
 				}
 				}
 			}
 			}
 		);
 		);
@@ -248,11 +256,11 @@ onMounted(async () => {
 
 
 		router.isReady().then(() => {
 		router.isReady().then(() => {
 			if (
 			if (
-				configStore.githubAuthentication &&
-				localStorage.getItem("github_redirect")
+				configStore.oidcAuthentication &&
+				localStorage.getItem("oidc_redirect")
 			) {
 			) {
-				router.push(localStorage.getItem("github_redirect"));
-				localStorage.removeItem("github_redirect");
+				router.push(localStorage.getItem("oidc_redirect"));
+				localStorage.removeItem("oidc_redirect");
 			}
 			}
 		});
 		});
 	}, true);
 	}, true);
@@ -1644,6 +1652,14 @@ button.delete:focus {
 	}
 	}
 }
 }
 
 
+.input-with-label {
+	column-gap: 8px;
+
+	.label {
+		align-items: center;
+	}
+}
+
 .page-title {
 .page-title {
 	margin: 0 0 50px 0;
 	margin: 0 0 50px 0;
 }
 }

+ 27 - 6
frontend/src/components/AdvancedTable.vue

@@ -182,11 +182,32 @@ const bulkPopup = ref();
 const rowElements = ref([]);
 const rowElements = ref([]);
 
 
 const lastPage = computed(() => Math.ceil(count.value / pageSize.value));
 const lastPage = computed(() => Math.ceil(count.value / pageSize.value));
-const sortedFilteredColumns = computed(() =>
-	orderedColumns.value.filter(
-		column => shownColumns.value.indexOf(column.name) !== -1
-	)
-);
+const sortedFilteredColumns = computed({
+	get: () =>
+		orderedColumns.value.filter(
+			column => shownColumns.value.indexOf(column.name) !== -1
+		),
+	set: newValue =>
+		orderedColumns.value.sort((columnA, columnB) => {
+			// Always places updatedPlaceholder column in the first position
+			if (columnA.name === "updatedPlaceholder") return -1;
+			if (columnB.name === "updatedPlaceholder") return 1;
+			// Always places select column in the second position
+			if (columnA.name === "select") return -1;
+			if (columnB.name === "select") return 1;
+			// Always places placeholder column in the last position
+			if (columnA.name === "placeholder") return 1;
+			if (columnB.name === "placeholder") return -1;
+
+			const indexA = newValue.indexOf(columnA);
+			const indexB = newValue.indexOf(columnB);
+
+			// If either of the columns is not visible, use default ordering
+			if (indexA === -1 || indexB === -1) return 0;
+
+			return indexA - indexB;
+		})
+});
 const hidableSortedColumns = computed(() =>
 const hidableSortedColumns = computed(() =>
 	orderedColumns.value.filter(column => column.hidable)
 	orderedColumns.value.filter(column => column.hidable)
 );
 );
@@ -1598,7 +1619,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 					<thead>
 					<thead>
 						<tr>
 						<tr>
 							<draggable-list
 							<draggable-list
-								v-model:list="orderedColumns"
+								v-model:list="sortedFilteredColumns"
 								item-key="name"
 								item-key="name"
 								@update="columnOrderChanged"
 								@update="columnOrderChanged"
 								tag="th"
 								tag="th"

+ 1 - 1
frontend/src/components/MainFooter.vue

@@ -25,7 +25,7 @@ const getLink = title =>
 		<div class="container">
 		<div class="container">
 			<div class="footer-content">
 			<div class="footer-content">
 				<div id="footer-copyright">
 				<div id="footer-copyright">
-					<p>© Copyright Musare 2015 - 2024</p>
+					<p>© Copyright Musare 2015 - 2025</p>
 				</div>
 				</div>
 				<router-link id="footer-logo" to="/">
 				<router-link id="footer-logo" to="/">
 					<img
 					<img

+ 25 - 3
frontend/src/components/MainHeader.vue

@@ -2,6 +2,7 @@
 import { defineAsyncComponent, ref, onMounted, watch, nextTick } from "vue";
 import { defineAsyncComponent, ref, onMounted, watch, nextTick } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { storeToRefs } from "pinia";
+import { useRoute } from "vue-router";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useConfigStore } from "@/stores/config";
 import { useConfigStore } from "@/stores/config";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserAuthStore } from "@/stores/userAuth";
@@ -18,6 +19,8 @@ defineProps({
 	hideLoggedOut: { type: Boolean, default: false }
 	hideLoggedOut: { type: Boolean, default: false }
 });
 });
 
 
+const route = useRoute();
+
 const userAuthStore = useUserAuthStore();
 const userAuthStore = useUserAuthStore();
 
 
 const localNightmode = ref(false);
 const localNightmode = ref(false);
@@ -28,8 +31,13 @@ const broadcastChannel = ref();
 const { socket } = useWebsocketsStore();
 const { socket } = useWebsocketsStore();
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { cookie, sitename, registrationDisabled, christmas } =
-	storeToRefs(configStore);
+const {
+	cookie,
+	sitename,
+	registrationDisabled,
+	christmas,
+	oidcAuthentication
+} = storeToRefs(configStore);
 
 
 const { loggedIn, username } = storeToRefs(userAuthStore);
 const { loggedIn, username } = storeToRefs(userAuthStore);
 const { logout, hasPermission } = userAuthStore;
 const { logout, hasPermission } = userAuthStore;
@@ -59,6 +67,10 @@ const onResize = () => {
 	windowWidth.value = window.innerWidth;
 	windowWidth.value = window.innerWidth;
 };
 };
 
 
+const oidcRedirect = () => {
+	localStorage.setItem("oidc_redirect", route.path);
+};
+
 watch(nightmode, value => {
 watch(nightmode, value => {
 	localNightmode.value = value;
 	localNightmode.value = value;
 });
 });
@@ -157,7 +169,17 @@ onMounted(async () => {
 				<a class="nav-item" @click="logout()">Logout</a>
 				<a class="nav-item" @click="logout()">Logout</a>
 			</span>
 			</span>
 			<span v-if="!loggedIn && !hideLoggedOut" class="grouped">
 			<span v-if="!loggedIn && !hideLoggedOut" class="grouped">
-				<a class="nav-item" @click="openModal('login')">Login</a>
+				<a
+					v-if="oidcAuthentication"
+					class="nav-item"
+					:href="configStore.urls.api + '/auth/oidc/authorize'"
+					@click="oidcRedirect()"
+				>
+					Login
+				</a>
+				<a v-else class="nav-item" @click="openModal('login')">
+					Login
+				</a>
 				<a
 				<a
 					v-if="!registrationDisabled"
 					v-if="!registrationDisabled"
 					class="nav-item"
 					class="nav-item"

+ 10 - 5
frontend/src/components/modals/CreatePlaylist.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { defineAsyncComponent, ref, onBeforeUnmount } from "vue";
 import { defineAsyncComponent, ref, onBeforeUnmount } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
+import { storeToRefs } from "pinia";
 import validation from "@/validation";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useModalsStore } from "@/stores/modals";
+import { useUserPreferencesStore } from "@/stores/userPreferences";
 
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
 
@@ -12,16 +14,19 @@ const props = defineProps({
 	admin: { type: Boolean, default: false }
 	admin: { type: Boolean, default: false }
 });
 });
 
 
+const { openModal, closeCurrentModal } = useModalsStore();
+
+const { socket } = useWebsocketsStore();
+
+const userPreferencesStore = useUserPreferencesStore();
+const { defaultPlaylistPrivacy } = storeToRefs(userPreferencesStore);
+
 const playlist = ref({
 const playlist = ref({
 	displayName: "",
 	displayName: "",
-	privacy: "public",
+	privacy: defaultPlaylistPrivacy.value,
 	songs: []
 	songs: []
 });
 });
 
 
-const { openModal, closeCurrentModal } = useModalsStore();
-
-const { socket } = useWebsocketsStore();
-
 const createPlaylist = () => {
 const createPlaylist = () => {
 	const { displayName } = playlist.value;
 	const { displayName } = playlist.value;
 
 

+ 18 - 4
frontend/src/components/modals/CreateStation.vue

@@ -1,8 +1,10 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { defineAsyncComponent, ref } from "vue";
 import { defineAsyncComponent, ref } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
+import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useModalsStore } from "@/stores/modals";
+import { useUserPreferencesStore } from "@/stores/userPreferences";
 import validation from "@/validation";
 import validation from "@/validation";
 
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
@@ -16,15 +18,19 @@ const { socket } = useWebsocketsStore();
 
 
 const { closeCurrentModal } = useModalsStore();
 const { closeCurrentModal } = useModalsStore();
 
 
+const userPreferencesStore = useUserPreferencesStore();
+const { defaultStationPrivacy } = storeToRefs(userPreferencesStore);
+
 const newStation = ref({
 const newStation = ref({
 	name: "",
 	name: "",
 	displayName: "",
 	displayName: "",
-	description: ""
+	description: "",
+	privacy: defaultStationPrivacy.value
 });
 });
 
 
 const submitModal = () => {
 const submitModal = () => {
 	newStation.value.name = newStation.value.name.toLowerCase();
 	newStation.value.name = newStation.value.name.toLowerCase();
-	const { name, displayName, description } = newStation.value;
+	const { name, displayName, description, privacy } = newStation.value;
 
 
 	if (!name || !displayName || !description)
 	if (!name || !displayName || !description)
 		return new Toast("Please fill in all fields");
 		return new Toast("Please fill in all fields");
@@ -62,7 +68,8 @@ const submitModal = () => {
 			name,
 			name,
 			type: props.official ? "official" : "community",
 			type: props.official ? "official" : "community",
 			displayName,
 			displayName,
-			description
+			description,
+			privacy
 		},
 		},
 		res => {
 		res => {
 			if (res.status === "success") {
 			if (res.status === "success") {
@@ -107,9 +114,16 @@ const submitModal = () => {
 					class="input"
 					class="input"
 					type="text"
 					type="text"
 					placeholder="Description..."
 					placeholder="Description..."
-					@keyup.enter="submitModal()"
 				/>
 				/>
 			</p>
 			</p>
+			<label class="label">Privacy</label>
+			<p class="control select">
+				<select v-model="newStation.privacy">
+					<option value="public">Public</option>
+					<option value="unlisted">Unlisted</option>
+					<option value="private">Private</option>
+				</select>
+			</p>
 		</template>
 		</template>
 		<template #footer>
 		<template #footer>
 			<a class="button is-primary" @click="submitModal()">Create</a>
 			<a class="button is-primary" @click="submitModal()">Create</a>

+ 1 - 1
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -259,7 +259,7 @@ watch(
 								@click="
 								@click="
 									addYouTubeSongToPlaylist(
 									addYouTubeSongToPlaylist(
 										playlist._id,
 										playlist._id,
-										result.id,
+										`youtube:${result.id}`,
 										index
 										index
 									)
 									)
 								"
 								"

+ 1 - 1
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -151,7 +151,7 @@ const onMusarePlaylistFileChange = () => {
 					"An error occured whilst parsing the playlist file. Is it valid?"
 					"An error occured whilst parsing the playlist file. Is it valid?"
 				);
 				);
 			else importMusarePlaylistFileContents.value = parsed;
 			else importMusarePlaylistFileContents.value = parsed;
-		} catch (err) {
+		} catch {
 			new Toast(
 			new Toast(
 				"An error occured whilst parsing the playlist file. Is it valid?"
 				"An error occured whilst parsing the playlist file. Is it valid?"
 			);
 			);

+ 5 - 5
frontend/src/components/modals/EditSong/index.vue

@@ -812,7 +812,7 @@ const getYouTubeData = type => {
 
 
 			if (title) setValue({ title });
 			if (title) setValue({ title });
 			else throw new Error("No title found");
 			else throw new Error("No title found");
-		} catch (e) {
+		} catch {
 			new Toast(
 			new Toast(
 				"Unable to fetch YouTube video title. Try starting the video."
 				"Unable to fetch YouTube video title. Try starting the video."
 			);
 			);
@@ -830,7 +830,7 @@ const getYouTubeData = type => {
 
 
 			if (author) setValue({ addArtist: author });
 			if (author) setValue({ addArtist: author });
 			else throw new Error("No video author found");
 			else throw new Error("No video author found");
-		} catch (e) {
+		} catch {
 			new Toast(
 			new Toast(
 				"Unable to fetch YouTube video author. Try starting the video."
 				"Unable to fetch YouTube video author. Try starting the video."
 			);
 			);
@@ -847,7 +847,7 @@ const getSoundCloudData = type => {
 				if (title) setValue({ title });
 				if (title) setValue({ title });
 				else throw new Error("No title found");
 				else throw new Error("No title found");
 			});
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track title.");
 			new Toast("Unable to fetch SoundCloud track title.");
 		}
 		}
 	}
 	}
@@ -866,7 +866,7 @@ const getSoundCloudData = type => {
 				if (artworkUrl) setValue({ thumbnail: artworkUrl });
 				if (artworkUrl) setValue({ thumbnail: artworkUrl });
 				else throw new Error("No thumbnail found");
 				else throw new Error("No thumbnail found");
 			});
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track artwork.");
 			new Toast("Unable to fetch SoundCloud track artwork.");
 		}
 		}
 	}
 	}
@@ -879,7 +879,7 @@ const getSoundCloudData = type => {
 				if (user) setValue({ addArtist: user.username });
 				if (user) setValue({ addArtist: user.username });
 				else throw new Error("No artist found");
 				else throw new Error("No artist found");
 			});
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track artist.");
 			new Toast("Unable to fetch SoundCloud track artist.");
 		}
 		}
 	}
 	}

+ 1 - 30
frontend/src/components/modals/Login.vue

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { defineAsyncComponent, ref } from "vue";
 import { defineAsyncComponent, ref } from "vue";
-import { useRoute } from "vue-router";
 import Toast from "toasters";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useConfigStore } from "@/stores/config";
@@ -9,8 +8,6 @@ import { useModalsStore } from "@/stores/modals";
 
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
 
-const route = useRoute();
-
 const email = ref("");
 const email = ref("");
 const password = ref({
 const password = ref({
 	value: "",
 	value: "",
@@ -19,7 +16,7 @@ const password = ref({
 const passwordElement = ref();
 const passwordElement = ref();
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { githubAuthentication, registrationDisabled } = storeToRefs(configStore);
+const { registrationDisabled } = storeToRefs(configStore);
 const { login } = useUserAuthStore();
 const { login } = useUserAuthStore();
 
 
 const { openModal, closeCurrentModal } = useModalsStore();
 const { openModal, closeCurrentModal } = useModalsStore();
@@ -62,10 +59,6 @@ const changeToRegisterModal = () => {
 	closeCurrentModal();
 	closeCurrentModal();
 	openModal("register");
 	openModal("register");
 };
 };
-
-const githubRedirect = () => {
-	localStorage.setItem("github_redirect", route.path);
-};
 </script>
 </script>
 
 
 <template>
 <template>
@@ -150,20 +143,6 @@ const githubRedirect = () => {
 					<button class="button is-primary" @click="submitModal()">
 					<button class="button is-primary" @click="submitModal()">
 						Login
 						Login
 					</button>
 					</button>
-					<a
-						v-if="githubAuthentication"
-						class="button is-github"
-						:href="configStore.urls.api + '/auth/github/authorize'"
-						@click="githubRedirect()"
-					>
-						<div class="icon">
-							<img
-								class="invert"
-								src="/assets/social/github.svg"
-							/>
-						</div>
-						&nbsp;&nbsp;Login with GitHub
-					</a>
 				</div>
 				</div>
 
 
 				<p
 				<p
@@ -204,14 +183,6 @@ const githubRedirect = () => {
 	}
 	}
 }
 }
 
 
-.button.is-github {
-	background-color: var(--dark-grey-2);
-	color: var(--white) !important;
-}
-
-.is-github:focus {
-	background-color: var(--dark-grey-4);
-}
 .is-primary:focus {
 .is-primary:focus {
 	background-color: var(--primary-color) !important;
 	background-color: var(--primary-color) !important;
 }
 }

+ 1 - 32
frontend/src/components/modals/Register.vue

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { defineAsyncComponent, ref, watch, onMounted } from "vue";
 import { defineAsyncComponent, ref, watch, onMounted } from "vue";
-import { useRoute } from "vue-router";
 import Toast from "toasters";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useConfigStore } from "@/stores/config";
@@ -13,8 +12,6 @@ const InputHelpBox = defineAsyncComponent(
 	() => import("@/components/InputHelpBox.vue")
 	() => import("@/components/InputHelpBox.vue")
 );
 );
 
 
-const route = useRoute();
-
 const username = ref({
 const username = ref({
 	value: "",
 	value: "",
 	valid: false,
 	valid: false,
@@ -41,8 +38,7 @@ const passwordElement = ref();
 const { register } = useUserAuthStore();
 const { register } = useUserAuthStore();
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { registrationDisabled, recaptcha, githubAuthentication } =
-	storeToRefs(configStore);
+const { registrationDisabled, recaptcha } = storeToRefs(configStore);
 const { openModal, closeCurrentModal } = useModalsStore();
 const { openModal, closeCurrentModal } = useModalsStore();
 
 
 const submitModal = () => {
 const submitModal = () => {
@@ -76,10 +72,6 @@ const changeToLoginModal = () => {
 	openModal("login");
 	openModal("login");
 };
 };
 
 
-const githubRedirect = () => {
-	localStorage.setItem("github_redirect", route.path);
-};
-
 watch(
 watch(
 	() => username.value.value,
 	() => username.value.value,
 	value => {
 	value => {
@@ -274,20 +266,6 @@ onMounted(async () => {
 					<button class="button is-primary" @click="submitModal()">
 					<button class="button is-primary" @click="submitModal()">
 						Register
 						Register
 					</button>
 					</button>
-					<a
-						v-if="githubAuthentication"
-						class="button is-github"
-						:href="configStore.urls.api + '/auth/github/authorize'"
-						@click="githubRedirect()"
-					>
-						<div class="icon">
-							<img
-								class="invert"
-								src="/assets/social/github.svg"
-							/>
-						</div>
-						&nbsp;&nbsp;Register with GitHub
-					</a>
 				</div>
 				</div>
 
 
 				<p class="content-box-optional-helper">
 				<p class="content-box-optional-helper">
@@ -329,15 +307,6 @@ onMounted(async () => {
 	}
 	}
 }
 }
 
 
-.button.is-github {
-	background-color: var(--dark-grey-2);
-	color: var(--white) !important;
-}
-
-.is-github:focus {
-	background-color: var(--dark-grey-4);
-}
-
 .invert {
 .invert {
 	filter: brightness(5);
 	filter: brightness(5);
 }
 }

+ 9 - 119
frontend/src/components/modals/RemoveAccount.vue

@@ -1,10 +1,8 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted } from "vue";
-import { useRoute } from "vue-router";
+import { defineAsyncComponent, onMounted, ref } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useConfigStore } from "@/stores/config";
-import { useSettingsStore } from "@/stores/settings";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useModalsStore } from "@/stores/modals";
 
 
@@ -13,20 +11,15 @@ const QuickConfirm = defineAsyncComponent(
 	() => import("@/components/QuickConfirm.vue")
 	() => import("@/components/QuickConfirm.vue")
 );
 );
 
 
-const props = defineProps({
-	modalUuid: { type: String, required: true },
-	githubLinkConfirmed: { type: Boolean, default: false }
+defineProps({
+	modalUuid: { type: String, required: true }
 });
 });
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { cookie, githubAuthentication, messages } = storeToRefs(configStore);
-const settingsStore = useSettingsStore();
-const route = useRoute();
+const { cookie, oidcAuthentication, messages } = storeToRefs(configStore);
 
 
 const { socket } = useWebsocketsStore();
 const { socket } = useWebsocketsStore();
 
 
-const { isPasswordLinked, isGithubLinked } = settingsStore;
-
 const { closeCurrentModal } = useModalsStore();
 const { closeCurrentModal } = useModalsStore();
 
 
 const step = ref("confirm-identity");
 const step = ref("confirm-identity");
@@ -67,28 +60,6 @@ const confirmPasswordMatch = () =>
 		else new Toast(res.message);
 		else new Toast(res.message);
 	});
 	});
 
 
-const confirmGithubLink = () =>
-	socket.dispatch("users.confirmGithubLink", res => {
-		if (res.status === "success") {
-			if (res.data.linked) step.value = "remove-account";
-			else {
-				new Toast(
-					`Your GitHub account isn't linked. Please re-link your account and try again.`
-				);
-				step.value = "relink-github";
-			}
-		} else new Toast(res.message);
-	});
-
-const relinkGithub = () => {
-	localStorage.setItem(
-		"github_redirect",
-		`${window.location.pathname + window.location.search}${
-			!route.query.removeAccount ? "&removeAccount=relinked-github" : ""
-		}`
-	);
-};
-
 const remove = () =>
 const remove = () =>
 	socket.dispatch("users.remove", res => {
 	socket.dispatch("users.remove", res => {
 		if (res.status === "success") {
 		if (res.status === "success") {
@@ -102,8 +73,8 @@ const remove = () =>
 		return new Toast(res.message);
 		return new Toast(res.message);
 	});
 	});
 
 
-onMounted(async () => {
-	if (props.githubLinkConfirmed === true) confirmGithubLink();
+onMounted(() => {
+	if (oidcAuthentication.value) step.value = "remove-account";
 });
 });
 </script>
 </script>
 
 
@@ -113,7 +84,7 @@ onMounted(async () => {
 		class="confirm-account-removal-modal"
 		class="confirm-account-removal-modal"
 	>
 	>
 		<template #body>
 		<template #body>
-			<div id="steps">
+			<div v-if="!oidcAuthentication" id="steps">
 				<p
 				<p
 					class="step"
 					class="step"
 					:class="{ selected: step === 'confirm-identity' }"
 					:class="{ selected: step === 'confirm-identity' }"
@@ -124,41 +95,17 @@ onMounted(async () => {
 				<p
 				<p
 					class="step"
 					class="step"
 					:class="{
 					:class="{
-						selected:
-							(isPasswordLinked && step === 'export-data') ||
-							step === 'relink-github'
+						selected: step === 'remove-account'
 					}"
 					}"
 				>
 				>
 					2
 					2
 				</p>
 				</p>
-				<span class="divider"></span>
-				<p
-					class="step"
-					:class="{
-						selected:
-							(isPasswordLinked && step === 'remove-account') ||
-							step === 'export-data'
-					}"
-				>
-					3
-				</p>
-				<span class="divider" v-if="!isPasswordLinked"></span>
-				<p
-					class="step"
-					:class="{ selected: step === 'remove-account' }"
-					v-if="!isPasswordLinked"
-				>
-					4
-				</p>
 			</div>
 			</div>
 
 
 			<div
 			<div
 				class="content-box"
 				class="content-box"
 				id="password-linked"
 				id="password-linked"
-				v-if="
-					step === 'confirm-identity' &&
-					(isPasswordLinked || !githubAuthentication)
-				"
+				v-if="!oidcAuthentication && step === 'confirm-identity'"
 			>
 			>
 				<h2 class="content-box-title">Enter your password</h2>
 				<h2 class="content-box-title">Enter your password</h2>
 				<p class="content-box-description">
 				<p class="content-box-description">
@@ -216,63 +163,6 @@ onMounted(async () => {
 				</div>
 				</div>
 			</div>
 			</div>
 
 
-			<div
-				class="content-box"
-				v-else-if="
-					githubAuthentication &&
-					isGithubLinked &&
-					step === 'confirm-identity'
-				"
-			>
-				<h2 class="content-box-title">Verify your GitHub</h2>
-				<p class="content-box-description">
-					Check your account is still linked to remove your account.
-				</p>
-
-				<div class="content-box-inputs">
-					<a class="button is-github" @click="confirmGithubLink()">
-						<div class="icon">
-							<img
-								class="invert"
-								src="/assets/social/github.svg"
-							/>
-						</div>
-						&nbsp; Check GitHub is linked
-					</a>
-				</div>
-			</div>
-
-			<div
-				class="content-box"
-				v-if="githubAuthentication && step === 'relink-github'"
-			>
-				<h2 class="content-box-title">Re-link GitHub</h2>
-				<p class="content-box-description">
-					Re-link your GitHub account in order to verify your
-					identity.
-				</p>
-
-				<div class="content-box-inputs">
-					<a
-						class="button is-github"
-						@click="relinkGithub()"
-						:href="`${configStore.urls.api}/auth/github/link`"
-					>
-						<div class="icon">
-							<img
-								class="invert"
-								src="/assets/social/github.svg"
-							/>
-						</div>
-						&nbsp; Re-link GitHub to account
-					</a>
-				</div>
-			</div>
-
-			<div v-if="step === 'export-data'">
-				DOWNLOAD A BACKUP OF YOUR DATA BEFORE ITS PERMENATNELY DELETED
-			</div>
-
 			<div
 			<div
 				class="content-box"
 				class="content-box"
 				id="remove-account-container"
 				id="remove-account-container"

+ 1 - 1
frontend/src/composables/useYoutubeDirect.ts

@@ -40,7 +40,7 @@ export const useYoutubeDirect = () => {
 					return youtubeVideoIdMatch.groups.youtubeId;
 					return youtubeVideoIdMatch.groups.youtubeId;
 				}
 				}
 			}
 			}
-		} catch (error) {
+		} catch {
 			return null;
 			return null;
 		}
 		}
 
 

+ 2 - 2
frontend/src/index.html

@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="en">
 <html lang="en">
 	<head>
 	<head>
 		<title>{{ title }}</title>
 		<title>{{ title }}</title>
@@ -19,7 +19,7 @@
 		/>
 		/>
 		<meta
 		<meta
 			name="copyright"
 			name="copyright"
-			content="© Copyright Musare 2015-2024 All Right Reserved"
+			content="© Copyright Musare 2015-2025 All Right Reserved"
 		/>
 		/>
 
 
 		<link
 		<link

+ 10 - 11
frontend/src/main.ts

@@ -137,16 +137,7 @@ const router = createRouter({
 			path: "/reset_password",
 			path: "/reset_password",
 			component: () => import("@/pages/ResetPassword.vue"),
 			component: () => import("@/pages/ResetPassword.vue"),
 			meta: {
 			meta: {
-				configRequired: "mailEnabled"
-			}
-		},
-		{
-			path: "/set_password",
-			props: { mode: "set" },
-			component: () => import("@/pages/ResetPassword.vue"),
-			meta: {
-				configRequired: "mailEnabled",
-				loginRequired: true
+				configRequired: "passwordResetEnabled"
 			}
 			}
 		},
 		},
 		{
 		{
@@ -387,7 +378,9 @@ createSocket().then(async socket => {
 			changeNightmode,
 			changeNightmode,
 			changeActivityLogPublic,
 			changeActivityLogPublic,
 			changeAnonymousSongRequests,
 			changeAnonymousSongRequests,
-			changeActivityWatch
+			changeActivityWatch,
+			changeDefaultStationPrivacy,
+			changeDefaultPlaylistPrivacy
 		} = useUserPreferencesStore();
 		} = useUserPreferencesStore();
 
 
 		if (preferences.autoSkipDisliked !== undefined)
 		if (preferences.autoSkipDisliked !== undefined)
@@ -405,6 +398,12 @@ createSocket().then(async socket => {
 
 
 		if (preferences.activityWatch !== undefined)
 		if (preferences.activityWatch !== undefined)
 			changeActivityWatch(preferences.activityWatch);
 			changeActivityWatch(preferences.activityWatch);
+
+		if (preferences.defaultStationPrivacy !== undefined)
+			changeDefaultStationPrivacy(preferences.defaultStationPrivacy);
+
+		if (preferences.defaultPlaylistPrivacy !== undefined)
+			changeDefaultPlaylistPrivacy(preferences.defaultPlaylistPrivacy);
 	});
 	});
 
 
 	socket.on("keep.event:user.role.updated", res => {
 	socket.on("keep.event:user.role.updated", res => {

+ 32 - 37
frontend/src/pages/Admin/Users/index.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { defineAsyncComponent, ref, onMounted } from "vue";
 import { defineAsyncComponent, ref, onMounted } from "vue";
 import { useRoute } from "vue-router";
 import { useRoute } from "vue-router";
+import { storeToRefs } from "pinia";
 import { useModalsStore } from "@/stores/modals";
 import { useModalsStore } from "@/stores/modals";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
 import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+import { useConfigStore } from "@/stores/config";
 
 
 const AdvancedTable = defineAsyncComponent(
 const AdvancedTable = defineAsyncComponent(
 	() => import("@/components/AdvancedTable.vue")
 	() => import("@/components/AdvancedTable.vue")
@@ -12,6 +14,9 @@ const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
 	() => import("@/components/ProfilePicture.vue")
 );
 );
 
 
+const configStore = useConfigStore();
+const { oidcAuthentication } = storeToRefs(configStore);
+
 const route = useRoute();
 const route = useRoute();
 
 
 const columnDefault = ref<TableColumn>({
 const columnDefault = ref<TableColumn>({
@@ -63,20 +68,18 @@ const columns = ref<TableColumn[]>([
 		minWidth: 230,
 		minWidth: 230,
 		defaultWidth: 230
 		defaultWidth: 230
 	},
 	},
-	{
-		name: "githubId",
-		displayName: "GitHub ID",
-		properties: ["services.github.id"],
-		sortProperty: "services.github.id",
-		minWidth: 115,
-		defaultWidth: 115
-	},
-	{
-		name: "hasPassword",
-		displayName: "Has Password",
-		properties: ["hasPassword"],
-		sortProperty: "hasPassword"
-	},
+	...(oidcAuthentication.value
+		? [
+				{
+					name: "oidcSub",
+					displayName: "OIDC sub",
+					properties: ["services.oidc.sub"],
+					sortProperty: "services.oidc.sub",
+					minWidth: 115,
+					defaultWidth: 115
+				}
+			]
+		: []),
 	{
 	{
 		name: "role",
 		name: "role",
 		displayName: "Role",
 		displayName: "Role",
@@ -132,20 +135,17 @@ const filters = ref<TableFilter[]>([
 		filterTypes: ["contains", "exact", "regex"],
 		filterTypes: ["contains", "exact", "regex"],
 		defaultFilterType: "contains"
 		defaultFilterType: "contains"
 	},
 	},
-	{
-		name: "githubId",
-		displayName: "GitHub ID",
-		property: "services.github.id",
-		filterTypes: ["contains", "exact", "regex"],
-		defaultFilterType: "contains"
-	},
-	{
-		name: "hasPassword",
-		displayName: "Has Password",
-		property: "hasPassword",
-		filterTypes: ["boolean"],
-		defaultFilterType: "boolean"
-	},
+	...(oidcAuthentication.value
+		? [
+				{
+					name: "oidcSub",
+					displayName: "OIDC sub",
+					property: "services.oidc.sub",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			]
+		: []),
 	{
 	{
 		name: "role",
 		name: "role",
 		displayName: "Role",
 		displayName: "Role",
@@ -279,18 +279,13 @@ onMounted(() => {
 					slotProps.item._id
 					slotProps.item._id
 				}}</span>
 				}}</span>
 			</template>
 			</template>
-			<template #column-githubId="slotProps">
+			<template v-if="oidcAuthentication" #column-oidcSub="slotProps">
 				<span
 				<span
-					v-if="slotProps.item.services.github"
-					:title="slotProps.item.services.github.id"
-					>{{ slotProps.item.services.github.id }}</span
+					v-if="slotProps.item.services.oidc"
+					:title="slotProps.item.services.oidc.sub"
+					>{{ slotProps.item.services.oidc.sub }}</span
 				>
 				>
 			</template>
 			</template>
-			<template #column-hasPassword="slotProps">
-				<span :title="slotProps.item.hasPassword">{{
-					slotProps.item.hasPassword
-				}}</span>
-			</template>
 			<template #column-role="slotProps">
 			<template #column-role="slotProps">
 				<span :title="slotProps.item.role">{{
 				<span :title="slotProps.item.role">{{
 					slotProps.item.role
 					slotProps.item.role

+ 19 - 2
frontend/src/pages/Home.vue

@@ -38,7 +38,8 @@ const userAuthStore = useUserAuthStore();
 const route = useRoute();
 const route = useRoute();
 const router = useRouter();
 const router = useRouter();
 
 
-const { sitename, registrationDisabled } = storeToRefs(configStore);
+const { sitename, registrationDisabled, oidcAuthentication } =
+	storeToRefs(configStore);
 const { loggedIn, userId } = storeToRefs(userAuthStore);
 const { loggedIn, userId } = storeToRefs(userAuthStore);
 const { hasPermission } = userAuthStore;
 const { hasPermission } = userAuthStore;
 
 
@@ -152,6 +153,12 @@ const changeFavoriteOrder = ({ moved }) => {
 	);
 	);
 };
 };
 
 
+const oidcRedirect = () => {
+	localStorage.setItem("oidc_redirect", route.path);
+
+	window.location.href = `${configStore.urls.api}/auth/oidc/authorize`;
+};
+
 watch(
 watch(
 	() => hasPermission("stations.index.other"),
 	() => hasPermission("stations.index.other"),
 	value => {
 	value => {
@@ -178,7 +185,9 @@ onMounted(async () => {
 	) {
 	) {
 		// Makes sure the login/register modal isn't opened whenever the home page gets remounted due to a code change
 		// Makes sure the login/register modal isn't opened whenever the home page gets remounted due to a code change
 		handledLoginRegisterRedirect.value = true;
 		handledLoginRegisterRedirect.value = true;
-		openModal(route.redirectedFrom.name);
+
+		if (oidcAuthentication.value) oidcRedirect();
+		else openModal(route.redirectedFrom.name);
 	}
 	}
 
 
 	socket.onConnect(() => {
 	socket.onConnect(() => {
@@ -384,7 +393,15 @@ onBeforeUnmount(() => {
 						/>
 						/>
 						<span v-else class="logo">{{ sitename }}</span>
 						<span v-else class="logo">{{ sitename }}</span>
 						<div v-if="!loggedIn" class="buttons">
 						<div v-if="!loggedIn" class="buttons">
+							<a
+								v-if="oidcAuthentication"
+								class="button login"
+								@click="oidcRedirect()"
+							>
+								{{ t("Login") }}
+							</a>
 							<button
 							<button
+								v-else
 								class="button login"
 								class="button login"
 								@click="openModal('login')"
 								@click="openModal('login')"
 							>
 							>

+ 9 - 30
frontend/src/pages/ResetPassword.vue

@@ -16,10 +16,6 @@ const InputHelpBox = defineAsyncComponent(
 	() => import("@/components/InputHelpBox.vue")
 	() => import("@/components/InputHelpBox.vue")
 );
 );
 
 
-const props = defineProps({
-	mode: { type: String, enum: ["reset", "set"], default: "reset" }
-});
-
 const userAuthStore = useUserAuthStore();
 const userAuthStore = useUserAuthStore();
 const { email: accountEmail } = storeToRefs(userAuthStore);
 const { email: accountEmail } = storeToRefs(userAuthStore);
 
 
@@ -89,13 +85,6 @@ const submitEmail = () => {
 
 
 	inputs.value.email.hasBeenSentAlready = false;
 	inputs.value.email.hasBeenSentAlready = false;
 
 
-	if (props.mode === "set") {
-		return socket.dispatch("users.requestPassword", res => {
-			new Toast(res.message);
-			if (res.status === "success") step.value = 2;
-		});
-	}
-
 	return socket.dispatch(
 	return socket.dispatch(
 		"users.requestPasswordReset",
 		"users.requestPasswordReset",
 		inputs.value.email.value,
 		inputs.value.email.value,
@@ -112,16 +101,10 @@ const submitEmail = () => {
 const verifyCode = () => {
 const verifyCode = () => {
 	if (!code.value) return new Toast("Code cannot be empty");
 	if (!code.value) return new Toast("Code cannot be empty");
 
 
-	return socket.dispatch(
-		props.mode === "set"
-			? "users.verifyPasswordCode"
-			: "users.verifyPasswordResetCode",
-		code.value,
-		res => {
-			new Toast(res.message);
-			if (res.status === "success") step.value = 3;
-		}
-	);
+	return socket.dispatch("users.verifyPasswordResetCode", code.value, res => {
+		new Toast(res.message);
+		if (res.status === "success") step.value = 3;
+	});
 };
 };
 
 
 const changePassword = () => {
 const changePassword = () => {
@@ -132,9 +115,7 @@ const changePassword = () => {
 		return new Toast("Please enter a valid password.");
 		return new Toast("Please enter a valid password.");
 
 
 	return socket.dispatch(
 	return socket.dispatch(
-		props.mode === "set"
-			? "users.changePasswordWithCode"
-			: "users.changePasswordWithResetCode",
+		"users.changePasswordWithResetCode",
 		code.value,
 		code.value,
 		inputs.value.password.value,
 		inputs.value.password.value,
 		res => {
 		res => {
@@ -199,14 +180,12 @@ onMounted(() => {
 
 
 <template>
 <template>
 	<div>
 	<div>
-		<page-metadata
-			:title="mode === 'reset' ? 'Reset password' : 'Set password'"
-		/>
+		<page-metadata title="Reset password" />
 		<main-header />
 		<main-header />
 		<div class="container">
 		<div class="container">
 			<div class="content-wrapper">
 			<div class="content-wrapper">
 				<h1 id="title" class="has-text-centered page-title">
 				<h1 id="title" class="has-text-centered page-title">
-					{{ mode === "reset" ? "Reset" : "Set" }} your password
+					Reset your password
 				</h1>
 				</h1>
 
 
 				<div id="steps">
 				<div id="steps">
@@ -466,7 +445,7 @@ onMounted(() => {
 								<i class="material-icons success-icon"
 								<i class="material-icons success-icon"
 									>check_circle</i
 									>check_circle</i
 								>
 								>
-								<h2>Password successfully {{ mode }}</h2>
+								<h2>Password successfully reset</h2>
 								<router-link
 								<router-link
 									class="button is-dark"
 									class="button is-dark"
 									to="/settings"
 									to="/settings"
@@ -483,7 +462,7 @@ onMounted(() => {
 							>
 							>
 								<i class="material-icons error-icon">error</i>
 								<i class="material-icons error-icon">error</i>
 								<h2>
 								<h2>
-									Password {{ mode }} failed, please try again
+									Password reset failed, please try again
 									later
 									later
 								</h2>
 								</h2>
 								<router-link
 								<router-link

+ 10 - 23
frontend/src/pages/Settings/Tabs/Account.vue

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { defineAsyncComponent, ref, watch, reactive, onMounted } from "vue";
-import { useRoute } from "vue-router";
+import { defineAsyncComponent, ref, watch, reactive } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { storeToRefs } from "pinia";
 import { useSettingsStore } from "@/stores/settings";
 import { useSettingsStore } from "@/stores/settings";
@@ -21,14 +20,13 @@ const QuickConfirm = defineAsyncComponent(
 
 
 const settingsStore = useSettingsStore();
 const settingsStore = useSettingsStore();
 const userAuthStore = useUserAuthStore();
 const userAuthStore = useUserAuthStore();
-const route = useRoute();
 
 
 const { socket } = useWebsocketsStore();
 const { socket } = useWebsocketsStore();
 
 
 const saveButton = ref();
 const saveButton = ref();
 
 
 const { userId } = storeToRefs(userAuthStore);
 const { userId } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
+const { originalUser, modifiedUser } = storeToRefs(settingsStore);
 
 
 const validation = reactive({
 const validation = reactive({
 	username: {
 	username: {
@@ -52,7 +50,7 @@ const onInput = inputName => {
 };
 };
 
 
 const changeEmail = () => {
 const changeEmail = () => {
-	const email = modifiedUser.email.address;
+	const email = modifiedUser.value.email.address;
 	if (!_validation.isLength(email, 3, 254))
 	if (!_validation.isLength(email, 3, 254))
 		return new Toast("Email must have between 3 and 254 characters.");
 		return new Toast("Email must have between 3 and 254 characters.");
 	if (
 	if (
@@ -81,7 +79,7 @@ const changeEmail = () => {
 };
 };
 
 
 const changeUsername = () => {
 const changeUsername = () => {
-	const { username } = modifiedUser;
+	const { username } = modifiedUser.value;
 
 
 	if (!_validation.isLength(username, 2, 32))
 	if (!_validation.isLength(username, 2, 32))
 		return new Toast("Username must have between 2 and 32 characters.");
 		return new Toast("Username must have between 2 and 32 characters.");
@@ -121,9 +119,10 @@ const changeUsername = () => {
 };
 };
 
 
 const saveChanges = () => {
 const saveChanges = () => {
-	const usernameChanged = modifiedUser.username !== originalUser.username;
+	const usernameChanged =
+		modifiedUser.value.username !== originalUser.value.username;
 	const emailAddressChanged =
 	const emailAddressChanged =
-		modifiedUser.email.address !== originalUser.email.address;
+		modifiedUser.value.email.address !== originalUser.value.email.address;
 
 
 	if (usernameChanged) changeUsername();
 	if (usernameChanged) changeUsername();
 
 
@@ -142,20 +141,8 @@ const removeActivities = () => {
 	});
 	});
 };
 };
 
 
-onMounted(() => {
-	if (
-		route.query.removeAccount === "relinked-github" &&
-		!localStorage.getItem("github_redirect")
-	) {
-		openModal({
-			modal: "removeAccount",
-			props: { githubLinkConfirmed: true }
-		});
-	}
-});
-
 watch(
 watch(
-	() => modifiedUser.username,
+	() => modifiedUser.value.username,
 	value => {
 	value => {
 		// const value = newModifiedUser.username;
 		// const value = newModifiedUser.username;
 
 
@@ -165,7 +152,7 @@ watch(
 			validation.username.valid = false;
 			validation.username.valid = false;
 		} else if (
 		} else if (
 			!_validation.regex.azAZ09_.test(value) &&
 			!_validation.regex.azAZ09_.test(value) &&
-			value !== originalUser.username // Sometimes a username pulled from GitHub won't succeed validation
+			value !== originalUser.value.username // Sometimes a username pulled from OIDC won't succeed validation
 		) {
 		) {
 			validation.username.message =
 			validation.username.message =
 				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _.";
 				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _.";
@@ -182,7 +169,7 @@ watch(
 );
 );
 
 
 watch(
 watch(
-	() => modifiedUser.email.address,
+	() => modifiedUser.value.email?.address,
 	value => {
 	value => {
 		// const value = newModifiedUser.email.address;
 		// const value = newModifiedUser.email.address;
 
 

+ 40 - 3
frontend/src/pages/Settings/Tabs/Preferences.vue

@@ -20,12 +20,17 @@ const localActivityLogPublic = ref(false);
 const localAnonymousSongRequests = ref(false);
 const localAnonymousSongRequests = ref(false);
 const localActivityWatch = ref(false);
 const localActivityWatch = ref(false);
 
 
+const localDefaultStationPrivacy = ref("private");
+const localDefaultPlaylistPrivacy = ref("public");
+
 const {
 const {
 	nightmode,
 	nightmode,
 	autoSkipDisliked,
 	autoSkipDisliked,
 	activityLogPublic,
 	activityLogPublic,
 	anonymousSongRequests,
 	anonymousSongRequests,
-	activityWatch
+	activityWatch,
+	defaultStationPrivacy,
+	defaultPlaylistPrivacy
 } = storeToRefs(userPreferencesStore);
 } = storeToRefs(userPreferencesStore);
 
 
 const saveChanges = () => {
 const saveChanges = () => {
@@ -34,7 +39,9 @@ const saveChanges = () => {
 		localAutoSkipDisliked.value === autoSkipDisliked.value &&
 		localAutoSkipDisliked.value === autoSkipDisliked.value &&
 		localActivityLogPublic.value === activityLogPublic.value &&
 		localActivityLogPublic.value === activityLogPublic.value &&
 		localAnonymousSongRequests.value === anonymousSongRequests.value &&
 		localAnonymousSongRequests.value === anonymousSongRequests.value &&
-		localActivityWatch.value === activityWatch.value
+		localActivityWatch.value === activityWatch.value &&
+		localDefaultStationPrivacy.value === defaultStationPrivacy.value &&
+		localDefaultPlaylistPrivacy.value === defaultPlaylistPrivacy.value
 	) {
 	) {
 		new Toast("Please make a change before saving.");
 		new Toast("Please make a change before saving.");
 
 
@@ -50,7 +57,9 @@ const saveChanges = () => {
 			autoSkipDisliked: localAutoSkipDisliked.value,
 			autoSkipDisliked: localAutoSkipDisliked.value,
 			activityLogPublic: localActivityLogPublic.value,
 			activityLogPublic: localActivityLogPublic.value,
 			anonymousSongRequests: localAnonymousSongRequests.value,
 			anonymousSongRequests: localAnonymousSongRequests.value,
-			activityWatch: localActivityWatch.value
+			activityWatch: localActivityWatch.value,
+			defaultStationPrivacy: localDefaultStationPrivacy.value,
+			defaultPlaylistPrivacy: localDefaultPlaylistPrivacy.value
 		},
 		},
 		res => {
 		res => {
 			if (res.status !== "success") {
 			if (res.status !== "success") {
@@ -76,6 +85,10 @@ onMounted(() => {
 				localAnonymousSongRequests.value =
 				localAnonymousSongRequests.value =
 					preferences.anonymousSongRequests;
 					preferences.anonymousSongRequests;
 				localActivityWatch.value = preferences.activityWatch;
 				localActivityWatch.value = preferences.activityWatch;
+				localDefaultStationPrivacy.value =
+					preferences.defaultStationPrivacy;
+				localDefaultPlaylistPrivacy.value =
+					preferences.defaultPlaylistPrivacy;
 			}
 			}
 		});
 		});
 	});
 	});
@@ -98,6 +111,14 @@ onMounted(() => {
 
 
 		if (preferences.activityWatch !== undefined)
 		if (preferences.activityWatch !== undefined)
 			localActivityWatch.value = preferences.activityWatch;
 			localActivityWatch.value = preferences.activityWatch;
+
+		if (preferences.defaultStationPrivacy !== undefined)
+			localDefaultStationPrivacy.value =
+				preferences.defaultStationPrivacy;
+
+		if (preferences.defaultPlaylistPrivacy !== undefined)
+			localDefaultPlaylistPrivacy.value =
+				preferences.defaultPlaylistPrivacy;
 	});
 	});
 });
 });
 </script>
 </script>
@@ -187,6 +208,22 @@ onMounted(() => {
 			</label>
 			</label>
 		</p>
 		</p>
 
 
+		<label class="label">Default station privacy</label>
+		<div class="control select">
+			<select v-model="localDefaultStationPrivacy">
+				<option value="public">Public</option>
+				<option value="unlisted">Unlisted</option>
+				<option value="private">Private</option>
+			</select>
+		</div>
+
+		<label class="label">Default playlist privacy</label>
+		<div class="control select">
+			<select v-model="localDefaultPlaylistPrivacy">
+				<option value="public">Public</option>
+				<option value="private">Private</option>
+			</select>
+		</div>
 		<SaveButton ref="saveButton" @clicked="saveChanges()" />
 		<SaveButton ref="saveButton" @clicked="saveChanges()" />
 	</div>
 	</div>
 </template>
 </template>

+ 14 - 11
frontend/src/pages/Settings/Tabs/Profile.vue

@@ -22,13 +22,15 @@ const { socket } = useWebsocketsStore();
 const saveButton = ref();
 const saveButton = ref();
 
 
 const { userId } = storeToRefs(userAuthStore);
 const { userId } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
+const { originalUser, modifiedUser } = storeToRefs(settingsStore);
 
 
 const { updateOriginalUser } = settingsStore;
 const { updateOriginalUser } = settingsStore;
 
 
 const changeName = () => {
 const changeName = () => {
-	modifiedUser.name = modifiedUser.name.replaceAll(/ +/g, " ").trim();
-	const { name } = modifiedUser;
+	modifiedUser.value.name = modifiedUser.value.name
+		.replaceAll(/ +/g, " ")
+		.trim();
+	const { name } = modifiedUser.value;
 
 
 	if (!validation.isLength(name, 1, 64))
 	if (!validation.isLength(name, 1, 64))
 		return new Toast("Name must have between 1 and 64 characters.");
 		return new Toast("Name must have between 1 and 64 characters.");
@@ -62,7 +64,7 @@ const changeName = () => {
 };
 };
 
 
 const changeLocation = () => {
 const changeLocation = () => {
-	const { location } = modifiedUser;
+	const { location } = modifiedUser.value;
 
 
 	if (!validation.isLength(location, 0, 50))
 	if (!validation.isLength(location, 0, 50))
 		return new Toast("Location must have between 0 and 50 characters.");
 		return new Toast("Location must have between 0 and 50 characters.");
@@ -92,7 +94,7 @@ const changeLocation = () => {
 };
 };
 
 
 const changeBio = () => {
 const changeBio = () => {
-	const { bio } = modifiedUser;
+	const { bio } = modifiedUser.value;
 
 
 	if (!validation.isLength(bio, 0, 200))
 	if (!validation.isLength(bio, 0, 200))
 		return new Toast("Bio must have between 0 and 200 characters.");
 		return new Toast("Bio must have between 0 and 200 characters.");
@@ -117,7 +119,7 @@ const changeBio = () => {
 };
 };
 
 
 const changeAvatar = () => {
 const changeAvatar = () => {
-	const { avatar } = modifiedUser;
+	const { avatar } = modifiedUser.value;
 
 
 	saveButton.value.status = "disabled";
 	saveButton.value.status = "disabled";
 
 
@@ -139,12 +141,13 @@ const changeAvatar = () => {
 };
 };
 
 
 const saveChanges = () => {
 const saveChanges = () => {
-	const nameChanged = modifiedUser.name !== originalUser.name;
-	const locationChanged = modifiedUser.location !== originalUser.location;
-	const bioChanged = modifiedUser.bio !== originalUser.bio;
+	const nameChanged = modifiedUser.value.name !== originalUser.value.name;
+	const locationChanged =
+		modifiedUser.value.location !== originalUser.value.location;
+	const bioChanged = modifiedUser.value.bio !== originalUser.value.bio;
 	const avatarChanged =
 	const avatarChanged =
-		modifiedUser.avatar.type !== originalUser.avatar.type ||
-		modifiedUser.avatar.color !== originalUser.avatar.color;
+		modifiedUser.value.avatar.type !== originalUser.value.avatar.type ||
+		modifiedUser.value.avatar.color !== originalUser.value.avatar.color;
 
 
 	if (nameChanged) changeName();
 	if (nameChanged) changeName();
 	if (locationChanged) changeLocation();
 	if (locationChanged) changeLocation();

+ 5 - 82
frontend/src/pages/Settings/Tabs/Security.vue

@@ -3,7 +3,6 @@ import { defineAsyncComponent, ref, watch, reactive } from "vue";
 import Toast from "toasters";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useConfigStore } from "@/stores/config";
-import { useSettingsStore } from "@/stores/settings";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserAuthStore } from "@/stores/userAuth";
 import _validation from "@/validation";
 import _validation from "@/validation";
@@ -16,8 +15,7 @@ const QuickConfirm = defineAsyncComponent(
 );
 );
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { githubAuthentication, sitename } = storeToRefs(configStore);
-const settingsStore = useSettingsStore();
+const { oidcAuthentication } = storeToRefs(configStore);
 const userAuthStore = useUserAuthStore();
 const userAuthStore = useUserAuthStore();
 
 
 const { socket } = useWebsocketsStore();
 const { socket } = useWebsocketsStore();
@@ -40,7 +38,6 @@ const validation = reactive({
 const newPassword = ref();
 const newPassword = ref();
 const oldPassword = ref();
 const oldPassword = ref();
 
 
-const { isPasswordLinked, isGithubLinked } = settingsStore;
 const { userId } = storeToRefs(userAuthStore);
 const { userId } = storeToRefs(userAuthStore);
 
 
 const togglePasswordVisibility = refName => {
 const togglePasswordVisibility = refName => {
@@ -57,6 +54,8 @@ const onInput = inputName => {
 	validation[inputName].entered = true;
 	validation[inputName].entered = true;
 };
 };
 const changePassword = () => {
 const changePassword = () => {
+	if (oidcAuthentication.value) return null;
+
 	const newPassword = validation.newPassword.value;
 	const newPassword = validation.newPassword.value;
 
 
 	if (validation.oldPassword.value === "")
 	if (validation.oldPassword.value === "")
@@ -80,16 +79,6 @@ const changePassword = () => {
 		}
 		}
 	);
 	);
 };
 };
-const unlinkPassword = () => {
-	socket.dispatch("users.unlinkPassword", res => {
-		new Toast(res.message);
-	});
-};
-const unlinkGitHub = () => {
-	socket.dispatch("users.unlinkGitHub", res => {
-		new Toast(res.message);
-	});
-};
 const removeSessions = () => {
 const removeSessions = () => {
 	socket.dispatch(`users.removeSessions`, userId.value, res => {
 	socket.dispatch(`users.removeSessions`, userId.value, res => {
 		new Toast(res.message);
 		new Toast(res.message);
@@ -115,7 +104,7 @@ watch(validation, newValidation => {
 
 
 <template>
 <template>
 	<div class="content security-tab">
 	<div class="content security-tab">
-		<div v-if="isPasswordLinked">
+		<template v-if="!oidcAuthentication">
 			<h4 class="section-title">Change password</h4>
 			<h4 class="section-title">Change password</h4>
 
 
 			<p class="section-description">
 			<p class="section-description">
@@ -195,73 +184,7 @@ watch(validation, newValidation => {
 			</p>
 			</p>
 
 
 			<div class="section-margin-bottom" />
 			<div class="section-margin-bottom" />
-		</div>
-
-		<div v-if="!isPasswordLinked">
-			<h4 class="section-title">Add a password</h4>
-			<p class="section-description">
-				Add a password, as an alternative to signing in with GitHub
-			</p>
-
-			<hr class="section-horizontal-rule" />
-
-			<router-link to="/set_password" class="button is-default"
-				><i class="material-icons icon-with-button">create</i>Set
-				Password
-			</router-link>
-
-			<div class="section-margin-bottom" />
-		</div>
-
-		<div v-if="!isGithubLinked && githubAuthentication">
-			<h4 class="section-title">Link your GitHub account</h4>
-			<p class="section-description">
-				Link your {{ sitename }} account with GitHub
-			</p>
-
-			<hr class="section-horizontal-rule" />
-
-			<a
-				class="button is-github"
-				:href="`${configStore.urls.api}/auth/github/link`"
-			>
-				<div class="icon">
-					<img class="invert" src="/assets/social/github.svg" />
-				</div>
-				&nbsp; Link GitHub to account
-			</a>
-
-			<div class="section-margin-bottom" />
-		</div>
-
-		<div v-if="isPasswordLinked && isGithubLinked">
-			<h4 class="section-title">Remove login methods</h4>
-			<p class="section-description">
-				Remove your password as a login method or unlink GitHub
-			</p>
-
-			<hr class="section-horizontal-rule" />
-
-			<div class="row">
-				<quick-confirm
-					v-if="isPasswordLinked && githubAuthentication"
-					@confirm="unlinkPassword()"
-				>
-					<a class="button is-danger">
-						<i class="material-icons icon-with-button">close</i>
-						Remove password
-					</a>
-				</quick-confirm>
-				<quick-confirm v-if="isGithubLinked" @confirm="unlinkGitHub()">
-					<a class="button is-danger">
-						<i class="material-icons icon-with-button">link_off</i>
-						Remove GitHub from account
-					</a>
-				</quick-confirm>
-			</div>
-
-			<div class="section-margin-bottom" />
-		</div>
+		</template>
 
 
 		<div>
 		<div>
 			<h4 class="section-title">Log out everywhere</h4>
 			<h4 class="section-title">Log out everywhere</h4>

+ 7 - 22
frontend/src/pages/Settings/index.vue

@@ -58,27 +58,6 @@ onMounted(() => {
 			value: true
 			value: true
 		})
 		})
 	);
 	);
-
-	socket.on("event:user.password.unlinked", () =>
-		updateOriginalUser({
-			property: "password",
-			value: false
-		})
-	);
-
-	socket.on("event:user.github.linked", () =>
-		updateOriginalUser({
-			property: "github",
-			value: true
-		})
-	);
-
-	socket.on("event:user.github.unlinked", () =>
-		updateOriginalUser({
-			property: "github",
-			value: false
-		})
-	);
 });
 });
 </script>
 </script>
 
 
@@ -214,13 +193,19 @@ onMounted(() => {
 		margin: 24px 0;
 		margin: 24px 0;
 		height: fit-content;
 		height: fit-content;
 
 
-		.control:not(:first-of-type) {
+		.control.checkbox-control:not(:first-of-type),
+		.control.input-with-label {
 			margin: 10px 0;
 			margin: 10px 0;
 		}
 		}
 
 
+		.select {
+			margin: 0 !important;
+		}
+
 		label {
 		label {
 			font-size: 14px;
 			font-size: 14px;
 			color: var(--dark-grey-2);
 			color: var(--dark-grey-2);
+			font-weight: 500;
 		}
 		}
 
 
 		textarea {
 		textarea {

+ 1 - 1
frontend/src/pages/Station/Sidebar/Users.vue

@@ -67,7 +67,7 @@ const copyToClipboard = async () => {
 		await navigator.clipboard.writeText(
 		await navigator.clipboard.writeText(
 			configStore.urls.client + route.fullPath
 			configStore.urls.client + route.fullPath
 		);
 		);
-	} catch (err) {
+	} catch {
 		new Toast("Failed to copy to clipboard.");
 		new Toast("Failed to copy to clipboard.");
 	}
 	}
 };
 };

+ 4 - 2
frontend/src/stores/config.ts

@@ -8,7 +8,7 @@ export const useConfigStore = defineStore("config", {
 			enabled: boolean;
 			enabled: boolean;
 			key: string;
 			key: string;
 		};
 		};
-		githubAuthentication: boolean;
+		oidcAuthentication: boolean;
 		messages: Record<string, string>;
 		messages: Record<string, string>;
 		christmas: boolean;
 		christmas: boolean;
 		footerLinks: Record<string, string | boolean>;
 		footerLinks: Record<string, string | boolean>;
@@ -17,6 +17,7 @@ export const useConfigStore = defineStore("config", {
 		registrationDisabled: boolean;
 		registrationDisabled: boolean;
 		mailEnabled: boolean;
 		mailEnabled: boolean;
 		discogsEnabled: boolean;
 		discogsEnabled: boolean;
+		passwordResetEnabled: boolean;
 		experimental: {
 		experimental: {
 			changable_listen_mode: string[] | boolean;
 			changable_listen_mode: string[] | boolean;
 			media_session: boolean;
 			media_session: boolean;
@@ -32,7 +33,7 @@ export const useConfigStore = defineStore("config", {
 			enabled: false,
 			enabled: false,
 			key: ""
 			key: ""
 		},
 		},
-		githubAuthentication: false,
+		oidcAuthentication: false,
 		messages: {
 		messages: {
 			accountRemoval:
 			accountRemoval:
 				"Your account will be deactivated instantly and your data will shortly be deleted by an admin."
 				"Your account will be deactivated instantly and your data will shortly be deleted by an admin."
@@ -44,6 +45,7 @@ export const useConfigStore = defineStore("config", {
 		registrationDisabled: false,
 		registrationDisabled: false,
 		mailEnabled: true,
 		mailEnabled: true,
 		discogsEnabled: true,
 		discogsEnabled: true,
+		passwordResetEnabled: true,
 		experimental: {
 		experimental: {
 			changable_listen_mode: [],
 			changable_listen_mode: [],
 			media_session: false,
 			media_session: false,

+ 0 - 4
frontend/src/stores/settings.ts

@@ -28,9 +28,5 @@ export const useSettingsStore = defineStore("settings", {
 			this.originalUser = user;
 			this.originalUser = user;
 			this.modifiedUser = JSON.parse(JSON.stringify(user));
 			this.modifiedUser = JSON.parse(JSON.stringify(user));
 		}
 		}
-	},
-	getters: {
-		isGithubLinked: state => state.originalUser.github,
-		isPasswordLinked: state => state.originalUser.password
 	}
 	}
 });
 });

+ 1 - 1
frontend/src/stores/station.ts

@@ -93,7 +93,7 @@ export const useStationStore = defineStore("station", {
 			const {
 			const {
 				autorequestDisallowRecentlyPlayedEnabled,
 				autorequestDisallowRecentlyPlayedEnabled,
 				autorequestDisallowRecentlyPlayedNumber
 				autorequestDisallowRecentlyPlayedNumber
-			} = this.station.requests;
+			} = this.station?.requests ?? {};
 
 
 			// If the station is set to disallow recently played songs, and station history is enabled, exclude the last X history songs
 			// If the station is set to disallow recently played songs, and station history is enabled, exclude the last X history songs
 			if (
 			if (

+ 11 - 1
frontend/src/stores/userPreferences.ts

@@ -7,12 +7,16 @@ export const useUserPreferencesStore = defineStore("userPreferences", {
 		activityLogPublic: boolean;
 		activityLogPublic: boolean;
 		anonymousSongRequests: boolean;
 		anonymousSongRequests: boolean;
 		activityWatch: boolean;
 		activityWatch: boolean;
+		defaultStationPrivacy: "public" | "unlisted" | "private";
+		defaultPlaylistPrivacy: "public" | "private";
 	} => ({
 	} => ({
 		nightmode: false,
 		nightmode: false,
 		autoSkipDisliked: true,
 		autoSkipDisliked: true,
 		activityLogPublic: false,
 		activityLogPublic: false,
 		anonymousSongRequests: false,
 		anonymousSongRequests: false,
-		activityWatch: false
+		activityWatch: false,
+		defaultStationPrivacy: "private",
+		defaultPlaylistPrivacy: "public"
 	}),
 	}),
 	actions: {
 	actions: {
 		changeNightmode(nightmode) {
 		changeNightmode(nightmode) {
@@ -30,6 +34,12 @@ export const useUserPreferencesStore = defineStore("userPreferences", {
 		},
 		},
 		changeActivityWatch(activityWatch) {
 		changeActivityWatch(activityWatch) {
 			this.activityWatch = activityWatch;
 			this.activityWatch = activityWatch;
+		},
+		changeDefaultStationPrivacy(defaultStationPrivacy) {
+			this.defaultStationPrivacy = defaultStationPrivacy;
+		},
+		changeDefaultPlaylistPrivacy(defaultPlaylistPrivacy) {
+			this.defaultPlaylistPrivacy = defaultPlaylistPrivacy;
 		}
 		}
 	}
 	}
 });
 });

+ 5 - 3
frontend/src/types/user.ts

@@ -24,13 +24,13 @@ export interface User {
 				expires: Date;
 				expires: Date;
 			};
 			};
 		};
 		};
-		github?: {
-			id: number;
+		oidc?: {
+			sub: string;
 			access_token: string;
 			access_token: string;
 		};
 		};
 	};
 	};
 	password?: boolean;
 	password?: boolean;
-	github?: boolean;
+	oidc?: boolean;
 	statistics: {
 	statistics: {
 		songsRequested: number;
 		songsRequested: number;
 	};
 	};
@@ -48,5 +48,7 @@ export interface User {
 		activityLogPublic: boolean;
 		activityLogPublic: boolean;
 		anonymousSongRequests: boolean;
 		anonymousSongRequests: boolean;
 		activityWatch: boolean;
 		activityWatch: boolean;
+		defaultStationPrivacy: "public" | "unlisted" | "private";
+		defaultPlaylistPrivacy: "public" | "private";
 	};
 	};
 }
 }

+ 2 - 2
frontend/vite.config.js

@@ -122,7 +122,7 @@ const htmlPlugin = () => ({
 
 
 let server = null;
 let server = null;
 
 
-if (process.env.FRONTEND_MODE === "development")
+if (process.env.APP_ENV === "development")
 	server = {
 	server = {
 		host: "0.0.0.0",
 		host: "0.0.0.0",
 		port: process.env.FRONTEND_DEV_PORT ?? 81,
 		port: process.env.FRONTEND_DEV_PORT ?? 81,
@@ -133,7 +133,7 @@ if (process.env.FRONTEND_MODE === "development")
 	};
 	};
 
 
 export default {
 export default {
-	mode: process.env.FRONTEND_MODE,
+	mode: process.env.APP_ENV,
 	root: "src",
 	root: "src",
 	publicDir: "../dist",
 	publicDir: "../dist",
 	base: "/",
 	base: "/",

+ 496 - 420
musare.sh

@@ -1,36 +1,50 @@
 #!/bin/bash
 #!/bin/bash
 
 
+set -e
+
 export PATH=/usr/local/bin:/usr/bin:/bin
 export PATH=/usr/local/bin:/usr/bin:/bin
 
 
+# Color variables
 CYAN='\033[33;36m';
 CYAN='\033[33;36m';
 RED='\033[0;31m'
 RED='\033[0;31m'
 YELLOW='\033[0;93m'
 YELLOW='\033[0;93m'
 GREEN='\033[0;32m'
 GREEN='\033[0;32m'
 NC='\033[0m'
 NC='\033[0m'
 
 
+# Print provided formatted error and exit with code (default 1)
+throw()
+{
+    echo -e "${RED}${1}${NC}"
+    exit "${2:-1}"
+}
+
+# Navigate to repository
 scriptLocation=$(dirname -- "$(readlink -fn -- "$0"; echo x)")
 scriptLocation=$(dirname -- "$(readlink -fn -- "$0"; echo x)")
-cd "${scriptLocation%x}" || exit 1
-
-if [[ -f .env ]]; then
-    # shellcheck disable=SC1091
-    source .env
-else
-    echo -e "${RED}Error: .env does not exist${NC}"
-    exit 2
+cd "${scriptLocation%x}"
+
+# Import environment variables
+if [[ ! -f .env ]]; then
+    throw "Error: .env does not exist" 2
 fi
 fi
+# shellcheck disable=SC1091
+source .env
 
 
-if [[ -z ${DOCKER_COMMAND} ]]; then
-    DOCKER_COMMAND="docker"
-elif [[ ${DOCKER_COMMAND} != "docker" && ${DOCKER_COMMAND} != "podman" ]]; then
-    echo -e "${RED}Error: Invalid DOCKER_COMMAND${NC}"
-    exit 1
+# Define docker command
+docker="${DOCKER_COMMAND:-docker}"
+if [[ ${docker} != "docker" && ${docker} != "podman" ]]; then
+    throw "Error: Invalid DOCKER_COMMAND"
 fi
 fi
 
 
-docker="${DOCKER_COMMAND}"
+set +e
+
+# Check if docker is installed
 ${docker} --version > /dev/null 2>&1
 ${docker} --version > /dev/null 2>&1
 dockerInstalled=$?
 dockerInstalled=$?
 
 
+# Define docker compose command
 dockerCompose="${docker} compose"
 dockerCompose="${docker} compose"
+
+# Check if docker compose is installed
 ${dockerCompose} version > /dev/null 2>&1
 ${dockerCompose} version > /dev/null 2>&1
 composeInstalled=$?
 composeInstalled=$?
 if [[ ${composeInstalled} -gt 0 ]]; then
 if [[ ${composeInstalled} -gt 0 ]]; then
@@ -39,31 +53,44 @@ if [[ ${composeInstalled} -gt 0 ]]; then
     composeInstalled=$?
     composeInstalled=$?
 fi
 fi
 
 
+set -e
+
+# Exit if docker and/or docker compose is not installed
 if [[ ${dockerInstalled} -gt 0 || ${composeInstalled} -gt 0 ]]; then
 if [[ ${dockerInstalled} -gt 0 || ${composeInstalled} -gt 0 ]]; then
     if [[ ${dockerInstalled} -eq 0 && ${composeInstalled} -gt 0 ]]; then
     if [[ ${dockerInstalled} -eq 0 && ${composeInstalled} -gt 0 ]]; then
-        echo -e "${RED}Error: ${dockerCompose} not installed.${NC}"
-    elif [[ ${dockerInstalled} -gt 0 && ${composeInstalled} -eq 0 ]]; then
-        echo -e "${RED}Error: ${docker} not installed.${NC}"
-    else
-        echo -e "${RED}Error: ${docker} and ${dockerCompose} not installed.${NC}"
+        throw "Error: ${dockerCompose} not installed."
+    fi
+
+    if [[ ${dockerInstalled} -gt 0 && ${composeInstalled} -eq 0 ]]; then
+        throw "Error: ${docker} not installed."
     fi
     fi
-    exit 1
+
+    throw "Error: ${docker} and ${dockerCompose} not installed."
 fi
 fi
 
 
-composeFiles="-f docker-compose.yml"
-if [[ ${CONTAINER_MODE} == "development" ]]; then
-    composeFiles="${composeFiles} -f docker-compose.dev.yml"
+# Add docker compose file arguments to command
+composeFiles="-f compose.yml"
+if [[ ${APP_ENV} == "development" ]]; then
+    composeFiles="${composeFiles} -f compose.dev.yml"
 fi
 fi
-if [[ -f docker-compose.override.yml ]]; then
+if [[ ${CONTAINER_MODE} == "local" ]]; then
+    composeFiles="${composeFiles} -f compose.local.yml"
+fi
+if [[ -f compose.override.yml ]]; then
+    composeFiles="${composeFiles} -f compose.override.yml"
+elif [[ -f docker-compose.override.yml ]]; then
     composeFiles="${composeFiles} -f docker-compose.override.yml"
     composeFiles="${composeFiles} -f docker-compose.override.yml"
 fi
 fi
 dockerCompose="${dockerCompose} ${composeFiles}"
 dockerCompose="${dockerCompose} ${composeFiles}"
 
 
+# Parse services from arguments string
 handleServices()
 handleServices()
 {
 {
+    # shellcheck disable=SC2206
     validServices=($1)
     validServices=($1)
     servicesArray=()
     servicesArray=()
     invalidServices=false
     invalidServices=false
+
     for x in "${@:2}"; do
     for x in "${@:2}"; do
         if [[ ${validServices[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
         if [[ ${validServices[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
             if ! [[ ${servicesArray[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
             if ! [[ ${servicesArray[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
@@ -77,6 +104,7 @@ handleServices()
             fi
             fi
         fi
         fi
     done
     done
+
     if [[ $invalidServices == false && ${#servicesArray[@]} -gt 0 ]]; then
     if [[ $invalidServices == false && ${#servicesArray[@]} -gt 0 ]]; then
         echo "1|${servicesArray[*]}"
         echo "1|${servicesArray[*]}"
     elif [[ $invalidServices == false ]]; then
     elif [[ $invalidServices == false ]]; then
@@ -86,81 +114,451 @@ handleServices()
     fi
     fi
 }
 }
 
 
+# Execute a docker command
 runDockerCommand()
 runDockerCommand()
 {
 {
     validCommands=(start stop restart pull build ps logs)
     validCommands=(start stop restart pull build ps logs)
-    if [[ ${validCommands[*]} =~ (^|[[:space:]])"$2"($|[[:space:]]) ]]; then
-        servicesString=$(handleServices "backend frontend mongo redis" "${@:3}")
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" ]]; then
-                servicesString=""
-                pullServices="mongo redis"
-                buildServices="backend frontend"
+    if ! [[ ${validCommands[*]} =~ (^|[[:space:]])"$2"($|[[:space:]]) ]]; then
+        throw "Error: Invalid runDockerCommand input"
+    fi
+
+    servicesString=$(handleServices "backend frontend mongo redis" "${@:3}")
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]"
+    fi
+
+    if [[ ${servicesString:2:4} == "all" ]]; then
+        servicesString=""
+        pullServices="mongo redis"
+        buildServices="backend frontend"
+    else
+        servicesString=${servicesString:2}
+        pullArray=()
+        buildArray=()
+
+        if [[ "${servicesString}" == *mongo* ]]; then
+            pullArray+=("mongo")
+        fi
+
+        if [[ "${servicesString}" == *redis* ]]; then
+            pullArray+=("redis")
+        fi
+
+        if [[ "${servicesString}" == *backend* ]]; then
+            buildArray+=("backend")
+        fi
+
+        if [[ "${servicesString}" == *frontend* ]]; then
+            buildArray+=("frontend")
+        fi
+
+        pullServices="${pullArray[*]}"
+        buildServices="${buildArray[*]}"
+    fi
+
+    if [[ ${2} == "stop" || ${2} == "restart" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} stop ${servicesString}
+    fi
+
+    if [[ ${2} == "start" || ${2} == "restart" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} up -d ${servicesString}
+    fi
+
+    if [[ ${2} == "pull" && ${pullServices} != "" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} pull ${pullServices}
+    fi
+
+    if [[ ${2} == "build" && ${buildServices} != "" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} build --pull ${buildServices}
+    fi
+
+    if [[ ${2} == "ps" || ${2} == "logs" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} "${2}" ${servicesString}
+    fi
+}
+
+# Get docker container id
+getContainerId()
+{
+    if [[ $docker == "docker" ]]; then
+        containerId=$(${dockerCompose} ps -q "${1}")
+    else
+        containerId=$(${dockerCompose} ps \
+            | sed '0,/CONTAINER/d' \
+            | awk "/${1}/ {print \$1;exit}")
+    fi
+    echo "${containerId}"
+}
+
+# Reset services
+handleReset()
+{
+    servicesString=$(handleServices "backend frontend mongo redis" "${@:2}")
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]"
+    fi
+
+    confirmMessage="${GREEN}Are you sure you want to reset all data"
+    if [[ ${servicesString:2:4} != "all" ]]; then
+        confirmMessage="${confirmMessage} for $(echo "${servicesString:2}" | tr ' ' ',')"
+    fi
+    echo -e "${confirmMessage}? ${YELLOW}[y,n]: ${NC}"
+
+    read -r confirm
+    if [[ "${confirm}" != y* ]]; then
+        throw "Cancelled reset"
+    fi
+
+    if [[ ${servicesString:2:4} == "all" ]]; then
+        runDockerCommand "$(basename "$0") $1" stop
+        ${dockerCompose} rm -v --force
+    else
+        # shellcheck disable=SC2086
+        runDockerCommand "$(basename "$0") $1" stop ${servicesString:2}
+        # shellcheck disable=SC2086
+        ${dockerCompose} rm -v --force ${servicesString:2}
+    fi
+}
+
+# Attach to service in docker container
+attachContainer()
+{
+    containerId=$(getContainerId "${2}")
+    if [[ -z $containerId ]]; then
+        throw "Error: ${2} offline, please start to attach."
+    fi
+
+    case $2 in
+        backend)
+            echo -e "${YELLOW}Detach with CTRL+P+Q${NC}"
+            ${docker} attach "$containerId"
+            ;;
+
+        mongo)
+            MONGO_VERSION_INT=${MONGO_VERSION:0:1}
+            echo -e "${YELLOW}Detach with CTRL+D${NC}"
+            if [[ $MONGO_VERSION_INT -ge 5 ]]; then
+                ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry()" --shell
             else
             else
-                servicesString=${servicesString:2}
-                pullArray=()
-                buildArray=()
-                if [[ "${servicesString}" == *mongo* ]]; then
-                    pullArray+=("mongo")
-                fi
-                if [[ "${servicesString}" == *redis* ]]; then
-                    pullArray+=("redis")
-                fi
-                if [[ "${servicesString}" == *backend* ]]; then
-                    buildArray+=("backend")
-                fi
-                if [[ "${servicesString}" == *frontend* ]]; then
-                    buildArray+=("frontend")
-                fi
-                pullServices="${pullArray[*]}"
-                buildServices="${buildArray[*]}"
+                ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}"
             fi
             fi
+            ;;
 
 
-            if [[ ${2} == "stop" || ${2} == "restart" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} stop ${servicesString}
-            fi
-            if [[ ${2} == "start" || ${2} == "restart" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} up -d ${servicesString}
-            fi
-            if [[ ${2} == "pull" && ${pullServices} != "" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} "${2}" ${pullServices}
+        redis)
+            echo -e "${YELLOW}Detach with CTRL+C${NC}"
+            ${dockerCompose} exec redis redis-cli -a "${REDIS_PASSWORD}"
+            ;;
+
+        *)
+            throw "Invalid service ${2}\n${YELLOW}Usage: ${1} [backend, mongo, redis]"
+            ;;
+    esac
+}
+
+# Lint codebase, docs, scripts, etc
+handleLinting()
+{
+    set +e
+    # shellcheck disable=SC2001
+    services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}fix//g;t;q1" <<< "${@:2}")
+    fixFound=$?
+    if [[ $fixFound -eq 0 ]]; then
+        fix="--fix"
+        echo -e "${GREEN}Auto-fix enabled${NC}"
+    fi
+    # shellcheck disable=SC2001
+    services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}no-cache//g;t;q1" <<< "${services}")
+    noCacheFound=$?
+    cache="--cache"
+    if [[ $noCacheFound -eq 0 ]]; then
+        cache=""
+        echo -e "${YELLOW}ESlint cache disabled${NC}"
+    fi
+    set -e
+
+    # shellcheck disable=SC2068
+    servicesString=$(handleServices "backend frontend docs shell" ${services[@]})
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, docs, shell] [fix]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend lint...${NC}"
+        # shellcheck disable=SC2086
+        ${dockerCompose} exec -T frontend npm run lint -- ${cache} ${fix}
+        frontendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
+        echo -e "${CYAN}Running backend lint...${NC}"
+        # shellcheck disable=SC2086
+        ${dockerCompose} exec -T backend npm run lint -- ${cache} ${fix}
+        backendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *docs* ]]; then
+        echo -e "${CYAN}Running docs lint...${NC}"
+        # shellcheck disable=SC2086
+        ${docker} run --rm -v "${scriptLocation}":/workdir ghcr.io/igorshubovych/markdownlint-cli:latest ".wiki" "*.md" ${fix}
+        docsExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *shell* ]]; then
+        echo -e "${CYAN}Running shell lint...${NC}"
+        ${docker} run --rm -v "${scriptLocation}":/mnt koalaman/shellcheck:stable ./*.sh ./**/*.sh
+        shellExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 || ${docsExitValue} -gt 0 || ${shellExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Validate typescript in services
+handleTypescript()
+{
+    set +e
+    # shellcheck disable=SC2001
+    services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}strict//g;t;q1" <<< "${@:2}")
+    strictFound=$?
+    if [[ $strictFound -eq 0 ]]; then
+        strict="--strict"
+        echo -e "${GREEN}Strict mode enabled${NC}"
+    fi
+    set -e
+
+    # shellcheck disable=SC2068
+    servicesString=$(handleServices "backend frontend" ${services[@]})
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend] [strict]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend typescript check...${NC}"
+        # shellcheck disable=SC2086
+        ${dockerCompose} exec -T frontend npm run typescript -- ${strict}
+        frontendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
+        echo -e "${CYAN}Running backend typescript check...${NC}"
+        # shellcheck disable=SC2086
+        ${dockerCompose} exec -T backend npm run typescript -- ${strict}
+        backendExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Execute automated tests in services
+handleTests()
+{
+    # shellcheck disable=SC2068
+    servicesString=$(handleServices "frontend" ${services[@]})
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [frontend]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend tests...${NC}"
+        ${dockerCompose} exec -T frontend npm run test -- --run
+        frontendExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Execute test coverage in services
+handleTestCoverage()
+{
+    servicesString=$(handleServices "frontend" "${@:2}")
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [frontend]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend test coverage report...${NC}"
+        ${dockerCompose} exec -T frontend npm run coverage
+        frontendExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Update Musare
+handleUpdate()
+{
+    musareshModified=$(git diff HEAD -- musare.sh)
+
+    git fetch
+
+    updated=$(git log --name-only --oneline HEAD..@\{u\})
+    if [[ ${updated} == "" ]]; then
+        echo -e "${GREEN}Already up to date${NC}"
+        exit 0
+    fi
+
+    set +e
+    breakingConfigChange=$(git rev-list "$(git rev-parse HEAD)" | grep d8b73be1de231821db34c677110b7b97e413451f)
+    set -e
+    if [[ -f backend/config/default.json && -z $breakingConfigChange ]]; then
+        throw "Configuration has breaking changes. Please rename or remove 'backend/config/default.json' and run the update command again to continue."
+    fi
+
+    set +e
+    musareshChange=$(echo "${updated}" | grep "musare.sh")
+    dbChange=$(echo "${updated}" | grep "backend/logic/db/schemas")
+    bcChange=$(echo "${updated}" | grep "backend/config/default.json")
+    envChange=$(echo "${updated}" | grep ".env.example")
+    set -e
+    if [[ ( $2 == "auto" && -z $dbChange && -z $bcChange && -z $musareshChange && -z $envChange ) || -z $2 ]]; then
+        if [[ -n $musareshChange && $(git diff @\{u\} -- musare.sh) != "" ]]; then
+            if [[ $musareshModified != "" ]]; then
+                throw "musare.sh has been modified, please reset these changes and run the update command again to continue."
+            else
+                git checkout @\{u\} -- musare.sh
+                throw "${YELLOW}musare.sh has been updated, please run the update command again to continue."
             fi
             fi
-            if [[ ${2} == "build" && ${buildServices} != "" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} "${2}" ${buildServices}
+        else
+            git pull
+            echo -e "${CYAN}Updating...${NC}"
+            runDockerCommand "$(basename "$0") $1" pull
+            runDockerCommand "$(basename "$0") $1" build
+            runDockerCommand "$(basename "$0") $1" restart
+            echo -e "${GREEN}Updated!${NC}"
+            if [[ -n $dbChange ]]; then
+                echo -e "${RED}Database schema has changed, please run migration!${NC}"
             fi
             fi
-            if [[ ${2} == "ps" || ${2} == "logs" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} "${2}" ${servicesString}
+            if [[ -n $bcChange ]]; then
+                echo -e "${RED}Backend config has changed, please update!${NC}"
             fi
             fi
-
-            exitValue=$?
-            if [[ ${exitValue} -gt 0 ]]; then
-                exit ${exitValue}
+            if [[ -n $envChange ]]; then
+                echo -e "${RED}Environment config has changed, please update!${NC}"
             fi
             fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]${NC}"
-            exit 1
         fi
         fi
+    elif [[ $2 == "auto" ]]; then
+        throw "Auto Update Failed! musare.sh, database and/or config has changed!"
+    fi
+}
+
+# Backup the database
+handleBackup()
+{
+    if [[ -z "${BACKUP_LOCATION}" ]]; then
+        backupLocation="${scriptLocation%x}/backups"
     else
     else
-        echo -e "${RED}Error: Invalid runDockerCommand input${NC}"
-        exit 1
+        backupLocation="${BACKUP_LOCATION%/}"
+    fi
+    if [[ ! -d "${backupLocation}" ]]; then
+        echo -e "${YELLOW}Creating backup directory at ${backupLocation}${NC}"
+        mkdir "${backupLocation}"
+    fi
+    if [[ -z "${BACKUP_NAME}" ]]; then
+        backupLocation="${backupLocation}/musare-$(date +"%Y-%m-%d-%s").dump"
+    else
+        backupLocation="${backupLocation}/${BACKUP_NAME}"
     fi
     fi
+    echo -e "${YELLOW}Creating backup at ${backupLocation}${NC}"
+    ${dockerCompose} exec -T mongo sh -c "mongodump --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} -d musare --archive" > "${backupLocation}"
 }
 }
 
 
-getContainerId()
+# Restore database from dump
+handleRestore()
 {
 {
-    if [[ ${DOCKER_COMMAND} == "docker" ]]; then
-        containerId=$(${dockerCompose} ps -q "${1}")
+    if [[ -z $2 ]]; then
+        echo -e "${GREEN}Please enter the full path of the dump you wish to restore: ${NC}"
+        read -r restoreFile
     else
     else
-        containerId=$(${dockerCompose} ps | sed '0,/CONTAINER/d' | awk "/${1}/ {print \$1;exit}")
+        restoreFile=$2
+    fi
+
+    if [[ -z ${restoreFile} ]]; then
+        throw "Error: no restore path given, cancelled restoration."
+    elif [[ -d ${restoreFile} ]]; then
+        throw "Error: restore path given is a directory, cancelled restoration."
+    elif [[ ! -f ${restoreFile} ]]; then
+        throw "Error: no file at restore path given, cancelled restoration."
+    else
+        ${dockerCompose} exec -T mongo sh -c "mongorestore --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} --archive" < "${restoreFile}"
     fi
     fi
-    echo "${containerId}"
 }
 }
 
 
+# Toggle user admin role
+handleAdmin()
+{
+    MONGO_VERSION_INT=${MONGO_VERSION:0:1}
+
+    case $2 in
+        add)
+            if [[ -z $3 ]]; then
+                echo -e "${GREEN}Please enter the username of the user you wish to make an admin: ${NC}"
+                read -r adminUser
+            else
+                adminUser=$3
+            fi
+            if [[ -z $adminUser ]]; then
+                throw "Error: Username for new admin not provided."
+            fi
+
+            if [[ $MONGO_VERSION_INT -ge 5 ]]; then
+                ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
+            else
+                ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
+            fi
+            ;;
+        remove)
+            if [[ -z $3 ]]; then
+                echo -e "${GREEN}Please enter the username of the user you wish to remove as admin: ${NC}"
+                read -r adminUser
+            else
+                adminUser=$3
+            fi
+            if [[ -z $adminUser ]]; then
+                throw "Error: Username for new admin not provided."
+            fi
+
+            if [[ $MONGO_VERSION_INT -ge 5 ]]; then
+                ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
+            else
+                ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
+            fi
+            ;;
+        *)
+            throw "Invalid command $2\n${YELLOW}Usage: ${1} [add,remove] username"
+            ;;
+    esac
+}
+
+availableCommands=$(cat << COMMANDS
+start - Start services
+stop - Stop services
+restart - Restart services
+status - Service status
+logs - View logs for services
+update - Update Musare
+attach [backend,mongo,redis] - Attach to backend service, mongo or redis shell
+build - Build services
+lint - Run lint on frontend, backend, docs and/or shell
+backup - Backup database data to file
+restore - Restore database data from backup file
+reset - Reset service data
+admin [add,remove] - Assign/unassign admin role to/from a user
+typescript - Run typescript checks on frontend and/or backend
+COMMANDS
+)
+
+# Handle command
 case $1 in
 case $1 in
     start)
     start)
         echo -e "${CYAN}Musare | Start Services${NC}"
         echo -e "${CYAN}Musare | Start Services${NC}"
@@ -196,272 +594,43 @@ case $1 in
 
 
     reset)
     reset)
         echo -e "${CYAN}Musare | Reset Services${NC}"
         echo -e "${CYAN}Musare | Reset Services${NC}"
-        servicesString=$(handleServices "backend frontend mongo redis" "${@:2}")
-        if [[ ${servicesString:0:1} == 1 && ${servicesString:2:4} == "all" ]]; then
-            echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} and ${MONGO_DATA_LOCATION} directories.${NC}"
-            echo -e "${GREEN}Are you sure you want to reset all data? ${YELLOW}[y,n]: ${NC}"
-            read -r confirm
-            if [[ "${confirm}" == y* ]]; then
-                runDockerCommand "$(basename "$0") $1" stop
-                ${dockerCompose} rm -v --force
-                if [[ -d $REDIS_DATA_LOCATION ]]; then
-                    rm -rf "${REDIS_DATA_LOCATION}"
-                fi
-                if [[ -d $MONGO_DATA_LOCATION ]]; then
-                    rm -rf "${MONGO_DATA_LOCATION}"
-                fi
-            else
-                echo -e "${RED}Cancelled reset${NC}"
-            fi
-        elif [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ "${servicesString:2}" == *redis* && "${servicesString:2}" == *mongo* ]]; then
-                echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} and ${MONGO_DATA_LOCATION} directories.${NC}"
-            elif [[ "${servicesString:2}" == *redis* ]]; then
-                echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} directory.${NC}"
-            elif [[ "${servicesString:2}" == *mongo* ]]; then
-                echo -e "${RED}Resetting will remove the ${MONGO_DATA_LOCATION} directory.${NC}"
-            fi
-            echo -e "${GREEN}Are you sure you want to reset all data for $(echo "${servicesString:2}" | tr ' ' ',')? ${YELLOW}[y,n]: ${NC}"
-            read -r confirm
-            if [[ "${confirm}" == y* ]]; then
-                # shellcheck disable=SC2086
-                runDockerCommand "$(basename "$0") $1" stop ${servicesString:2}
-                # shellcheck disable=SC2086
-                ${dockerCompose} rm -v --force ${servicesString}
-                if [[ "${servicesString:2}" == *redis* && -d $REDIS_DATA_LOCATION ]]; then
-                    rm -rf "${REDIS_DATA_LOCATION}"
-                fi
-                if [[ "${servicesString:2}" == *mongo* && -d $MONGO_DATA_LOCATION ]]; then
-                    rm -rf "${MONGO_DATA_LOCATION}"
-                fi
-            else
-                echo -e "${RED}Cancelled reset${NC}"
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") build [backend, frontend, mongo, redis]${NC}"
-            exit 1
-        fi
+        # shellcheck disable=SC2068
+        handleReset "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     attach)
     attach)
         echo -e "${CYAN}Musare | Attach${NC}"
         echo -e "${CYAN}Musare | Attach${NC}"
-        if [[ $2 == "backend" ]]; then
-            containerId=$(getContainerId backend)
-            if [[ -z $containerId ]]; then
-                echo -e "${RED}Error: Backend offline, please start to attach.${NC}"
-                exit 1
-            else
-                echo -e "${YELLOW}Detach with CTRL+P+Q${NC}"
-                ${docker} attach "$containerId"
-            fi
-        elif [[ $2 == "mongo" ]]; then
-            MONGO_VERSION_INT=${MONGO_VERSION:0:1}
-            if [[ -z $(getContainerId mongo) ]]; then
-                echo -e "${RED}Error: Mongo offline, please start to attach.${NC}"
-                exit 1
-            else
-                echo -e "${YELLOW}Detach with CTRL+D${NC}"
-                if [[ $MONGO_VERSION_INT -ge 5 ]]; then
-                    ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry()" --shell
-                else
-                    ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}"
-                fi
-            fi
-        elif [[ $2 == "redis" ]]; then
-            if [[ -z $(getContainerId redis) ]]; then
-                echo -e "${RED}Error: Redis offline, please start to attach.${NC}"
-                exit 1
-            else
-                echo -e "${YELLOW}Detach with CTRL+C${NC}"
-                ${dockerCompose} exec redis redis-cli -a "${REDIS_PASSWORD}"
-            fi
-        else
-            echo -e "${RED}Invalid service $2\n${YELLOW}Usage: $(basename "$0") attach [backend,mongo,redis]${NC}"
-            exit 1
-        fi
+        attachContainer "$(basename "$0") $1" "$2"
         ;;
         ;;
 
 
     lint|eslint)
     lint|eslint)
         echo -e "${CYAN}Musare | Lint${NC}"
         echo -e "${CYAN}Musare | Lint${NC}"
-        services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}fix//g;t;q1" <<< "${@:2}")
-        fixFound=$?
-        if [[ $fixFound -eq 0 ]]; then
-            fix="--fix"
-            echo -e "${GREEN}Auto-fix enabled${NC}"
-        fi
-        services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}no-cache//g;t;q1" <<< "${services}")
-        noCacheFound=$?
-        cache="--cache"
-        if [[ $noCacheFound -eq 0 ]]; then
-            cache=""
-            echo -e "${YELLOW}ESlint cache disabled${NC}"
-        fi
         # shellcheck disable=SC2068
         # shellcheck disable=SC2068
-        servicesString=$(handleServices "backend frontend docs" ${services[@]})
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend lint...${NC}"
-                ${dockerCompose} exec -T frontend npm run lint -- $cache $fix
-                frontendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
-                echo -e "${CYAN}Running backend lint...${NC}"
-                ${dockerCompose} exec -T backend npm run lint -- $cache $fix
-                backendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *docs* ]]; then
-                echo -e "${CYAN}Running docs lint...${NC}"
-                ${docker} run --rm -v "${scriptLocation}":/workdir ghcr.io/igorshubovych/markdownlint-cli:latest ".wiki" "*.md" $fix
-                docsExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 || ${docsExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") lint [backend, frontend, docs] [fix]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        handleLinting "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
-
     typescript|ts)
     typescript|ts)
         echo -e "${CYAN}Musare | TypeScript Check${NC}"
         echo -e "${CYAN}Musare | TypeScript Check${NC}"
-        services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}strict//g;t;q1" <<< "${@:2}")
-        strictFound=$?
-        if [[ $strictFound -eq 0 ]]; then
-            strict="--strict"
-            echo -e "${GREEN}Strict mode enabled${NC}"
-        fi
         # shellcheck disable=SC2068
         # shellcheck disable=SC2068
-        servicesString=$(handleServices "backend frontend" ${services[@]})
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend typescript check...${NC}"
-                ${dockerCompose} exec -T frontend npm run typescript -- $strict
-                frontendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
-                echo -e "${CYAN}Running backend typescript check...${NC}"
-                ${dockerCompose} exec -T backend npm run typescript -- $strict
-                backendExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") typescript [backend, frontend] [strict]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        handleTypescript "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     test)
     test)
         echo -e "${CYAN}Musare | Test${NC}"
         echo -e "${CYAN}Musare | Test${NC}"
-        servicesString=$(handleServices "frontend" "${@:2}")
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend tests...${NC}"
-                ${dockerCompose} exec -T frontend npm run test -- --run
-                frontendExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") test [frontend]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        # shellcheck disable=SC2068
+        handleTests "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     test:coverage)
     test:coverage)
         echo -e "${CYAN}Musare | Test Coverage${NC}"
         echo -e "${CYAN}Musare | Test Coverage${NC}"
-        servicesString=$(handleServices "frontend" "${@:2}")
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend test coverage report...${NC}"
-                ${dockerCompose} exec -T frontend npm run coverage
-                frontendExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") test:coverage [frontend]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        # shellcheck disable=SC2068
+        handleTestCoverage "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     update)
     update)
         echo -e "${CYAN}Musare | Update${NC}"
         echo -e "${CYAN}Musare | Update${NC}"
-        musareshModified=$(git diff HEAD -- musare.sh)
-        git fetch
-        exitValue=$?
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
-        updated=$(git log --name-only --oneline HEAD..@\{u\})
-        if [[ ${updated} == "" ]]; then
-            echo -e "${GREEN}Already up to date${NC}"
-            exit ${exitValue}
-        fi
-        breakingConfigChange=$(git rev-list "$(git rev-parse HEAD)" | grep d8b73be1de231821db34c677110b7b97e413451f)
-        if [[ -f backend/config/default.json && -z $breakingConfigChange ]]; then
-            echo -e "${RED}Configuration has breaking changes. Please rename or remove 'backend/config/default.json' and run the update command again to continue.${NC}"
-            exit 1
-        fi
-        musareshChange=$(echo "${updated}" | grep "musare.sh")
-        dbChange=$(echo "${updated}" | grep "backend/logic/db/schemas")
-        bcChange=$(echo "${updated}" | grep "backend/config/default.json")
-        if [[ ( $2 == "auto" && -z $dbChange && -z $bcChange && -z $musareshChange ) || -z $2 ]]; then
-            if [[ -n $musareshChange && $(git diff @\{u\} -- musare.sh) != "" ]]; then
-                if [[ $musareshModified != "" ]]; then
-                    echo -e "${RED}musare.sh has been modified, please reset these changes and run the update command again to continue.${NC}"
-                else
-                    git checkout @\{u\} -- musare.sh
-                    echo -e "${YELLOW}musare.sh has been updated, please run the update command again to continue.${NC}"
-                fi
-                exit 1
-            else
-                git pull
-                exitValue=$?
-                if [[ ${exitValue} -gt 0 ]]; then
-                    exit ${exitValue}
-                fi
-                echo -e "${CYAN}Updating...${NC}"
-                runDockerCommand "$(basename "$0") $1" pull
-                runDockerCommand "$(basename "$0") $1" build
-                runDockerCommand "$(basename "$0") $1" restart
-                echo -e "${GREEN}Updated!${NC}"
-                if [[ -n $dbChange ]]; then
-                    echo -e "${RED}Database schema has changed, please run migration!${NC}"
-                fi
-                if [[ -n $bcChange ]]; then
-                    echo -e "${RED}Backend config has changed, please update!${NC}"
-                fi
-            fi
-        elif [[ $2 == "auto" ]]; then
-            echo -e "${RED}Auto Update Failed! musare.sh, database and/or config has changed!${NC}"
-            exit 1
-        fi
+        # shellcheck disable=SC2068
+        handleUpdate "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     logs)
     logs)
@@ -472,125 +641,32 @@ case $1 in
 
 
     backup)
     backup)
         echo -e "${CYAN}Musare | Backup${NC}"
         echo -e "${CYAN}Musare | Backup${NC}"
-        if [[ -z "${BACKUP_LOCATION}" ]]; then
-            backupLocation="${scriptLocation%x}/backups"
-        else
-            backupLocation="${BACKUP_LOCATION%/}"
-        fi
-        if [[ ! -d "${backupLocation}" ]]; then
-            echo -e "${YELLOW}Creating backup directory at ${backupLocation}${NC}"
-            mkdir "${backupLocation}"
-        fi
-        if [[ -z "${BACKUP_NAME}" ]]; then
-            backupLocation="${backupLocation}/musare-$(date +"%Y-%m-%d-%s").dump"
-        else
-            backupLocation="${backupLocation}/${BACKUP_NAME}"
-        fi
-        echo -e "${YELLOW}Creating backup at ${backupLocation}${NC}"
-        ${dockerCompose} exec -T mongo sh -c "mongodump --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} -d musare --archive" > "${backupLocation}"
+        # shellcheck disable=SC2068
+        handleBackup "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     restore)
     restore)
         echo -e "${CYAN}Musare | Restore${NC}"
         echo -e "${CYAN}Musare | Restore${NC}"
-        if [[ -z $2 ]]; then
-            echo -e "${GREEN}Please enter the full path of the dump you wish to restore: ${NC}"
-            read -r restoreFile
-        else
-            restoreFile=$2
-        fi
-        if [[ -z ${restoreFile} ]]; then
-            echo -e "${RED}Error: no restore path given, cancelled restoration.${NC}"
-            exit 1
-        elif [[ -d ${restoreFile} ]]; then
-            echo -e "${RED}Error: restore path given is a directory, cancelled restoration.${NC}"
-            exit 1
-        elif [[ ! -f ${restoreFile} ]]; then
-            echo -e "${RED}Error: no file at restore path given, cancelled restoration.${NC}"
-            exit 1
-        else
-            ${dockerCompose} exec -T mongo sh -c "mongorestore --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} --archive" < "${restoreFile}"
-        fi
+        # shellcheck disable=SC2068
+        handleRestore "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     admin)
     admin)
         echo -e "${CYAN}Musare | Add Admin${NC}"
         echo -e "${CYAN}Musare | Add Admin${NC}"
-        MONGO_VERSION_INT=${MONGO_VERSION:0:1}
-        if [[ $2 == "add" ]]; then
-            if [[ -z $3 ]]; then
-                echo -e "${GREEN}Please enter the username of the user you wish to make an admin: ${NC}"
-                read -r adminUser
-            else
-                adminUser=$3
-            fi
-            if [[ -z $adminUser ]]; then
-                echo -e "${RED}Error: Username for new admin not provided.${NC}"
-                exit 1
-            else
-                if [[ $MONGO_VERSION_INT -ge 5 ]]; then
-                    ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
-                else
-                    ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
-                fi
-            fi
-        elif [[ $2 == "remove" ]]; then
-            if [[ -z $3 ]]; then
-                echo -e "${GREEN}Please enter the username of the user you wish to remove as admin: ${NC}"
-                read -r adminUser
-            else
-                adminUser=$3
-            fi
-            if [[ -z $adminUser ]]; then
-                echo -e "${RED}Error: Username for new admin not provided.${NC}"
-                exit 1
-            else
-                if [[ $MONGO_VERSION_INT -ge 5 ]]; then
-                    ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
-                else
-                    ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
-                fi
-            fi
-        else
-            echo -e "${RED}Invalid command $2\n${YELLOW}Usage: $(basename "$0") admin [add,remove] username${NC}"
-            exit 1
-        fi
+        # shellcheck disable=SC2068
+        handleAdmin "$(basename "$0") $1" ${@:2}
         ;;
         ;;
 
 
     "")
     "")
         echo -e "${CYAN}Musare | Available Commands${NC}"
         echo -e "${CYAN}Musare | Available Commands${NC}"
-        echo -e "${YELLOW}start - Start services${NC}"
-        echo -e "${YELLOW}stop - Stop services${NC}"
-        echo -e "${YELLOW}restart - Restart services${NC}"
-        echo -e "${YELLOW}status - Service status${NC}"
-        echo -e "${YELLOW}logs - View logs for services${NC}"
-        echo -e "${YELLOW}update - Update Musare${NC}"
-        echo -e "${YELLOW}attach [backend,mongo,redis] - Attach to backend service, mongo or redis shell${NC}"
-        echo -e "${YELLOW}build - Build services${NC}"
-        echo -e "${YELLOW}lint - Run lint on frontend, backend and/or docs${NC}"
-        echo -e "${YELLOW}backup - Backup database data to file${NC}"
-        echo -e "${YELLOW}restore - Restore database data from backup file${NC}"
-        echo -e "${YELLOW}reset - Reset service data${NC}"
-        echo -e "${YELLOW}admin [add,remove] - Assign/unassign admin role to/from a user${NC}"
-        echo -e "${YELLOW}typescript - Run typescript checks on frontend and/or backend${NC}"
+        echo -e "${YELLOW}${availableCommands}${NC}"
         ;;
         ;;
 
 
     *)
     *)
         echo -e "${CYAN}Musare${NC}"
         echo -e "${CYAN}Musare${NC}"
         echo -e "${RED}Error: Invalid Command $1${NC}"
         echo -e "${RED}Error: Invalid Command $1${NC}"
         echo -e "${CYAN}Available Commands:${NC}"
         echo -e "${CYAN}Available Commands:${NC}"
-        echo -e "${YELLOW}start - Start services${NC}"
-        echo -e "${YELLOW}stop - Stop services${NC}"
-        echo -e "${YELLOW}restart - Restart services${NC}"
-        echo -e "${YELLOW}status - Service status${NC}"
-        echo -e "${YELLOW}logs - View logs for services${NC}"
-        echo -e "${YELLOW}update - Update Musare${NC}"
-        echo -e "${YELLOW}attach [backend,mongo,redis] - Attach to backend service, mongo or redis shell${NC}"
-        echo -e "${YELLOW}build - Build services${NC}"
-        echo -e "${YELLOW}lint - Run lint on frontend, backend and/or docs${NC}"
-        echo -e "${YELLOW}backup - Backup database data to file${NC}"
-        echo -e "${YELLOW}restore - Restore database data from backup file${NC}"
-        echo -e "${YELLOW}reset - Reset service data${NC}"
-        echo -e "${YELLOW}admin [add,remove] - Assign/unassign admin role to/from a user${NC}"
-        echo -e "${YELLOW}typescript - Run typescript checks on frontend and/or backend${NC}"
+        echo -e "${YELLOW}${availableCommands}${NC}"
         exit 1
         exit 1
         ;;
         ;;
 esac
 esac

+ 2 - 0
types/models/User.ts

@@ -5,6 +5,8 @@ export type UserPreferences = {
 	activityLogPublic: boolean;
 	activityLogPublic: boolean;
 	anonymousSongRequests: boolean;
 	anonymousSongRequests: boolean;
 	activityWatch: boolean;
 	activityWatch: boolean;
+	defaultStationPrivacy: "public" | "unlisted" | "private";
+	defaultPlaylistPrivacy: "public" | "private";
 };
 };
 
 
 export type UserModel = {
 export type UserModel = {

Деякі файли не було показано, через те що забагато файлів було змінено