2
0
Owen Diffey 3 сар өмнө
parent
commit
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
-RESTART_POLICY=unless-stopped
-CONTAINER_MODE=production
 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_DEV_PORT=81
-FRONTEND_MODE=production
 FRONTEND_PROD_DEVTOOLS=false
 
-MONGO_HOST=127.0.0.1
-MONGO_PORT=27017
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_USER_USERNAME=musare
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
-MONGO_DATA_LOCATION=.db
 MONGO_VERSION=6
 
-REDIS_HOST=127.0.0.1
-REDIS_PORT=6379
 REDIS_PASSWORD=PASSWORD
-REDIS_DATA_LOCATION=.redis
 
 BACKUP_LOCATION=
 BACKUP_NAME=

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

@@ -4,33 +4,22 @@ on: [ push, pull_request, workflow_dispatch ]
 
 env:
     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_USER_USERNAME: musare
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
-    MONGO_DATA_LOCATION: .db
     MONGO_VERSION: 5.0
-    REDIS_HOST: 127.0.0.1
-    REDIS_PORT: 6379
     REDIS_PASSWORD: PASSWORD
-    REDIS_DATA_LOCATION: .redis
 
 jobs:
     tests:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
             - name: Build Musare
               run: |
                   cp .env.example .env
+                  sed -i 's/APP_ENV=production/APP_ENV=development/g' .env
                   ./musare.sh build
             - name: Start Musare
               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:
     - name: Checkout repository
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Initialize CodeQL
       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 ]
 
 env:
     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_USER_USERNAME: musare
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
-    MONGO_DATA_LOCATION: .db
     MONGO_VERSION: 5.0
-    REDIS_HOST: 127.0.0.1
-    REDIS_PORT: 6379
     REDIS_PASSWORD: PASSWORD
-    REDIS_DATA_LOCATION: .redis
 
 jobs:
-    build-lint:
+    lint:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v3
+            - uses: actions/checkout@v4
             - name: Build Musare
               run: |
                   cp .env.example .env
+                  sed -i 's/APP_ENV=production/APP_ENV=development/g' .env
                   ./musare.sh build
             - name: Start Musare
               run: ./musare.sh start
             - name: Backend Lint
               run: ./musare.sh lint backend
-            - name: Backend Typescript
-              run: ./musare.sh typescript backend
             - name: Frontend Lint
               run: ./musare.sh lint frontend
-#            - name: Frontend Typescript
-#              run: ./musare.sh typescript frontend
             - name: Docs Lint
               run: ./musare.sh lint docs
+            - name: Shell Lint
+              run: ./musare.sh lint shell

+ 1 - 1
.gitignore

@@ -2,7 +2,6 @@ Thumbs.db
 .DS_Store
 *.swp
 .idea/
-.vscode/
 .vagrant/
 
 .env
@@ -13,6 +12,7 @@ startMongo.cmd
 .redis
 *.rdb
 backups/
+compose.override.yml
 docker-compose.override.yml
 
 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
 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 |
 | --- | --- |
 | `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`.  |
-| `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_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] |
-| `MONGO_HOST` | Mongo container host. |
-| `MONGO_PORT` | Mongo container port. |
 | `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
 | `MONGO_USER_USERNAME` | Application username 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. |
-| `REDIS_HOST` | Redis container host. |
-| `REDIS_PORT` | Redis container port. |
 | `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_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] |
@@ -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.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.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.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
 | `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. |
 | `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. |
+| `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. |
 | `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`. |
@@ -168,18 +151,47 @@ For more information on configuration files please refer to the
 | `experimental.soundcloud` | Experimental SoundCloud 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
 services:
   backend:
     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
 
+## [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
 
 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
   - Profile page showing public playlists and activity logs
   - Text or gravatar profile pictures
-  - Email or Github login/registration
+  - Email or OIDC login/registration
   - Preferences to tailor site usage
   - Password reset
   - 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,
 	"secret": "default",
 	"port": 8080,
@@ -52,16 +52,17 @@
 			"key": "",
 			"secret": ""
 		},
