diff --git a/.gitignore b/.gitignore index e5d67ca..0f8d505 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ README-old.md utils-old/ coverage/ _depricated/ +_deprecated/ ### Terraform ### # Local .terraform directories diff --git a/.vscode/settings.json b/.vscode/settings.json index 20df271..7b3712b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,3 @@ -// { -// "files.associations": { -// "*.sh.tpl": "shellscript", -// "*.json.tpl": "JSON", -// "*.conf.tpl": "NGINX Conf" -// } -// } \ No newline at end of file +{ + "java.compile.nullAnalysis.mode": "disabled" +} diff --git a/docker-compose.yml b/docker-compose.yml index 8df7d6e..2d67fa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: - MONGO_URI=mongodb://root:changeme@mongo-jbx:27017 - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} + - LOG_LEVEL=debug ports: - 8000:8000 depends_on: diff --git a/package-lock.json b/package-lock.json index 68d6928..72439b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "dependencies": { "@jukebox/config": "^1.0.0", "@jukebox/lib": "^1.0.0", + "@spotify/web-api-ts-sdk": "^1.2.0", "axios": "^1.6.7", "bcrypt": "^5.1.1", "body-parser": "^1.20.2", @@ -68,7 +69,7 @@ "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", "tsx": "^4.7.0", - "typescript": "^4.9.5" + "typescript": "^5.5.4" } }, "node_modules/@ampproject/remapping": { @@ -1860,9 +1861,9 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.8.tgz", - "integrity": "sha512-qKwC/M/nNNaKUBMQ0nuzm47b7ZYWQHN3pcXq4IIcoSBc2hOIrflAxJduIvvqmhoz3gR2TacTAs8vlsCVPkiEdQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", "license": "MIT", "optional": true, "dependencies": { @@ -1920,6 +1921,13 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1953,6 +1961,12 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@spotify/web-api-ts-sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@spotify/web-api-ts-sdk/-/web-api-ts-sdk-1.2.0.tgz", + "integrity": "sha512-JUaebva3Ohwo5I5tuTqyW/FKGOMbb40YevJMySAOINRxP7qQ/AMjBzfJx0zeO6yS+wAPfQSoGNsZaUggHw8vsA==", + "license": "Apache" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2229,9 +2243,9 @@ } }, "node_modules/@types/node": { - "version": "22.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", - "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -2755,6 +2769,20 @@ "node": ">=10" } }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2934,9 +2962,9 @@ } }, "node_modules/axios": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", - "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -3373,9 +3401,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001655", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz", - "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==", + "version": "1.0.30001658", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001658.tgz", + "integrity": "sha512-N2YVqWbJELVdrnsW5p+apoQyYt51aBMSsBZki1XZEfeBCexcM/sf4xiAHcXQBkuOwJBXtWF7aW1sYX6tKebPHw==", "dev": true, "funding": [ { @@ -3639,42 +3667,6 @@ "typedarray": "^0.0.6" } }, - "node_modules/concat-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -3855,12 +3847,12 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4091,9 +4083,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", - "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "version": "1.5.18", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz", + "integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==", "dev": true, "license": "ISC" }, @@ -4535,9 +4527,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.2.tgz", - "integrity": "sha512-3XnC5fDyc8M4J2E8pt8pmSVRX2M+5yWMCfI/kDZwauQeFgzQOuhcRBFKjTeJagqgk4sFKxe1mvNVnaWwImx/Tg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz", + "integrity": "sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4585,27 +4577,28 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", + "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", + "eslint-module-utils": "^2.9.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", "semver": "^6.3.1", "tsconfig-paths": "^3.15.0" }, @@ -5291,9 +5284,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -6300,10 +6293,9 @@ } }, "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, "node_modules/isexe": { @@ -7556,15 +7548,15 @@ "license": "ISC" }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, "bin": { "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/module-alias": { @@ -7646,12 +7638,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -7717,9 +7703,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/multer": { @@ -7740,18 +7726,6 @@ "node": ">= 6.0.0" } }, - "node_modules/multer/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mylas": { "version": "2.1.13", "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", @@ -8324,9 +8298,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -8663,19 +8637,26 @@ "license": "MIT" }, "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8859,6 +8840,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8963,12 +8951,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -9279,14 +9261,20 @@ } }, "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.2.0" + "safe-buffer": "~5.1.0" } }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9601,6 +9589,18 @@ "node": ">=10" } }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -9880,6 +9880,19 @@ } } }, + "node_modules/ts-node-dev/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-node-dev/node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -10571,9 +10584,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10581,7 +10594,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { @@ -10883,6 +10896,34 @@ "node": ">= 12.0.0" } }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11072,13 +11113,6 @@ "node": "^12.20.0 || >=14" } }, - "packages/a": { - "name": "jukebox", - "version": "1.0.0", - "extraneous": true, - "license": "ISC", - "devDependencies": {} - }, "packages/config": { "name": "@jukebox/config", "version": "1.0.0", diff --git a/package.json b/package.json index 8816e28..1b6ad8b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@jukebox/config": "^1.0.0", "@jukebox/lib": "^1.0.0", + "@spotify/web-api-ts-sdk": "^1.2.0", "axios": "^1.6.7", "bcrypt": "^5.1.1", "body-parser": "^1.20.2", @@ -77,7 +78,7 @@ "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", "tsx": "^4.7.0", - "typescript": "^4.9.5" + "typescript": "^5.5.4" }, "workspaces": [ "packages/a", diff --git a/server/config/constants.ts b/server/config/constants.ts index 700433f..3fea327 100644 --- a/server/config/constants.ts +++ b/server/config/constants.ts @@ -16,6 +16,7 @@ export const AUTH_TOKEN_COOKIE_NAME = 'dev-auth-token' export const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID || 'changeme' export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET || 'changeme' +export const SPOTIFY_REDIRECT_URI = process.env.SPOITFY_REDIRECT_URI || 'http://localhost:8000/api/spotify/login-callback/' // export const LOG_LEVEL = process.env.LOG_LEVEL || 'warn' // export const LOG_NS = process.env.LOG_NS || 'server' diff --git a/server/config/server.ts b/server/config/server.ts index a6303e7..442560d 100644 --- a/server/config/server.ts +++ b/server/config/server.ts @@ -3,6 +3,7 @@ import morgan from 'morgan' import multer from 'multer' import { logger } from '@jukebox/lib' +import cookieParser from 'cookie-parser' import { errorHandler } from 'server/middleware' import { router } from 'server/routes' @@ -14,6 +15,7 @@ const jsonParser = express.json() server.use(urlencodedParser) server.use(jsonParser) server.use(multer().any()) +server.use(cookieParser()) server.use( morgan(':remote-addr :method :url :status :res[content-length] - :response-time ms', { diff --git a/server/controllers/baseController.ts b/server/controllers/baseController.ts deleted file mode 100644 index 79083a0..0000000 --- a/server/controllers/baseController.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @fileoverview General routes for the project. - */ -import 'dotenv/config' -import { httpOk } from '../utils' - -export const healthCheck = async (_: any, res: any) => { - /**======================* - #swagger.tags = ['Base'] - #swagger.summary = "Base route" - #swagger.description = "Base starting point of the project, displays important data" - #swagger.responses[200] = { - description: 'Example data with redactions', - schema: { - spotifyLogin: 'http://localhost:8000/api/spotify/login/', - documentation: "http://localhost:8000/api/docs/", - } - } - *========================*/ - return httpOk(res, { - spotifyLogin: 'http://localhost:8000/api/spotify/login/', - documenation: 'http://localhost:8000/api/docs/' - }) -} diff --git a/server/controllers/groupController.ts b/server/controllers/groupController.ts index 3749c4d..5f4d9a6 100644 --- a/server/controllers/groupController.ts +++ b/server/controllers/groupController.ts @@ -1,151 +1,81 @@ -import type { Request, Response } from 'express' -import type { AuthLocals } from 'server/middleware' -import { Group, User } from 'server/models' -import { AuthService, GroupService } from 'server/services' -import { httpBadRequest, httpCreated, httpNoContent, httpNotFound, httpOk } from 'server/utils' - -// TODO: Requires permission -export const createGroup = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - */ - const { body } = req - const { user } = res.locals - - try { - // const group = await Group.create({ ...body, ownerId: user._id }) - const { name } = body // TODO: Validate body, explicitly define fields - - const group = await GroupService.createGroup(user, name, body) - return httpCreated(res, group) - } catch (error: any) { - return httpBadRequest(res, error?.message) +import type { Model } from 'mongoose' +import { Group, SpotifyAuth, type User } from 'server/models' +import { SpotifyService } from 'server/services' +import { NotFoundError } from 'server/utils' + +const getOrError = async >(id: string, model: T): Promise> => { + const query = await model.findById(id) + if (!query) { + throw new NotFoundError(`${model.name} with id ${id} not found.`) } -} -// TODO: Requires permission -export const createGroupMember = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - */ - const { groupId } = req.params - const { email, options } = req.body + return query +} - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') +export const assignSpotifyToGroup = async ( + user: User, + groupId: string, + spotifyEmail: string +): Promise => { + const auth = await SpotifyAuth.findOne({ userId: user._id.toString(), spotifyEmail }) - try { - const userFound: User | null = await User.findOne({ email: email }) - let user: User + if (!auth) + throw new Error(`User ${user.email} is not connected to spotify account ${spotifyEmail}.`) - if (!userFound) { - user = await AuthService.inviteUser(email) - } else { - user = userFound - } + const group = await getOrError(groupId, Group) - const newMembership = await GroupService.registerGroupMember(group, user, options) - return httpCreated(res, newMembership) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } + await group.updateOne({ spotifyAuthId: auth._id }, { new: true }) + return group } -export const getGroup = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - */ - const { groupId } = req.params +export const getGroupSpotify = async (groupId: string) => { + const group = await getOrError(groupId, Group) - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') + const auth = await SpotifyAuth.findById(group.spotifyAuthId) + if (!auth) throw new Error(`No linked Spotify accounts for group ${group.name}.`) - return httpOk(res, group) + return SpotifyService.connect(auth.spotifyEmail) } -export const updateGroup = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - #swagger.summary = "Not implemented" - */ - const { groupId } = req.params - const { ownerId, name, spotifyToken } = req.body - - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') - - try { - // TODO: Validate body - // const updatedGroup = await group.updateOne({ ownerId, name, spotifyToken }, { new: true }) - const updatedGroup = await Group.findByIdAndUpdate( - groupId, - { ownerId, name, spotifyToken }, - { new: true } - ) - - return httpOk(res, updatedGroup) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } +export const getGroupTrack = async (groupId: string) => { + const spotify = await getGroupSpotify(groupId) + return await spotify.sdk.player.getCurrentlyPlayingTrack() } -export const deleteGroup = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - #swagger.summary = "Not implemented" - */ - const { groupId } = req.params - - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') - - try { - await GroupService.deleteGroup(group) - return httpNoContent(res) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } +export const getGroupDevices = async (groupId: string) => { + const spotify = await getGroupSpotify(groupId) + return await spotify.sdk.player.getAvailableDevices() } -export const getGroupMembers = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - #swagger.summary = "Not implemented" - */ - const { groupId } = req.params +export const setGroupDefaultDevice = async (groupId: string, deviceId: string) => { + const group = await getOrError(groupId, Group) + group.defaultDeviceId = deviceId + await group.save() - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') - - try { - const members = await GroupService.getGroupMembers(group) - return httpOk(res, members) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } + return group } -export const getGroupMemberships = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - #swagger.summary = "Not implemented" - */ - const { groupId } = req.params - - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') - - try { - const memberships = await GroupService.getGroupMemberships(group) - return httpOk(res, memberships) - } catch (error: any) { - return httpBadRequest(res, error?.message) +export const setGroupPlayerState = async ( + groupId: string, + state: 'play' | 'pause' | 'next' | 'previous' +) => { + const spotify = await getGroupSpotify(groupId) + const group = await getOrError(groupId, Group) + + switch (state) { + case 'play': + await spotify.sdk.player.startResumePlayback(group.defaultDeviceId ?? '') + break + case 'pause': + await spotify.sdk.player.pausePlayback(group.defaultDeviceId ?? '') + break + case 'next': + await spotify.sdk.player.skipToNext(group.defaultDeviceId ?? '') + break + case 'previous': + await spotify.sdk.player.skipToPrevious(group.defaultDeviceId ?? '') + break + default: + throw new Error(`Cannot set player state to ${state}.`) } } diff --git a/server/controllers/spotifyController.ts b/server/controllers/spotifyController.ts deleted file mode 100644 index fa11d14..0000000 --- a/server/controllers/spotifyController.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Request, Response } from 'express' -import { User } from 'server/models' -import { SpotifyService } from 'server/services' -import { - httpBadRequest, - httpNotImplemented, - httpOk, - httpSeeOther, - httpUnauthorized -} from 'server/utils' -import type { AuthLocals } from './../middleware/authMiddleware' - -export const spotifyLogin = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - */ - const { user } = res.locals - const { redirectUri, groupId } = req.query - - const spotifyLoginUri = SpotifyService.getSpotifyRedirectUri({ - finalRedirect: String(redirectUri || ''), - userId: String(user._id), - groupId: String(groupId) - }) - - return httpSeeOther(res, spotifyLoginUri) -} - -export const spotifyLoginCallback = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - */ - const { code, state } = req.query - - try { - const parsedState = JSON.parse(JSON.parse(JSON.stringify(state))) // TODO: Fix double parse - const { userId, finalRedirect } = parsedState - if (!userId) return httpBadRequest(res, 'Spotify state mismatch error.') - - const user: User | null = await User.findById(userId) - if (!user) return httpUnauthorized(res) - - const { accessToken, refreshToken, expiresAt } = await SpotifyService.requestSpotifyToken( - String(code) - ) - await user.updateOne({ - spotifyAccessToken: accessToken, - spotifyRefreshToken: refreshToken, - spotifyTokenExpiration: expiresAt - }) - - if (finalRedirect && String(finalRedirect) !== 'undefined' && finalRedirect !== '') { - return httpSeeOther(res, finalRedirect) - } else { - return httpOk(res, { accessToken, refreshToken }) - } - } catch (error: any) { - return httpBadRequest(res, error?.message || error) - } -} - -export const getUserProfile = async (_: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - */ - const { spotifyAccessToken } = res.locals - const spotify = new SpotifyService(spotifyAccessToken) - - try { - const profile = await spotify.getProfile() - - return httpOk(res, profile) - } catch (error: any) { - return httpBadRequest(res, 'Error getting user profile: ' + error?.message) - } -} - -export const spotifySearch = (_: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - #swagger.summary = "Not implemented" - */ - return httpNotImplemented(res) -} - -export const spotifySearchTracks = (_: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - #swagger.summary = "Not implemented" - */ - return httpNotImplemented(res) -} - -export const spotifySearchTrackId = (_: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - #swagger.summary = "Not implemented" - */ - return httpNotImplemented(res) -} - -export const getCurrentlyPlaying = (_: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Spotify'] - #swagger.summary = "Not implemented" - */ - return httpNotImplemented(res) -} diff --git a/server/controllers/userController.ts b/server/controllers/userController.ts index 6449b52..28c4b91 100644 --- a/server/controllers/userController.ts +++ b/server/controllers/userController.ts @@ -1,4 +1,4 @@ -import { User } from 'server/models' +import { SpotifyAuth, User } from 'server/models' import { AuthService } from 'server/services' import { NotFoundError } from 'server/utils' @@ -29,3 +29,8 @@ export const resetPassword = async (email: string, newPassword: string): Promise const updatedUser: User = await AuthService.changePassword(user, newPassword) return true } + +export const getUserSpotifyEmails = async (user: User): Promise => { + const auths = await SpotifyAuth.find({ userId: user._id }) + return auths.map((auth) => auth.spotifyEmail) +} diff --git a/server/docs/swagger.ts b/server/docs/swagger.ts index e4dfeb4..98959b6 100644 --- a/server/docs/swagger.ts +++ b/server/docs/swagger.ts @@ -1,4 +1,5 @@ import { BASE_URL } from 'server/config' +import type { IGroup } from 'server/models' import { ResponseCodes, formatJsonResponse } from 'server/utils' import swaggerAutogen from 'swagger-autogen' @@ -32,23 +33,18 @@ const doc = { description: 'Communicate with Spotify' } ], - components: { - securitySchemes: { - Bearer: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - in: 'header', - description: 'Token used to authenticate with network.' - } + components: {}, + securityDefinitions: { + Bearer: { + type: 'apiKey', + name: 'Authorization', + in: 'header', + description: 'The token for authentication into system.' } }, - security: [ - { - Bearer: [] - } - ], - definitions: {} + definitions: { + Group: { name: '', ownerId: '' } as IGroup + } } const generateResponseDocs = () => { const codes = ResponseCodes diff --git a/server/docs/swagger_output.json b/server/docs/swagger_output.json index 47ffd9e..6e28d6c 100644 --- a/server/docs/swagger_output.json +++ b/server/docs/swagger_output.json @@ -5,7 +5,7 @@ "title": "Jukebox API", "description": "Documentation automatically generated by the swagger-autogen module." }, - "host": "localhost:8080", + "host": "localhost:8000", "basePath": "/", "tags": [ { @@ -25,6 +25,14 @@ "http", "https" ], + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "The token for authentication into system." + } + }, "consumes": [ "application/json" ], @@ -113,7 +121,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/spotify/login-callback": { @@ -162,12 +175,26 @@ } } }, - "/api/spotify/me": { - "get": { + "/api/spotify/": { + "delete": { "tags": [ "Spotify" ], "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "spotifyEmail": { + "example": "any" + } + } + } + } + ], "responses": { "400": { "schema": { @@ -193,16 +220,37 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/spotify/search": { - "get": { + "/api/user/register": { + "post": { "tags": [ - "Spotify" + "User" ], - "summary": "Not implemented", "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "password": { + "example": "any" + } + } + } + } + ], "responses": { "400": { "schema": { @@ -231,13 +279,29 @@ } } }, - "/api/spotify/tracks": { - "get": { + "/api/user/token": { + "post": { "tags": [ - "Spotify" + "User" ], - "summary": "Not implemented", "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "password": { + "example": "any" + } + } + } + } + ], "responses": { "400": { "schema": { @@ -266,19 +330,24 @@ } } }, - "/api/spotify/tracks/{id}": { - "get": { + "/api/user/request-password-reset": { + "post": { "tags": [ - "Spotify" + "User" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "id", - "in": "path", - "required": true, - "type": "string" + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + } + } + } } ], "responses": { @@ -306,10 +375,15 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/user/register": { + "/api/user/reset-password": { "post": { "tags": [ "User" @@ -357,32 +431,20 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/user/login": { - "post": { + "/api/user/me": { + "get": { "tags": [ "User" ], "description": "", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "password": { - "example": "any" - } - } - } - } - ], "responses": { "400": { "schema": { @@ -408,10 +470,15 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/user/me": { + "/api/user/me/spotify-accounts": { "get": { "tags": [ "User" @@ -442,23 +509,34 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/user/request-password-reset": { + "/api/group/{id}/spotify": { "post": { "tags": [ - "User" + "Group" ], "description": "", "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, { "name": "body", "in": "body", "schema": { "type": "object", "properties": { - "email": { + "spotifyEmail": { "example": "any" } } @@ -490,30 +568,26 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/user/reset-password": { - "post": { + "/api/group/{id}/spotify/current-track": { + "get": { "tags": [ - "User" + "Group" ], "description": "", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "password": { - "example": "any" - } - } - } + "name": "id", + "in": "path", + "required": true, + "type": "string" } ], "responses": { @@ -541,23 +615,34 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/group/groups": { + "/api/group/{id}/spotify/state": { "post": { "tags": [ "Group" ], "description": "", "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, { "name": "body", "in": "body", "schema": { "type": "object", "properties": { - "name": { + "state": { "example": "any" } } @@ -589,10 +674,62 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/group/{id}/spotify/devices": { + "get": { + "tags": [ + "Group" + ], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "schema": { + "$ref": "#/definitions/Error400" + }, + "description": "Bad request" + }, + "404": { + "schema": { + "$ref": "#/definitions/Error404" + }, + "description": "Not found" + }, + "500": { + "schema": { + "$ref": "#/definitions/Error500" + }, + "description": "Internal Server Error" + }, + "501": { + "schema": { + "$ref": "#/definitions/Error501" + }, + "description": "Not implemented" + } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/group/groups/{groupId}/members": { + "/api/group/{id}/spotify/default-device": { "post": { "tags": [ "Group" @@ -600,7 +737,7 @@ "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -611,10 +748,7 @@ "schema": { "type": "object", "properties": { - "email": { - "example": "any" - }, - "options": { + "deviceId": { "example": "any" } } @@ -646,9 +780,16 @@ }, "description": "Not implemented" } - } - }, - "get": { + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/group/{id}/jam": { + "post": { "tags": [ "Group" ], @@ -656,7 +797,7 @@ "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -688,17 +829,16 @@ "description": "Not implemented" } } - } - }, - "/api/group/groups/{groupId}": { - "get": { + }, + "delete": { "tags": [ "Group" ], + "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -730,39 +870,51 @@ "description": "Not implemented" } } - }, - "put": { + } + }, + "/api/group/groups": { + "post": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string" + "responses": { + "400": { + "schema": { + "$ref": "#/definitions/Error400" + }, + "description": "Bad request" }, - { - "name": "body", - "in": "body", + "404": { "schema": { - "type": "object", - "properties": { - "ownerId": { - "example": "any" - }, - "name": { - "example": "any" - }, - "spotifyToken": { - "example": "any" - } - } - } + "$ref": "#/definitions/Error404" + }, + "description": "Not found" + }, + "500": { + "schema": { + "$ref": "#/definitions/Error500" + }, + "description": "Internal Server Error" + }, + "501": { + "schema": { + "$ref": "#/definitions/Error501" + }, + "description": "Not implemented" + } + }, + "security": [ + { + "Bearer": [] } + ] + }, + "get": { + "tags": [ + "Group" ], + "description": "", "responses": { "400": { "schema": { @@ -788,38 +940,26 @@ }, "description": "Not implemented" } - } - }, - "patch": { + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/group/groups/{id}": { + "get": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" - }, - { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "ownerId": { - "example": "any" - }, - "name": { - "example": "any" - }, - "spotifyToken": { - "example": "any" - } - } - } } ], "responses": { @@ -847,17 +987,21 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, - "delete": { + "put": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -888,19 +1032,21 @@ }, "description": "Not implemented" } - } - } - }, - "/api/group/groups/{groupId}/jam": { - "post": { + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "patch": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -931,17 +1077,21 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "delete": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -972,11 +1122,29 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } } }, "definitions": { + "Group": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "" + }, + "ownerId": { + "type": "string", + "example": "" + } + } + }, "Success200": { "type": "object", "properties": { @@ -1300,21 +1468,5 @@ } } } - }, - "components": { - "securitySchemes": { - "Bearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "in": "header", - "description": "Token used to authenticate with network." - } - } - }, - "security": [ - { - "Bearer": [] - } - ] + } } \ No newline at end of file diff --git a/server/lib/index.ts b/server/lib/index.ts new file mode 100644 index 0000000..6c6a464 --- /dev/null +++ b/server/lib/index.ts @@ -0,0 +1 @@ +export * from './spotify' \ No newline at end of file diff --git a/server/lib/spotify.ts b/server/lib/spotify.ts new file mode 100644 index 0000000..9e13a50 --- /dev/null +++ b/server/lib/spotify.ts @@ -0,0 +1,116 @@ +/** + * Resources + * - Repo: https://github.com/spotify/spotify-web-api-ts-sdk/tree/main + * - Authentication: https://developer.spotify.com/documentation/web-api/tutorials/code-flow + * - Scopes: https://developer.spotify.com/documentation/web-api/concepts/scopes + */ +import { SpotifyApi } from '@spotify/web-api-ts-sdk' +import axios from 'axios' +import { stringify } from 'querystring' +import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REDIRECT_URI } from 'server/config' + +const SPOTIFY_SCOPES = [ + 'user-read-private', + 'user-read-email', + 'playlist-modify-public', + 'playlist-modify-private', + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing', + 'app-remote-control', + 'streaming' +] + +export type SpotifySdk = SpotifyApi + +export interface SpotifyTokens { + accessToken: string + refreshToken: string + expiresIn: number + tokenType: string +} + +type SpotifyAuthReqBody = + | { + grant_type: 'authorization_code' + code: string + redirect_uri: string + } + | { + grant_type: 'refresh_token' + refresh_token: string + } + +export const getSpotifySdk = (tokens: SpotifyTokens): SpotifySdk => { + return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + expires_in: tokens.expiresIn, + token_type: tokens.tokenType + }) +} + +export const getSpotifyRedirectUri = (state: { userId: string; finalRedirect?: string }) => { + const stateString = JSON.stringify(state) + + const url = + 'https://accounts.spotify.com/authorize?' + + stringify({ + response_type: 'code', + client_id: SPOTIFY_CLIENT_ID, + scope: SPOTIFY_SCOPES.join(', '), + redirect_uri: SPOTIFY_REDIRECT_URI, + state: stateString + }) + + return url +} + +export const authenticateSpotify = async (params: { + type: 'authorization_code' | 'refresh_token' + payload: string +}): Promise => { + const body: SpotifyAuthReqBody = + params.type === 'authorization_code' + ? { + grant_type: params.type, + code: params.payload, + redirect_uri: SPOTIFY_REDIRECT_URI + } + : { + grant_type: params.type, + refresh_token: params.payload + } + const spotifyAuthBuffer: Buffer = Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET) + + const spotifyRes = await axios + .post('https://accounts.spotify.com/api/token', body, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + spotifyAuthBuffer.toString('base64') + } + }) + .catch((error: any) => { + console.log('Error authorizing with spotify:', error) + throw new Error(error?.response?.data?.error_description || error) + }) + + if (spotifyRes.status > 299 || !spotifyRes.data) + throw new Error('Error authenticating with spotify.') + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + token_type: tokenType + } = spotifyRes.data + + return { accessToken, refreshToken, expiresIn, tokenType } +} + +export const getSpotifyEmail = async (tokens: SpotifyTokens) => { + const sdk = getSpotifySdk(tokens) + const userProfile = await sdk.currentUser.profile() + + return userProfile.email +} diff --git a/server/middleware/authMiddleware.ts b/server/middleware/authMiddleware.ts index 474e6ff..d700355 100644 --- a/server/middleware/authMiddleware.ts +++ b/server/middleware/authMiddleware.ts @@ -4,9 +4,8 @@ import type { Jwt } from 'jsonwebtoken' import jwt from 'jsonwebtoken' -import { NODE_ENV } from '@jukebox/config' import type { NextFunction, Request, Response } from 'express' -import { AUTH_TOKEN_COOKIE_NAME, JWT_ALGORITHM, JWT_ISSUER, JWT_SECRET_KEY } from 'server/config' +import { JWT_ALGORITHM, JWT_ISSUER, JWT_SECRET_KEY } from 'server/config' import { User } from 'server/models' import { httpUnauthorized } from '../utils' @@ -19,14 +18,19 @@ export type AuthResponse = Response & { } export const isAuthenticated = async (req: Request, res: Response, next: NextFunction) => { - // if (NODE_ENV === 'development') return next() + /** + @swagger + #swagger.security = [{ + "Bearer": [] + }] + */ let token: string = req.headers['authorization'] || '' let jwtPayload: Jwt - if (NODE_ENV === 'development') { - token = String(req.cookies[AUTH_TOKEN_COOKIE_NAME]) - } + // if (NODE_ENV === 'development') { + // token = String(req.cookies[AUTH_TOKEN_COOKIE_NAME]) + // } try { jwtPayload = jwt.verify(token.split(' ')[1], JWT_SECRET_KEY, { diff --git a/server/middleware/errorHandler.ts b/server/middleware/errorHandler.ts index afd04be..3bba307 100644 --- a/server/middleware/errorHandler.ts +++ b/server/middleware/errorHandler.ts @@ -4,8 +4,10 @@ import { httpBadRequest, httpNotFound, httpNotImplemented, + httpUnauthorized, NotFoundError, - NotImplementedError + NotImplementedError, + UnauthorizedError } from 'server/utils' export const errorHandler = (error: Error, req: Request, res: Response, next: NextFunction) => { @@ -15,6 +17,8 @@ export const errorHandler = (error: Error, req: Request, res: Response, next: Ne return next(httpNotFound(res, error)) } else if (error instanceof NotImplementedError) { return next(httpNotImplemented(res, error)) + } else if (error instanceof UnauthorizedError) { + return next(httpUnauthorized(res, error)) } else { return next(httpBadRequest(res, error)) } diff --git a/server/models/groupModel.ts b/server/models/groupModel.ts index feec34e..b86eeef 100644 --- a/server/models/groupModel.ts +++ b/server/models/groupModel.ts @@ -1,4 +1,54 @@ -import mongoose, { Types } from 'mongoose' +import mongoose, { Types, type Model } from 'mongoose' + +export interface IGroup { + id: string + name: string + ownerId: string + spotifyAuthId?: string + defaultDeviceId?: string +} + +export interface IGroupFields extends Omit { + ownerId: typeof Types.ObjectId + spotifyAuthId?: typeof Types.ObjectId +} +export interface IGroupMethods extends IModelMethods {} +type IGroupModel = Model + +const GroupSchema = new mongoose.Schema( + { + ownerId: { + type: Types.ObjectId, + required: true + }, + name: { + type: String, + required: true, + unique: true + }, + spotifyAuthId: { + type: Types.ObjectId, + ref: 'SpotifyAuth', + unique: true, + dropDups: true + }, + defaultDeviceId: { + type: String + } + }, + { + timestamps: true + } +) + +GroupSchema.methods.serialize = function () { + return { + id: this.id, + ownerId: this.ownerId.toString(), + name: this.name, + spotifyAuthId: this.spotifyAuthId?.toString() + } +} const membershipSchema = new mongoose.Schema( { @@ -27,31 +77,7 @@ const membershipSchema = new mongoose.Schema( } ) -const groupSchema = new mongoose.Schema( - { - ownerId: { - type: Types.ObjectId, - required: true - }, - name: { - type: String, - required: true - }, - spotifyToken: { - type: { - accessToken: String, - refreshTOken: String, - expirationDate: Date - }, - required: false - } - }, - { - timestamps: true - } -) - -export const Group = mongoose.model('Group', groupSchema) +export const Group = mongoose.model('Group', GroupSchema) export type Group = InstanceType export const Membership = mongoose.model('Membership', membershipSchema) export type Membership = InstanceType diff --git a/server/models/index.ts b/server/models/index.ts index eaf9f23..04a8380 100644 --- a/server/models/index.ts +++ b/server/models/index.ts @@ -1,2 +1,3 @@ -export * from './userModel' export * from './groupModel' +export * from './spotifyAuthModel' +export * from './userModel' diff --git a/server/models/spotifyAuthModel.ts b/server/models/spotifyAuthModel.ts new file mode 100644 index 0000000..f76f3f5 --- /dev/null +++ b/server/models/spotifyAuthModel.ts @@ -0,0 +1,55 @@ +import { Schema, Types, model, type Model } from 'mongoose' + +export interface ISpotifyAuth { + id: string + accessToken: string + refreshToken: string + userId: string + spotifyEmail: string + expiresIn: number + tokenType: string + expiresAt: Date +} + +export interface ISpotifyAuthFields extends Omit { + userId: typeof Types.ObjectId +} + +export interface ISpotifyAuthMethods extends IModelMethods {} + +export type ISpotifyAuthModel = Model + +export const SpotifyAuthSchema = new Schema< + ISpotifyAuthFields, + ISpotifyAuthModel, + ISpotifyAuthMethods +>({ + accessToken: { + type: String + }, + refreshToken: { + type: String + }, + userId: { + type: Types.ObjectId, + required: true, + ref: 'User' + }, + spotifyEmail: { + type: String, + required: true, + unique: true + }, + expiresIn: { + type: Number + }, + expiresAt: { + type: Date + }, + tokenType: { + type: String + } +}) + +export const SpotifyAuth = model('SpotifyAuth', SpotifyAuthSchema) +export type SpotifyAuth = InstanceType diff --git a/server/models/userModel.ts b/server/models/userModel.ts index 1abd905..2d458a7 100644 --- a/server/models/userModel.ts +++ b/server/models/userModel.ts @@ -12,6 +12,7 @@ export interface IUser { image?: string } export interface IUserFields extends Omit { +// export interface IUserFields extends IModelFields { password: string } export interface IUserMethods extends IModelMethods {} diff --git a/server/routes/groupRoutes.ts b/server/routes/groupRoutes.ts index 40a55de..ef767d7 100644 --- a/server/routes/groupRoutes.ts +++ b/server/routes/groupRoutes.ts @@ -1,19 +1,24 @@ import { Router } from 'express' -import * as GroupController from '../controllers/groupController' import * as JamController from '../controllers/jamController' +import { isAuthenticated } from '../middleware/authMiddleware' +import * as views from '../views/groupViews' const router = Router() -router.post('/groups', GroupController.createGroup) -router.post('/groups/:groupId/members', GroupController.createGroupMember) -// router.post('/groups/:groupId/guests', GroupController.createSessionGuest) -router.get('/groups/:groupId', GroupController.getGroup) -router.put('/groups/:groupId', GroupController.updateGroup) -router.patch('/groups/:groupId', GroupController.updateGroup) -router.delete('/groups/:groupId', GroupController.deleteGroup) -router.get('/groups/:groupId/members', GroupController.getGroupMembers) +router.post('/:id/spotify', isAuthenticated, views.assignSpotifyAccountView) +router.get('/:id/spotify/current-track', isAuthenticated, views.getGroupCurrentTrackView) +router.post('/:id/spotify/state', isAuthenticated, views.setGroupPlayerStateView) +router.get('/:id/spotify/devices', isAuthenticated, views.getGroupDevicesView) +router.post('/:id/spotify/default-device', isAuthenticated, views.setGroupDefaultDeviceView) -router.post('/groups/:groupId/jam', JamController.startJam) -router.delete('/groups/:groupId/jam', JamController.endJam) +router.post('/:id/jam', JamController.startJam) +router.delete('/:id/jam', JamController.endJam) + +router.post('/groups', isAuthenticated, views.groupCreateView) +router.get('/groups', isAuthenticated, views.groupListView) +router.get('/groups/:id', isAuthenticated, views.groupGetView) +router.put('/groups/:id', isAuthenticated, views.groupUpdateView) +router.patch('/groups/:id', isAuthenticated, views.groupPartialUpdateView) +router.delete('/groups/:id', isAuthenticated, views.groupDeleteView) export const groupRoutes = router diff --git a/server/routes/spotifyRoutes.ts b/server/routes/spotifyRoutes.ts index 762eb04..5c82042 100644 --- a/server/routes/spotifyRoutes.ts +++ b/server/routes/spotifyRoutes.ts @@ -1,18 +1,12 @@ import { Router } from 'express' -import { hasSpotifyToken } from 'server/middleware/authMiddleware' -import * as SpotifyController from '../controllers/spotifyController' +import * as views from '../views/spotifyAuthViews' import { isAuthenticated } from './../middleware/authMiddleware' const router = Router() /**== Spotify Authentication - /api/spotify/ ==**/ -router.get('/login', isAuthenticated, SpotifyController.spotifyLogin) -router.get('/login-callback', SpotifyController.spotifyLoginCallback) - -/**== Spotify Communication - /api/spotify/ ==**/ -router.get('/me', isAuthenticated, hasSpotifyToken, SpotifyController.getUserProfile) -router.get('/search', isAuthenticated, hasSpotifyToken, SpotifyController.spotifySearch) -router.get('/tracks', isAuthenticated, hasSpotifyToken, SpotifyController.spotifySearchTracks) -router.get('/tracks/:id', isAuthenticated, hasSpotifyToken, SpotifyController.spotifySearchTrackId) +router.get('/login', isAuthenticated, views.spotifyLoginView) +router.get('/login-callback', views.spotifyLoginCallbackView) +router.delete('/', isAuthenticated, views.removeSpotifyConnection) export const spotifyRouter = router diff --git a/server/routes/userRoutes.ts b/server/routes/userRoutes.ts index fa1810c..2f5c160 100644 --- a/server/routes/userRoutes.ts +++ b/server/routes/userRoutes.ts @@ -1,16 +1,18 @@ import { Router } from 'express' import { isAuthenticated } from '../middleware/authMiddleware' -import * as userViews from '../views/userViews' +import * as views from '../views/userViews' import { UserViewset } from '../views/userViews' const router = Router() /**== User Authentication ==**/ -router.post('/register', userViews.registerUserView) -router.post('/login', userViews.loginUserView) -router.get('/me', isAuthenticated, userViews.currentUserView) -router.post('/request-password-reset', isAuthenticated, userViews.requestPasswordResetView) -router.post('/reset-password', isAuthenticated, userViews.resetPasswordView) +router.post('/register', views.registerUserView) +router.post('/token', views.loginUserView) +router.post('/request-password-reset', isAuthenticated, views.requestPasswordResetView) +router.post('/reset-password', isAuthenticated, views.resetPasswordView) + +router.get('/me', isAuthenticated, views.currentUserView) +router.get('/me/spotify-accounts', isAuthenticated, views.connectedSpotifyAccounts) /**== User Management ==**/ router.use('/users', isAuthenticated, UserViewset.registerRouter()) diff --git a/server/services/authService.ts b/server/services/authService.ts index c6e5213..2ac6122 100644 --- a/server/services/authService.ts +++ b/server/services/authService.ts @@ -8,7 +8,7 @@ export class AuthService { private static jwtSecret: string = JWT_SECRET_KEY private static jwtIssuer: string = JWT_ISSUER private static jwtAlgorithm: Algorithm | undefined = JWT_ALGORITHM || 'HS256' - private static jwtExpiresIn: string = '5h' + private static jwtExpiresIn: string = '48h' private static jwtNotBefore: string | number = 0 /** diff --git a/server/services/spotifyService.ts b/server/services/spotifyService.ts index aa3a212..52a8924 100644 --- a/server/services/spotifyService.ts +++ b/server/services/spotifyService.ts @@ -1,115 +1,113 @@ -import type { AxiosResponse } from 'axios' -import axios from 'axios' -import { stringify } from 'querystring' -import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from 'server/config' - -type TokenResponse = { - accessToken: string - refreshToken: string - expiresAt: Date -} +import type { Device } from '@spotify/web-api-ts-sdk' +import { + authenticateSpotify, + getSpotifyEmail, + getSpotifySdk, + type SpotifySdk, + type SpotifyTokens +} from 'server/lib' +import { SpotifyAuth } from 'server/models' export class SpotifyService { - private accessToken: string - protected static clientId: string = SPOTIFY_CLIENT_ID - protected static clientSecret: string = SPOTIFY_CLIENT_SECRET - protected static scope: string = 'user-read-private user-read-email' - protected static redirectUri: string = 'http://localhost:8000/api/spotify/login-callback/' - protected static spotifyTokenUrl: string = 'https://accounts.spotify.com/api/token' - constructor(accessToken: string) { - this.accessToken = accessToken - } + private auth: SpotifyAuth + public sdk: SpotifySdk - private static requestAuthorization = async ( - body: - | { - grant_type: 'authorization_code' - code: string - redirect_uri: string - } - | { - grant_type: 'refresh_token' - refresh_token: string - } - ): Promise> => { - const spotifyAuthBuffer: Buffer = Buffer.from(this.clientId + ':' + this.clientSecret) - - const spotifyRes = await axios - .post(this.spotifyTokenUrl, body, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: 'Basic ' + spotifyAuthBuffer.toString('base64') - } - }) - .catch((error: any) => { - console.log('Error authorizing with spotify:', error) - throw new Error(error?.response?.data?.error_description || error) - }) + private constructor(auth: SpotifyAuth) { + this.auth = auth + this.sdk = getSpotifySdk(auth) + } - if (spotifyRes.status > 299 || !spotifyRes.data) - throw new Error('Error authenticating with spotify.') + public static async connect(spotifyEmail: string): Promise { + const spotifyAuth = await SpotifyAuth.findOne({ spotifyEmail }) - return spotifyRes - } + if (!spotifyAuth) + throw new Error(`Unable to find connected spotify account with email ${spotifyEmail}.`) - private static getExpiresAt = (expiresIn: number): Date => new Date(Date.now() / 1000 + expiresIn) - - static getSpotifyRedirectUri = (userState: { - userId: string - finalRedirect: string - groupId: string - }): string => { - const state = JSON.stringify(userState) - - const url = - 'https://accounts.spotify.com/authorize?' + - stringify({ - response_type: 'code', - client_id: this.clientId, - scope: this.scope, - redirect_uri: this.redirectUri, - state: state - }) + if (spotifyAuth.expiresAt.getTime() > Date.now()) { + return new SpotifyService(spotifyAuth) + } - return url - } - static requestSpotifyToken = async (code: string): Promise => { - const spotifyRes = await this.requestAuthorization({ - code: code, - redirect_uri: this.redirectUri, - grant_type: 'authorization_code' + const tokens = await authenticateSpotify({ + type: 'refresh_token', + payload: spotifyAuth.refreshToken }) - const { access_token, refresh_token, expires_in } = spotifyRes.data - const expiresAt = this.getExpiresAt(expires_in) + const updatedAuth = await this.udpateOrCreateAuth( + spotifyAuth.userId.toString(), + spotifyAuth.spotifyEmail, + tokens + ) - return { accessToken: access_token, refreshToken: refresh_token, expiresAt } + return new SpotifyService(updatedAuth) } - static refreshUserToken = async (refreshToken: string): Promise => { - const spotifyRes = await this.requestAuthorization({ - grant_type: 'refresh_token', - refresh_token: refreshToken - }) - - const { access_token, refresh_token, expires_in } = spotifyRes.data - const expiresAt = this.getExpiresAt(expires_in) + public static async authenticateUser(userId: string, code: string): Promise { + const tokens = await authenticateSpotify({ type: 'authorization_code', payload: code }) + const userEmail = await getSpotifyEmail(tokens) - return { accessToken: access_token, refreshToken: refresh_token, expiresAt } + const spotifyAuth = await this.udpateOrCreateAuth(userId, userEmail, tokens) + return new SpotifyService(spotifyAuth) } - getProfile = async (): Promise => { - const res = await axios - .get('https://api.spotify.com/v1/me', { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - .catch((error: any) => { - console.log('error getting profile:', error) - throw error + private static async udpateOrCreateAuth( + userId: string, + spotifyEmail: string, + tokens: SpotifyTokens + ): Promise { + const query = await SpotifyAuth.findOne({ userId, spotifyEmail }) + + if (!query) { + return await SpotifyAuth.create({ + userId, + spotifyEmail, + expiresAt: new Date(Date.now() + tokens.expiresIn * 1000), + ...tokens }) + } else { + // const updated = await query.updateOne({ ...tokens }, { new: true }) + query.accessToken = tokens.accessToken + query.expiresIn = tokens.expiresIn + query.expiresAt = new Date(Date.now() + tokens.expiresIn * 1000) + return query + } + } - return res.data + public async getProfile() { + return await this.sdk.currentUser.profile() } + + // public async getActiveDevice(failSilently: true): Promise + // public async getActiveDevice(failSilently?: false): Promise + // public async getActiveDevice(failSilently = false) { + // const devices = await this.sdk.player.getAvailableDevices() + // const activeDevice = devices.devices.reduce((currentDevice, device) => { + // if (device.is_active || !currentDevice) return device + + // return currentDevice + // }) + + // if (!failSilently && !activeDevice) { + // throw new Error('No active devices to play tracks.') + // } + + // return activeDevice + // } + + // public async playTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.startResumePlayback(activeDevice.id!) + // } + + // public async pauseTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.pausePlayback(activeDevice.id!) + // } + // public async nextTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.skipToNext(activeDevice.id!) + // } + // public async previousTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.skipToPrevious(activeDevice.id!) + // } } diff --git a/server/types/index.d.ts b/server/types/index.d.ts index eb90401..8263a4e 100644 --- a/server/types/index.d.ts +++ b/server/types/index.d.ts @@ -4,3 +4,9 @@ declare interface IModelMethods { serialize: () => T // static clean: (data: any) => T } + +// declare type IModelFields = Omit + +// declare interface IModelFields extends Omit { + +// } diff --git a/server/utils/apis/viewsets.ts b/server/utils/apis/viewsets.ts index 2f1559b..6910d21 100644 --- a/server/utils/apis/viewsets.ts +++ b/server/utils/apis/viewsets.ts @@ -11,25 +11,25 @@ export class Viewset< private model: T private clean: (data: any) => S - constructor(model: T, clean: (data: any) => S) { + constructor(model: T, clean: (data: any) => S = (data) => data) { this.model = model this.clean = clean } private apiWrapper = apiRequest - private async handleCreate(req: Request, res: Response, next: NextFunction) { + protected async handleCreate(req: Request, res: Response, next: NextFunction) { const { body } = req const data = this.clean(body) const obj = await this.model.create(data) return obj.serialize() } - private async handleList(req: Request, res: Response, next: NextFunction) { + protected async handleList(req: Request, res: Response, next: NextFunction) { const query = await this.model.find({}) return query.map((obj: InstanceType) => obj.serialize()) } - private async handleGet(req: Request, res: Response, next: NextFunction) { + protected async handleGet(req: Request, res: Response, next: NextFunction) { const { id } = req.params const result = await this.model.findById(id) @@ -37,7 +37,7 @@ export class Viewset< return result.serialize() } - private async handleUpdate(req: Request, res: Response, next: NextFunction) { + protected async handleUpdate(req: Request, res: Response, next: NextFunction) { const { body, params } = req const data = this.clean(body) @@ -46,7 +46,7 @@ export class Viewset< return obj.serialize() } - private async handlePartialUpdate(req: Request, res: Response, next: NextFunction) { + protected async handlePartialUpdate(req: Request, res: Response, next: NextFunction) { const { body, params } = req const data = this.clean(body) @@ -55,7 +55,7 @@ export class Viewset< return obj.serialize() } - private async handleDelete(req: Request, res: Response, next: NextFunction) { + protected async handleDelete(req: Request, res: Response, next: NextFunction) { const { id } = req.params const result: InstanceType>> | null = await this.model.findById(id) @@ -67,6 +67,9 @@ export class Viewset< return result.serialize() } + /** + * #swagger.tags = ['Group'] + */ public create = this.apiWrapper(this.handleCreate.bind(this), { onSuccess: httpCreated }) public list = this.apiWrapper(this.handleList.bind(this)) public get = this.apiWrapper(this.handleGet.bind(this)) @@ -77,7 +80,7 @@ export class Viewset< registerRouter(path = '/'): Router { const router = Router() - router.post(path, this.create.bind(this)) + router.post(path, this.create) router.get(path, this.list) router.get(`${path}:id`, this.get) router.post(`${path}:id`, this.update) diff --git a/server/utils/apis/wrappers.ts b/server/utils/apis/wrappers.ts index c251162..43dfda4 100644 --- a/server/utils/apis/wrappers.ts +++ b/server/utils/apis/wrappers.ts @@ -22,7 +22,10 @@ const wrapper = ( return async (req: Request, res: T, next: NextFunction) => { try { const data = await cb(req, res, next) - return onSuccess ? onSuccess(res, data, showStatus) : httpOk(res, data, showStatus) + + if (!res.headersSent) { + return onSuccess ? onSuccess(res, data, showStatus) : httpOk(res, data, showStatus) + } } catch (e) { return errorResponse(e, res, next) } diff --git a/server/utils/exceptions/generalExceptions.ts b/server/utils/exceptions/generalExceptions.ts index 3fa3771..bee4107 100644 --- a/server/utils/exceptions/generalExceptions.ts +++ b/server/utils/exceptions/generalExceptions.ts @@ -6,11 +6,8 @@ export class NotImplementedError extends Error { } export class NotFoundError extends Error { - constructor(resource?: string, query?: Record) { - const message = query - ? `Resource ${resource} with query ${query} not found.` - : `Resource ${resource} not found.` - super(message) + constructor(message?: string) { + super(message ?? 'Resource not found.') this.name = 'NotFoundError' } } @@ -21,3 +18,10 @@ export class ValidationError extends Error { this.name = 'ValidationError' } } + +export class UnauthorizedError extends Error { + constructor(message?: string) { + super(message ?? 'Unauthorized Error.') + this.name = 'UnauthorizedError' + } +} diff --git a/server/views/groupViews.ts b/server/views/groupViews.ts new file mode 100644 index 0000000..30f9244 --- /dev/null +++ b/server/views/groupViews.ts @@ -0,0 +1,110 @@ +import type { NextFunction, Request, Response } from 'express' +import { + assignSpotifyToGroup, + getGroupDevices, + getGroupTrack, + setGroupDefaultDevice, + setGroupPlayerState +} from 'server/controllers/groupController' +import { Group } from 'server/models' +import { apiAuthRequest } from 'server/utils' +import { Viewset } from '../utils/apis/viewsets' + +const groupViewset = new Viewset(Group) + +type ApiArgs = [req: Request, res: Response, next: NextFunction] + +export const assignSpotifyAccountView = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const { user } = res.locals + const spotifyEmail = String(req.body.spotifyEmail) + const id = String(req.params.id) + + return await assignSpotifyToGroup(user, id, spotifyEmail) +}) + +export const getGroupCurrentTrackView = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + return await getGroupTrack(id) +}) + +export const getGroupDevicesView = apiAuthRequest(async (req, res) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + return await getGroupDevices(id) +}) +export const setGroupDefaultDeviceView = apiAuthRequest(async (req, res) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + const deviceId = String(req.body.deviceId) + + return await setGroupDefaultDevice(id, deviceId) +}) + +export const setGroupPlayerStateView = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + const state = String(req.body.state) as 'play' | 'pause' + + return await setGroupPlayerState(id, state) +}) + +export const groupCreateView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.create(...args) +} + +export const groupListView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.list(...args) +} +export const groupGetView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.get(...args) +} +export const groupUpdateView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.update(...args) +} +export const groupPartialUpdateView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.partialUpdate(...args) +} +export const groupDeleteView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.delete(...args) +} diff --git a/server/views/spotifyAuthViews.ts b/server/views/spotifyAuthViews.ts new file mode 100644 index 0000000..1d16e9f --- /dev/null +++ b/server/views/spotifyAuthViews.ts @@ -0,0 +1,61 @@ +import { getSpotifyRedirectUri } from 'server/lib' +import { SpotifyAuth, User } from 'server/models' +import { SpotifyService } from 'server/services' +import { apiAuthRequest, apiRequest, httpSeeOther, UnauthorizedError } from 'server/utils' + +export const spotifyLoginView = apiAuthRequest(async (req, res) => { + /** + @swagger + #swagger.tags = ['Spotify'] + */ + const { user } = res.locals + let { redirectUri, groupId } = req.query + redirectUri = String(redirectUri) + + const spotifyLoginUri = getSpotifyRedirectUri({ + userId: user._id.toString(), + finalRedirect: redirectUri + }) + + return httpSeeOther(res, spotifyLoginUri) +}) + +export const spotifyLoginCallbackView = apiRequest(async (req, res) => { + /** + @swagger + #swagger.tags = ['Spotify'] + */ + let { code, state } = req.query + code = String(code) + + const parsedState = JSON.parse(JSON.parse(JSON.stringify(state))) // TODO: Fix double parse + const { userId, finalRedirect } = parsedState + if (!userId) throw new Error('Spotify state mismatch error.') + + const user: User | null = await User.findById(userId) + if (!user) throw new UnauthorizedError() + + const spotifyUser = await SpotifyService.authenticateUser(user._id.toString(), code) + const profile = await spotifyUser.getProfile() + + if (finalRedirect && String(finalRedirect) !== 'undefined' && finalRedirect !== '') { + return httpSeeOther(res, finalRedirect) + } else { + return profile + } +}) + +export const removeSpotifyConnection = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['Spotify'] + */ + const { user } = res.locals + const spotifyEmail = String(req.body.spotifyEmail) + + const deleted = await SpotifyAuth.deleteOne({ + spotifyEmail: spotifyEmail, + userId: user._id.toString() + }) + return deleted.deletedCount +}) diff --git a/server/views/userViews.ts b/server/views/userViews.ts index e787715..0e0bd99 100644 --- a/server/views/userViews.ts +++ b/server/views/userViews.ts @@ -1,13 +1,19 @@ -import { getUserToken, registerUser, requestPasswordReset, resetPassword } from 'server/controllers' +import { + getUserSpotifyEmails, + getUserToken, + registerUser, + requestPasswordReset, + resetPassword +} from 'server/controllers' import { cleanUser, User } from 'server/models' import { apiAuthRequest, apiRequest, httpCreated, Viewset } from 'server/utils' export const registerUserView = apiRequest( async (req, res, next) => { /** - @swagger - #swagger.tags = ['User'] - */ + @swagger + #swagger.tags = ['User'] + */ const { email, password } = req.body if (!email || !password) throw new Error('Missing email or password.') @@ -61,4 +67,13 @@ export const resetPasswordView = apiRequest(async (req, res, next) => { await resetPassword(email, password) }) +export const connectedSpotifyAccounts = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['User'] + */ + const { user } = res.locals + return getUserSpotifyEmails(user) +}) + export const UserViewset = new Viewset(User, cleanUser) diff --git a/tsconfig.json b/tsconfig.json index dcf4247..71a776b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,7 @@ "server/*": ["./server/*"], "websocket/*": ["./websocket/*"], "common/*": ["./common/*"], - "*": ["./*", "./server/*", "./websocket/*",] + "*": ["./*", "./server/*", "./websocket/*"] } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ "typeRoots": [ @@ -130,5 +130,12 @@ "common/lib/kafka.ts", // "./server/core/docs/swagger.ts", "common/lib/logger.ts" // "./src/core/docs/api/monitor.doc.ts", ], - "exclude": ["node_modules", "coverage", "logs", "_depricated"] + "exclude": [ + "node_modules/", + "coverage", + "logs", + "_depricated", + "./node_modules", + "./node_modules/*" + ] }