-		"github": {
-			"enabled": false,
-			"client": "",
-			"secret": "",
-			"redirect_uri": ""
-		},
 		"discogs": {
 			"enabled": false,
 			"client": "",
 			"secret": ""
+		},
+		"oidc": {
+			"enabled": false,
+			"client_id": "",
+			"secret_secret": "",
+			"openid_configuration_url": "",
+			"redirect_uri": ""
 		}
 	},
 	"cors": {
@@ -108,6 +109,7 @@
 	"shortcutOverrides": {},
 	"registrationDisabled": false,
 	"sendDataRequestEmails": true,
+	"restrictToUsers": false,
 	"skipConfigVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"debug": {

+ 1 - 1
backend/config/template.json

@@ -1,5 +1,5 @@
 {
-	"configVersion": 12,
+	"configVersion": 13,
 	"migration": false,
 	"secret": "CHANGE_ME",
 	"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 * 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
 Array.prototype.remove = function (item) {
@@ -194,7 +194,10 @@ class ModuleManager {
 	 */
 	onFail(module) {
 		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("media");
 	moduleManager.addModule("tasks");
+	moduleManager.addModule("users");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");
 	if (config.get("experimental.soundcloud")) moduleManager.addModule("soundcloud");

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

@@ -1,6 +1,7 @@
 import async from "async";
 
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -37,7 +38,7 @@ export default {
 	 * @param {string} userId - the id of the user in question
 	 * @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);
 
 		async.waterfall(
@@ -66,7 +67,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * 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 {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 activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
@@ -121,7 +122,7 @@ export default {
 				return cb({ status: "success", data: { activities } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * 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 isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -314,7 +315,7 @@ export default {
 	 * @param {string} room - the room to join
 	 * @param {Function} cb - callback
 	 */
-	joinRoom(session, room, cb) {
+	joinRoom: isLoginSometimesRequired(function joinRoom(session, room, cb) {
 		const roomName = room.split(".")[0];
 		// const roomId = room.split(".")[1];
 		const rooms = {
@@ -352,7 +353,7 @@ export default {
 				.then(() => join("success"))
 				.catch(err => join("error", err));
 		else join("error", "Room not found");
-	},
+	}),
 
 	/**
 	 * Leaves a room

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

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

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

@@ -1,5 +1,6 @@
 import async from "async";
 
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -166,7 +167,7 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @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);
 		async.waterfall(
 			[
@@ -186,7 +187,7 @@ export default {
 				return cb({ status: "success", data: { news } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a news item by id
@@ -194,7 +195,7 @@ export default {
 	 * @param {string} newsId - the news item id
 	 * @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);
 
 		async.waterfall(
@@ -215,7 +216,8 @@ export default {
 				return cb({ status: "success", data: { news } });
 			}
 		);
-	},
+	}),
+
 	/**
 	 * Creates a news item
 	 * @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 {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 query = { status: "published" };
 		if (newUser) query.showToNewUsers = true;
@@ -274,7 +276,7 @@ export default {
 				return cb({ status: "success", data: { news } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Removes a news item

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

@@ -2,6 +2,7 @@ import async from "async";
 import config from "config";
 
 import isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
@@ -629,7 +630,7 @@ export default {
 	 * @param {string} userId - the user id in question
 	 * @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 userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -698,7 +699,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * 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 {Function} cb - gets called with the result
 	 */
-	getPlaylist: function getPlaylist(session, playlistId, cb) {
+	getPlaylist: isLoginSometimesRequired(function getPlaylist(session, playlistId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -937,7 +938,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a playlist from station id
@@ -946,7 +947,7 @@ export default {
 	 * @param {string} includeSongs - include songs
 	 * @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(
 			[
 				next => {
@@ -987,7 +988,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * 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 isLoginRequired from "../hooks/loginRequired";
+import isLoginSometimesRequired from "../hooks/loginSometimesRequired";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -359,7 +360,7 @@ export default {
 	 * @param {boolean} adminFilter - whether to filter out stations admins do not own
 	 * @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" });
 
 		async.waterfall(
@@ -454,7 +455,7 @@ export default {
 				return cb({ status: "success", data: { stations, favorited } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * 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 {Function} cb - callback
 	 */
-	getStationForActivity(session, stationId, cb) {
+	getStationForActivity: isLoginSometimesRequired(function getStationForActivity(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -599,7 +600,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Verifies that a station exists from its name
@@ -607,7 +608,7 @@ export default {
 	 * @param {string} stationName - the station name
 	 * @param {Function} cb - callback
 	 */
-	existsByName(session, stationName, cb) {
+	existsByName: isLoginSometimesRequired(function existsByName(session, stationName, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -643,7 +644,7 @@ export default {
 				return cb({ status: "success", data: { exists } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Verifies that a station exists from its id
@@ -651,7 +652,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	existsById(session, stationId, cb) {
+	existsById: isLoginSometimesRequired(function existsById(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -687,7 +688,7 @@ export default {
 				return cb({ status: "success", data: { exists } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets the official playlist for a station
@@ -695,7 +696,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getPlaylist(session, stationId, cb) {
+	getPlaylist: isLoginSometimesRequired(function getPlaylist(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -769,7 +770,7 @@ export default {
 				return cb({ status: "success", data: { songs: playlist.songs } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Joins the station by its name
@@ -777,7 +778,7 @@ export default {
 	 * @param {string} stationIdentifier - the station name or station id
 	 * @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" });
 		async.waterfall(
 			[
@@ -897,7 +898,7 @@ export default {
 				return cb({ status: "success", data });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets a station by id
@@ -905,7 +906,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getStationById(session, stationId, cb) {
+	getStationById: isLoginSometimesRequired(function getStationById(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -986,7 +987,7 @@ export default {
 				return cb({ status: "success", data: { station: data } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Gets station history
@@ -994,7 +995,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getHistory(session, stationId, cb) {
+	getHistory: isLoginSometimesRequired(function getHistory(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1054,80 +1055,82 @@ export default {
 				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(
-						"ERROR",
+						"SUCCESS",
 						"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(
 			[
 				next => {
@@ -1196,7 +1199,7 @@ export default {
 				return cb({ status: "success", data: { playlists } });
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Toggle votes to skip a station
@@ -1331,7 +1334,7 @@ export default {
 	 * @param {string} stationId - id of station to leave
 	 * @param {Function} cb - callback
 	 */
-	leave(session, stationId, cb) {
+	leave: isLoginSometimesRequired(function leave(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1366,7 +1369,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * Updates a station's settings
@@ -1810,7 +1813,7 @@ export default {
 				},
 
 				(playlist, stationId, next) => {
-					const { name, displayName, description, type } = data;
+					const { name, displayName, description, type, privacy } = data;
 					if (type === "official") {
 						stationModel.create(
 							{
@@ -1820,7 +1823,7 @@ export default {
 								description,
 								playlist: playlist._id,
 								type,
-								privacy: "private",
+								privacy,
 								queue: [],
 								currentSong: null
 							},
@@ -1835,7 +1838,7 @@ export default {
 								description,
 								playlist: playlist._id,
 								type,
-								privacy: "private",
+								privacy,
 								owner: session.userId,
 								queue: [],
 								currentSong: null
@@ -2034,7 +2037,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	getQueue(session, stationId, cb) {
+	getQueue: isLoginSometimesRequired(function getQueue(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -2079,7 +2082,7 @@ export default {
 				});
 			}
 		);
-	},
+	}),
 
 	/**
 	 * 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 axios from "axios";
-import async from "async";
 import cors from "cors";
 import cookieParser from "cookie-parser";
 import bodyParser from "body-parser";
 import express from "express";
-import oauth from "oauth";
 import http from "http";
-import CoreClass from "../core";
 
-const { OAuth2 } = oauth;
+import CoreClass from "../core";
 
 let AppModule;
-let MailModule;
-let CacheModule;
-let DBModule;
-let ActivitiesModule;
-let PlaylistsModule;
-let UtilsModule;
+let UsersModule;
 
 class _AppModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -31,489 +22,131 @@ class _AppModule extends CoreClass {
 	 * Initialises the app module
 	 * @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") {
 					this.log(
 						"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.");
 				}
 
-				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,
 	song: 10,
 	station: 10,
-	user: 4,
+	user: 5,
 	youtubeApiRequest: 1,
 	youtubeVideo: [1, 2],
 	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
 					(pipeline, next) => {
-						const { properties, queries, specialProperties } = payload;
+						const { properties, queries, specialProperties = {} } = payload;
 
 						async.eachLimit(
 							Object.entries(specialProperties),

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

@@ -19,14 +19,10 @@ export default {
 			reset: {
 				code: { type: String, min: 8, max: 8 },
 				expires: { type: Date }
-			},
-			set: {
-				code: { type: String, min: 8, max: 8 },
-				expires: { type: Date }
 			}
 		},
-		github: {
-			id: Number,
+		oidc: {
+			sub: String,
 			access_token: String
 		}
 	},
@@ -46,7 +42,14 @@ export default {
 		autoSkipDisliked: { type: Boolean, default: true, required: true },
 		activityLogPublic: { 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,
 	"users.get": 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.update": 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]);
 		});
 	}
+
+	/**
+	 * 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();

+ 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
 					}
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 					status: "working",
 					message: `Failed to get alternative artist source for ${artistId}`,
@@ -862,7 +862,7 @@ class _SpotifyModule extends CoreClass {
 						result
 					}
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 					status: "working",
 					message: `Failed to get alternative album source for ${albumId}`,
@@ -935,7 +935,7 @@ class _SpotifyModule extends CoreClass {
 						result
 					}
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 					status: "working",
 					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"),
 						key: config.get("apis.recaptcha.key")
 					},
-					githubAuthentication: config.get("apis.github.enabled"),
+					oidcAuthentication: config.get("apis.oidc.enabled"),
 					messages: config.get("messages"),
 					christmas: config.get("christmas"),
 					footerLinks: config.get("footerLinks"),
 					primaryColor: config.get("primaryColor"),
 					shortcutOverrides: config.get("shortcutOverrides"),
-					registrationDisabled: config.get("registrationDisabled"),
+					registrationDisabled:
+						config.get("registrationDisabled") === true || config.get("apis.oidc.enabled") === true,
 					mailEnabled: config.get("mail.enabled"),
 					discogsEnabled: config.get("apis.discogs.enabled"),
+					passwordResetEnabled: config.get("mail.enabled") && !config.get("apis.oidc.enabled"),
 					experimental: {
 						changable_listen_mode: config.get("experimental.changable_listen_mode"),
 						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",
   "private": true,
-  "version": "3.11.0",
+  "version": "3.12.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -9,50 +9,49 @@
   "license": "GPL-3.0",
   "repository": "https://github.com/Musare/Musare",
   "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",
     "typescript": "tsc --noEmit --skipLibCheck"
   },
   "dependencies": {
-    "async": "^3.2.5",
-    "axios": "^1.6.7",
+    "async": "^3.2.6",
+    "axios": "^1.7.9",
     "bcrypt": "^5.1.1",
     "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",
-    "express": "^4.18.2",
+    "express": "^4.21.2",
+    "extensionless": "^1.9.9",
     "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",
     "sha256": "^0.2.0",
-    "socks": "^2.8.1",
+    "socks": "^2.8.3",
     "soundcloud-key-fetch": "^1.0.13",
-    "underscore": "^1.13.6",
-    "ws": "^8.16.0"
+    "underscore": "^1.13.7",
+    "ws": "^8.18.0"
   },
   "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-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",
     "ts-node": "^10.9.2",
-    "typescript": "^5.3.3"
+    "typescript": "^5.7.3"
   },
   "overrides": {
     "@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:
   backend:
     build:
       context: .
-      dockerfile: ./backend/Dockerfile
-      target: musare_backend
-    restart: ${RESTART_POLICY:-unless-stopped}
+      dockerfile: ./Dockerfile
+      target: backend
+    restart: unless-stopped
     volumes:
       - ./backend/config:/opt/app/config
     environment:
-      - CONTAINER_MODE=${CONTAINER_MODE:-production}
       - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
       - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
       - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
@@ -25,16 +22,18 @@ services:
     links:
       - mongo
       - redis
+    networks:
+      - backend
+      - proxy
     stdin_open: true
     tty: true
 
   frontend:
     build:
       context: .
-      dockerfile: ./frontend/Dockerfile
-      target: musare_frontend
+      dockerfile: ./Dockerfile
+      target: frontend
       args:
-        FRONTEND_MODE: "${FRONTEND_MODE:-production}"
         FRONTEND_PROD_DEVTOOLS: "${FRONTEND_PROD_DEVTOOLS:-false}"
         MUSARE_SITENAME: "${MUSARE_SITENAME:-Musare}"
         MUSARE_PRIMARY_COLOR: "${MUSARE_PRIMARY_COLOR:-#03a9f4}"
@@ -44,46 +43,43 @@ services:
         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: ${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:
       - backend
+    networks:
+      - proxy
 
   mongo:
     image: docker.io/mongo:${MONGO_VERSION}
-    restart: ${RESTART_POLICY:-unless-stopped}
+    restart: unless-stopped
     environment:
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_INITDB_DATABASE=musare
-      - MONGO_PORT=${MONGO_PORT:-27017}
       - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
       - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
+    networks:
+      - backend
     volumes:
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
-      - ${MONGO_DATA_LOCATION:-./db}:/data/db
+      - database:/data/db
 
   redis:
     image: docker.io/redis:7
-    restart: ${RESTART_POLICY:-unless-stopped}
+    restart: unless-stopped
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
+    networks:
+      - backend
     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"
   ],
   "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.",
   "main": "main.js",
   "author": "Musare Team",
@@ -20,47 +20,46 @@
     "coverage": "vitest run --coverage"
   },
   "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": {
-    "@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",
-    "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",
-    "marked": "^12.0.0",
+    "marked": "^15.0.6",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.1.7",
+    "pinia": "^2.3.0",
     "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-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,
 	changeActivityLogPublic,
 	changeAnonymousSongRequests,
-	changeActivityWatch
+	changeActivityWatch,
+	changeDefaultStationPrivacy,
+	changeDefaultPlaylistPrivacy
 } = userPreferencesStore;
 const { activeModals } = storeToRefs(modalsStore);
 const { openModal, closeCurrentModal } = modalsStore;
@@ -197,6 +199,12 @@ onMounted(async () => {
 						preferences.anonymousSongRequests
 					);
 					changeActivityWatch(preferences.activityWatch);
+					changeDefaultStationPrivacy(
+						preferences.defaultStationPrivacy
+					);
+					changeDefaultPlaylistPrivacy(
+						preferences.defaultPlaylistPrivacy
+					);
 				}
 			}
 		);
@@ -248,11 +256,11 @@ onMounted(async () => {
 
 		router.isReady().then(() => {
 			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);
@@ -1644,6 +1652,14 @@ button.delete:focus {
 	}
 }
 
+.input-with-label {
+	column-gap: 8px;
+
+	.label {
+		align-items: center;
+	}
+}
+
 .page-title {
 	margin: 0 0 50px 0;
 }

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

@@ -182,11 +182,32 @@ const bulkPopup = ref();
 const rowElements = ref([]);
 
 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(() =>
 	orderedColumns.value.filter(column => column.hidable)
 );
@@ -1598,7 +1619,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 					<thead>
 						<tr>
 							<draggable-list
-								v-model:list="orderedColumns"
+								v-model:list="sortedFilteredColumns"
 								item-key="name"
 								@update="columnOrderChanged"
 								tag="th"

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

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

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

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

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

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

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

@@ -1,8 +1,10 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref } from "vue";
 import Toast from "toasters";
+import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
+import { useUserPreferencesStore } from "@/stores/userPreferences";
 import validation from "@/validation";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
@@ -16,15 +18,19 @@ const { socket } = useWebsocketsStore();
 
 const { closeCurrentModal } = useModalsStore();
 
+const userPreferencesStore = useUserPreferencesStore();
+const { defaultStationPrivacy } = storeToRefs(userPreferencesStore);
+
 const newStation = ref({
 	name: "",
 	displayName: "",
-	description: ""
+	description: "",
+	privacy: defaultStationPrivacy.value
 });
 
 const submitModal = () => {
 	newStation.value.name = newStation.value.name.toLowerCase();
-	const { name, displayName, description } = newStation.value;
+	const { name, displayName, description, privacy } = newStation.value;
 
 	if (!name || !displayName || !description)
 		return new Toast("Please fill in all fields");
@@ -62,7 +68,8 @@ const submitModal = () => {
 			name,
 			type: props.official ? "official" : "community",
 			displayName,
-			description
+			description,
+			privacy
 		},
 		res => {
 			if (res.status === "success") {
@@ -107,9 +114,16 @@ const submitModal = () => {
 					class="input"
 					type="text"
 					placeholder="Description..."
-					@keyup.enter="submitModal()"
 				/>
 			</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 #footer>
 			<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="
 									addYouTubeSongToPlaylist(
 										playlist._id,
-										result.id,
+										`youtube:${result.id}`,
 										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?"
 				);
 			else importMusarePlaylistFileContents.value = parsed;
-		} catch (err) {
+		} catch {
 			new Toast(
 				"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 });
 			else throw new Error("No title found");
-		} catch (e) {
+		} catch {
 			new Toast(
 				"Unable to fetch YouTube video title. Try starting the video."
 			);
@@ -830,7 +830,7 @@ const getYouTubeData = type => {
 
 			if (author) setValue({ addArtist: author });
 			else throw new Error("No video author found");
-		} catch (e) {
+		} catch {
 			new Toast(
 				"Unable to fetch YouTube video author. Try starting the video."
 			);
@@ -847,7 +847,7 @@ const getSoundCloudData = type => {
 				if (title) setValue({ title });
 				else throw new Error("No title found");
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track title.");
 		}
 	}
@@ -866,7 +866,7 @@ const getSoundCloudData = type => {
 				if (artworkUrl) setValue({ thumbnail: artworkUrl });
 				else throw new Error("No thumbnail found");
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track artwork.");
 		}
 	}
@@ -879,7 +879,7 @@ const getSoundCloudData = type => {
 				if (user) setValue({ addArtist: user.username });
 				else throw new Error("No artist found");
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track artist.");
 		}
 	}

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

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref } from "vue";
-import { useRoute } from "vue-router";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
@@ -9,8 +8,6 @@ import { useModalsStore } from "@/stores/modals";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
-const route = useRoute();
-
 const email = ref("");
 const password = ref({
 	value: "",
@@ -19,7 +16,7 @@ const password = ref({
 const passwordElement = ref();
 
 const configStore = useConfigStore();
-const { githubAuthentication, registrationDisabled } = storeToRefs(configStore);
+const { registrationDisabled } = storeToRefs(configStore);
 const { login } = useUserAuthStore();
 
 const { openModal, closeCurrentModal } = useModalsStore();
@@ -62,10 +59,6 @@ const changeToRegisterModal = () => {
 	closeCurrentModal();
 	openModal("register");
 };
-
-const githubRedirect = () => {
-	localStorage.setItem("github_redirect", route.path);
-};
 </script>
 
 <template>
@@ -150,20 +143,6 @@ const githubRedirect = () => {
 					<button class="button is-primary" @click="submitModal()">
 						Login
 					</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>
 
 				<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 {
 	background-color: var(--primary-color) !important;
 }

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

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, watch, onMounted } from "vue";
-import { useRoute } from "vue-router";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
@@ -13,8 +12,6 @@ const InputHelpBox = defineAsyncComponent(
 	() => import("@/components/InputHelpBox.vue")
 );
 
-const route = useRoute();
-
 const username = ref({
 	value: "",
 	valid: false,
@@ -41,8 +38,7 @@ const passwordElement = ref();
 const { register } = useUserAuthStore();
 
 const configStore = useConfigStore();
-const { registrationDisabled, recaptcha, githubAuthentication } =
-	storeToRefs(configStore);
+const { registrationDisabled, recaptcha } = storeToRefs(configStore);
 const { openModal, closeCurrentModal } = useModalsStore();
 
 const submitModal = () => {
@@ -76,10 +72,6 @@ const changeToLoginModal = () => {
 	openModal("login");
 };
 
-const githubRedirect = () => {
-	localStorage.setItem("github_redirect", route.path);
-};
-
 watch(
 	() => username.value.value,
 	value => {
@@ -274,20 +266,6 @@ onMounted(async () => {
 					<button class="button is-primary" @click="submitModal()">
 						Register
 					</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>
 
 				<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 {
 	filter: brightness(5);
 }

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

@@ -1,10 +1,8 @@
 <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 { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
-import { useSettingsStore } from "@/stores/settings";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 
@@ -13,20 +11,15 @@ const QuickConfirm = defineAsyncComponent(
 	() => 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 { cookie, githubAuthentication, messages } = storeToRefs(configStore);
-const settingsStore = useSettingsStore();
-const route = useRoute();
+const { cookie, oidcAuthentication, messages } = storeToRefs(configStore);
 
 const { socket } = useWebsocketsStore();
 
-const { isPasswordLinked, isGithubLinked } = settingsStore;
-
 const { closeCurrentModal } = useModalsStore();
 
 const step = ref("confirm-identity");
@@ -67,28 +60,6 @@ const confirmPasswordMatch = () =>
 		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 = () =>
 	socket.dispatch("users.remove", res => {
 		if (res.status === "success") {
@@ -102,8 +73,8 @@ const remove = () =>
 		return new Toast(res.message);
 	});
 
-onMounted(async () => {
-	if (props.githubLinkConfirmed === true) confirmGithubLink();
+onMounted(() => {
+	if (oidcAuthentication.value) step.value = "remove-account";
 });
 </script>
 
@@ -113,7 +84,7 @@ onMounted(async () => {
 		class="confirm-account-removal-modal"
 	>
 		<template #body>
-			<div id="steps">
+			<div v-if="!oidcAuthentication" id="steps">
 				<p
 					class="step"
 					:class="{ selected: step === 'confirm-identity' }"
@@ -124,41 +95,17 @@ onMounted(async () => {
 				<p
 					class="step"
 					:class="{
-						selected:
-							(isPasswordLinked && step === 'export-data') ||
-							step === 'relink-github'
+						selected: step === 'remove-account'
 					}"
 				>
 					2
 				</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
 				class="content-box"
 				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>
 				<p class="content-box-description">
@@ -216,63 +163,6 @@ onMounted(async () => {
 				</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
 				class="content-box"
 				id="remove-account-container"

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

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

+ 2 - 2
frontend/src/index.html

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

+ 10 - 11
frontend/src/main.ts

@@ -137,16 +137,7 @@ const router = createRouter({
 			path: "/reset_password",
 			component: () => import("@/pages/ResetPassword.vue"),
 			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,
 			changeActivityLogPublic,
 			changeAnonymousSongRequests,
-			changeActivityWatch
+			changeActivityWatch,
+			changeDefaultStationPrivacy,
+			changeDefaultPlaylistPrivacy
 		} = useUserPreferencesStore();
 
 		if (preferences.autoSkipDisliked !== undefined)
@@ -405,6 +398,12 @@ createSocket().then(async socket => {
 
 		if (preferences.activityWatch !== undefined)
 			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 => {

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

@@ -1,9 +1,11 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, onMounted } from "vue";
 import { useRoute } from "vue-router";
+import { storeToRefs } from "pinia";
 import { useModalsStore } from "@/stores/modals";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+import { useConfigStore } from "@/stores/config";
 
 const AdvancedTable = defineAsyncComponent(
 	() => import("@/components/AdvancedTable.vue")
@@ -12,6 +14,9 @@ const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
 );
 
+const configStore = useConfigStore();
+const { oidcAuthentication } = storeToRefs(configStore);
+
 const route = useRoute();
 
 const columnDefault = ref<TableColumn>({
@@ -63,20 +68,18 @@ const columns = ref<TableColumn[]>([
 		minWidth: 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",
 		displayName: "Role",
@@ -132,20 +135,17 @@ const filters = ref<TableFilter[]>([
 		filterTypes: ["contains", "exact", "regex"],
 		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",
 		displayName: "Role",
@@ -279,18 +279,13 @@ onMounted(() => {
 					slotProps.item._id
 				}}</span>
 			</template>
-			<template #column-githubId="slotProps">
+			<template v-if="oidcAuthentication" #column-oidcSub="slotProps">
 				<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 #column-hasPassword="slotProps">
-				<span :title="slotProps.item.hasPassword">{{
-					slotProps.item.hasPassword
-				}}</span>
-			</template>
 			<template #column-role="slotProps">
 				<span :title="slotProps.item.role">{{
 					slotProps.item.role

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

@@ -38,7 +38,8 @@ const userAuthStore = useUserAuthStore();
 const route = useRoute();
 const router = useRouter();
 
-const { sitename, registrationDisabled } = storeToRefs(configStore);
+const { sitename, registrationDisabled, oidcAuthentication } =
+	storeToRefs(configStore);
 const { loggedIn, userId } = storeToRefs(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(
 	() => hasPermission("stations.index.other"),
 	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
 		handledLoginRegisterRedirect.value = true;
-		openModal(route.redirectedFrom.name);
+
+		if (oidcAuthentication.value) oidcRedirect();
+		else openModal(route.redirectedFrom.name);
 	}
 
 	socket.onConnect(() => {
@@ -384,7 +393,15 @@ onBeforeUnmount(() => {
 						/>
 						<span v-else class="logo">{{ sitename }}</span>
 						<div v-if="!loggedIn" class="buttons">
+							<a
+								v-if="oidcAuthentication"
+								class="button login"
+								@click="oidcRedirect()"
+							>
+								{{ t("Login") }}
+							</a>
 							<button
+								v-else
 								class="button login"
 								@click="openModal('login')"
 							>

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

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

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

@@ -1,6 +1,5 @@
 <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 { storeToRefs } from "pinia";
 import { useSettingsStore } from "@/stores/settings";
@@ -21,14 +20,13 @@ const QuickConfirm = defineAsyncComponent(
 
 const settingsStore = useSettingsStore();
 const userAuthStore = useUserAuthStore();
-const route = useRoute();
 
 const { socket } = useWebsocketsStore();
 
 const saveButton = ref();
 
 const { userId } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
+const { originalUser, modifiedUser } = storeToRefs(settingsStore);
 
 const validation = reactive({
 	username: {
@@ -52,7 +50,7 @@ const onInput = inputName => {
 };
 
 const changeEmail = () => {
-	const email = modifiedUser.email.address;
+	const email = modifiedUser.value.email.address;
 	if (!_validation.isLength(email, 3, 254))
 		return new Toast("Email must have between 3 and 254 characters.");
 	if (
@@ -81,7 +79,7 @@ const changeEmail = () => {
 };
 
 const changeUsername = () => {
-	const { username } = modifiedUser;
+	const { username } = modifiedUser.value;
 
 	if (!_validation.isLength(username, 2, 32))
 		return new Toast("Username must have between 2 and 32 characters.");
@@ -121,9 +119,10 @@ const changeUsername = () => {
 };
 
 const saveChanges = () => {
-	const usernameChanged = modifiedUser.username !== originalUser.username;
+	const usernameChanged =
+		modifiedUser.value.username !== originalUser.value.username;
 	const emailAddressChanged =
-		modifiedUser.email.address !== originalUser.email.address;
+		modifiedUser.value.email.address !== originalUser.value.email.address;
 
 	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(
-	() => modifiedUser.username,
+	() => modifiedUser.value.username,
 	value => {
 		// const value = newModifiedUser.username;
 
@@ -165,7 +152,7 @@ watch(
 			validation.username.valid = false;
 		} else if (
 			!_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 =
 				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _.";
@@ -182,7 +169,7 @@ watch(
 );
 
 watch(
-	() => modifiedUser.email.address,
+	() => modifiedUser.value.email?.address,
 	value => {
 		// 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 localActivityWatch = ref(false);
 
+const localDefaultStationPrivacy = ref("private");
+const localDefaultPlaylistPrivacy = ref("public");
+
 const {
 	nightmode,
 	autoSkipDisliked,
 	activityLogPublic,
 	anonymousSongRequests,
-	activityWatch
+	activityWatch,
+	defaultStationPrivacy,
+	defaultPlaylistPrivacy
 } = storeToRefs(userPreferencesStore);
 
 const saveChanges = () => {
@@ -34,7 +39,9 @@ const saveChanges = () => {
 		localAutoSkipDisliked.value === autoSkipDisliked.value &&
 		localActivityLogPublic.value === activityLogPublic.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.");
 
@@ -50,7 +57,9 @@ const saveChanges = () => {
 			autoSkipDisliked: localAutoSkipDisliked.value,
 			activityLogPublic: localActivityLogPublic.value,
 			anonymousSongRequests: localAnonymousSongRequests.value,
-			activityWatch: localActivityWatch.value
+			activityWatch: localActivityWatch.value,
+			defaultStationPrivacy: localDefaultStationPrivacy.value,
+			defaultPlaylistPrivacy: localDefaultPlaylistPrivacy.value
 		},
 		res => {
 			if (res.status !== "success") {
@@ -76,6 +85,10 @@ onMounted(() => {
 				localAnonymousSongRequests.value =
 					preferences.anonymousSongRequests;
 				localActivityWatch.value = preferences.activityWatch;
+				localDefaultStationPrivacy.value =
+					preferences.defaultStationPrivacy;
+				localDefaultPlaylistPrivacy.value =
+					preferences.defaultPlaylistPrivacy;
 			}
 		});
 	});
@@ -98,6 +111,14 @@ onMounted(() => {
 
 		if (preferences.activityWatch !== undefined)
 			localActivityWatch.value = preferences.activityWatch;
+
+		if (preferences.defaultStationPrivacy !== undefined)
+			localDefaultStationPrivacy.value =
+				preferences.defaultStationPrivacy;
+
+		if (preferences.defaultPlaylistPrivacy !== undefined)
+			localDefaultPlaylistPrivacy.value =
+				preferences.defaultPlaylistPrivacy;
 	});
 });
 </script>
@@ -187,6 +208,22 @@ onMounted(() => {
 			</label>
 		</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()" />
 	</div>
 </template>

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

@@ -22,13 +22,15 @@ const { socket } = useWebsocketsStore();
 const saveButton = ref();
 
 const { userId } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
+const { originalUser, modifiedUser } = storeToRefs(settingsStore);
 
 const { updateOriginalUser } = settingsStore;
 
 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))
 		return new Toast("Name must have between 1 and 64 characters.");
@@ -62,7 +64,7 @@ const changeName = () => {
 };
 
 const changeLocation = () => {
-	const { location } = modifiedUser;
+	const { location } = modifiedUser.value;
 
 	if (!validation.isLength(location, 0, 50))
 		return new Toast("Location must have between 0 and 50 characters.");
@@ -92,7 +94,7 @@ const changeLocation = () => {
 };
 
 const changeBio = () => {
-	const { bio } = modifiedUser;
+	const { bio } = modifiedUser.value;
 
 	if (!validation.isLength(bio, 0, 200))
 		return new Toast("Bio must have between 0 and 200 characters.");
@@ -117,7 +119,7 @@ const changeBio = () => {
 };
 
 const changeAvatar = () => {
-	const { avatar } = modifiedUser;
+	const { avatar } = modifiedUser.value;
 
 	saveButton.value.status = "disabled";
 
@@ -139,12 +141,13 @@ const changeAvatar = () => {
 };
 
 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 =
-		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 (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 { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
-import { useSettingsStore } from "@/stores/settings";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import _validation from "@/validation";
@@ -16,8 +15,7 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const configStore = useConfigStore();
-const { githubAuthentication, sitename } = storeToRefs(configStore);
-const settingsStore = useSettingsStore();
+const { oidcAuthentication } = storeToRefs(configStore);
 const userAuthStore = useUserAuthStore();
 
 const { socket } = useWebsocketsStore();
@@ -40,7 +38,6 @@ const validation = reactive({
 const newPassword = ref();
 const oldPassword = ref();
 
-const { isPasswordLinked, isGithubLinked } = settingsStore;
 const { userId } = storeToRefs(userAuthStore);
 
 const togglePasswordVisibility = refName => {
@@ -57,6 +54,8 @@ const onInput = inputName => {
 	validation[inputName].entered = true;
 };
 const changePassword = () => {
+	if (oidcAuthentication.value) return null;
+
 	const newPassword = validation.newPassword.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 = () => {
 	socket.dispatch(`users.removeSessions`, userId.value, res => {
 		new Toast(res.message);
@@ -115,7 +104,7 @@ watch(validation, newValidation => {
 
 <template>
 	<div class="content security-tab">
-		<div v-if="isPasswordLinked">
+		<template v-if="!oidcAuthentication">
 			<h4 class="section-title">Change password</h4>
 
 			<p class="section-description">
@@ -195,73 +184,7 @@ watch(validation, newValidation => {
 			</p>
 
 			<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>
 			<h4 class="section-title">Log out everywhere</h4>

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

@@ -58,27 +58,6 @@ onMounted(() => {
 			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>
 
@@ -214,13 +193,19 @@ onMounted(() => {
 		margin: 24px 0;
 		height: fit-content;
 
-		.control:not(:first-of-type) {
+		.control.checkbox-control:not(:first-of-type),
+		.control.input-with-label {
 			margin: 10px 0;
 		}
 
+		.select {
+			margin: 0 !important;
+		}
+
 		label {
 			font-size: 14px;
 			color: var(--dark-grey-2);
+			font-weight: 500;
 		}
 
 		textarea {

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

@@ -67,7 +67,7 @@ const copyToClipboard = async () => {
 		await navigator.clipboard.writeText(
 			configStore.urls.client + route.fullPath
 		);
-	} catch (err) {
+	} catch {
 		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;
 			key: string;
 		};
-		githubAuthentication: boolean;
+		oidcAuthentication: boolean;
 		messages: Record<string, string>;
 		christmas: boolean;
 		footerLinks: Record<string, string | boolean>;
@@ -17,6 +17,7 @@ export const useConfigStore = defineStore("config", {
 		registrationDisabled: boolean;
 		mailEnabled: boolean;
 		discogsEnabled: boolean;
+		passwordResetEnabled: boolean;
 		experimental: {
 			changable_listen_mode: string[] | boolean;
 			media_session: boolean;
@@ -32,7 +33,7 @@ export const useConfigStore = defineStore("config", {
 			enabled: false,
 			key: ""
 		},
-		githubAuthentication: false,
+		oidcAuthentication: false,
 		messages: {
 			accountRemoval:
 				"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,
 		mailEnabled: true,
 		discogsEnabled: true,
+		passwordResetEnabled: true,
 		experimental: {
 			changable_listen_mode: [],
 			media_session: false,

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

@@ -28,9 +28,5 @@ export const useSettingsStore = defineStore("settings", {
 			this.originalUser = 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 {
 				autorequestDisallowRecentlyPlayedEnabled,
 				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 (

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

@@ -7,12 +7,16 @@ export const useUserPreferencesStore = defineStore("userPreferences", {
 		activityLogPublic: boolean;
 		anonymousSongRequests: boolean;
 		activityWatch: boolean;
+		defaultStationPrivacy: "public" | "unlisted" | "private";
+		defaultPlaylistPrivacy: "public" | "private";
 	} => ({
 		nightmode: false,
 		autoSkipDisliked: true,
 		activityLogPublic: false,
 		anonymousSongRequests: false,
-		activityWatch: false
+		activityWatch: false,
+		defaultStationPrivacy: "private",
+		defaultPlaylistPrivacy: "public"
 	}),
 	actions: {
 		changeNightmode(nightmode) {
@@ -30,6 +34,12 @@ export const useUserPreferencesStore = defineStore("userPreferences", {
 		},
 		changeActivityWatch(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;
 			};
 		};
-		github?: {
-			id: number;
+		oidc?: {
+			sub: string;
 			access_token: string;
 		};
 	};
 	password?: boolean;
-	github?: boolean;
+	oidc?: boolean;
 	statistics: {
 		songsRequested: number;
 	};
@@ -48,5 +48,7 @@ export interface User {
 		activityLogPublic: boolean;
 		anonymousSongRequests: 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;
 
-if (process.env.FRONTEND_MODE === "development")
+if (process.env.APP_ENV === "development")
 	server = {
 		host: "0.0.0.0",
 		port: process.env.FRONTEND_DEV_PORT ?? 81,
@@ -133,7 +133,7 @@ if (process.env.FRONTEND_MODE === "development")
 	};
 
 export default {
-	mode: process.env.FRONTEND_MODE,
+	mode: process.env.APP_ENV,
 	root: "src",
 	publicDir: "../dist",
 	base: "/",

+ 496 - 420
musare.sh

@@ -1,36 +1,50 @@
 #!/bin/bash
 
+set -e
+
 export PATH=/usr/local/bin:/usr/bin:/bin
 
+# Color variables
 CYAN='\033[33;36m';
 RED='\033[0;31m'
 YELLOW='\033[0;93m'
 GREEN='\033[0;32m'
 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)")
-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
+# 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
 
-docker="${DOCKER_COMMAND}"
+set +e
+
+# Check if docker is installed
 ${docker} --version > /dev/null 2>&1
 dockerInstalled=$?
 
+# Define docker compose command
 dockerCompose="${docker} compose"
+
+# Check if docker compose is installed
 ${dockerCompose} version > /dev/null 2>&1
 composeInstalled=$?
 if [[ ${composeInstalled} -gt 0 ]]; then
@@ -39,31 +53,44 @@ if [[ ${composeInstalled} -gt 0 ]]; then
     composeInstalled=$?
 fi
 
+set -e
+
+# Exit if docker and/or docker compose is not installed
 if [[ ${dockerInstalled} -gt 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
-    exit 1
+
+    throw "Error: ${docker} and ${dockerCompose} not installed."
 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
-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"
 fi
 dockerCompose="${dockerCompose} ${composeFiles}"
 
+# Parse services from arguments string
 handleServices()
 {
+    # shellcheck disable=SC2206
     validServices=($1)
     servicesArray=()
     invalidServices=false
+
     for x in "${@:2}"; do
         if [[ ${validServices[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
             if ! [[ ${servicesArray[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
@@ -77,6 +104,7 @@ handleServices()
             fi
         fi
     done
+
     if [[ $invalidServices == false && ${#servicesArray[@]} -gt 0 ]]; then
         echo "1|${servicesArray[*]}"
     elif [[ $invalidServices == false ]]; then
@@ -86,81 +114,451 @@ handleServices()
     fi
 }
 
+# Execute a docker command
 runDockerCommand()
 {
     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
-                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
+            ;;
 
-            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
-            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
-            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
-
-            exitValue=$?
-            if [[ ${exitValue} -gt 0 ]]; then
-                exit ${exitValue}
+            if [[ -n $envChange ]]; then
+                echo -e "${RED}Environment config has changed, please update!${NC}"
             fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]${NC}"
-            exit 1
         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
-        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
+    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
-        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
-    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
     start)
         echo -e "${CYAN}Musare | Start Services${NC}"
@@ -196,272 +594,43 @@ case $1 in
 
     reset)
         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)
         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)
         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
-        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)
         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
-        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)
         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)
         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)
         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)
@@ -472,125 +641,32 @@ case $1 in
 
     backup)
         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)
         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)
         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 "${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 "${RED}Error: Invalid Command $1${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
         ;;
 esac

+ 2 - 0
types/models/User.ts

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

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