From 27874f8a5041a8bee218432562b03f097ad7c174 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sat, 7 Sep 2024 15:06:39 -0400 Subject: [PATCH 1/4] add spotify lib, refactor service, store spotify auth --- .gitignore | 1 + .vscode/settings.json | 10 +- docker-compose.yml | 1 + package-lock.json | 308 ++++++++++--------- package.json | 3 +- server/config/constants.ts | 1 + server/config/server.ts | 2 + server/docs/swagger_output.json | 2 +- server/lib/index.ts | 1 + server/lib/spotify.ts | 91 ++++++ server/middleware/authMiddleware.ts | 6 +- server/middleware/errorHandler.ts | 6 +- server/models/index.ts | 3 +- server/models/spotifyAuthModel.ts | 51 +++ server/routes/spotifyRoutes.ts | 5 +- server/services/spotifyService.ts | 133 ++------ server/utils/apis/wrappers.ts | 5 +- server/utils/exceptions/generalExceptions.ts | 7 + server/views/spotifyAuthViews.ts | 46 +++ tsconfig.json | 11 +- 20 files changed, 437 insertions(+), 256 deletions(-) create mode 100644 server/lib/index.ts create mode 100644 server/lib/spotify.ts create mode 100644 server/models/spotifyAuthModel.ts create mode 100644 server/views/spotifyAuthViews.ts 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/docs/swagger_output.json b/server/docs/swagger_output.json index 47ffd9e..9702bcb 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": [ { 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..ab3ac3c --- /dev/null +++ b/server/lib/spotify.ts @@ -0,0 +1,91 @@ +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' + +// tokens: { accessToken: string; refreshToken: string, expiresIn: number } + +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: 'user-read-private user-read-email', + redirect_uri: SPOTIFY_REDIRECT_URI, + state: stateString + }) + + return url +} + +export const authenticateSpotify = async (code: string): Promise => { + const body: SpotifyAuthReqBody = { + grant_type: 'authorization_code', + code, + redirect_uri: SPOTIFY_REDIRECT_URI + } + 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..4acb038 100644 --- a/server/middleware/authMiddleware.ts +++ b/server/middleware/authMiddleware.ts @@ -24,9 +24,9 @@ export const isAuthenticated = async (req: Request, res: Response, next: NextFun 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/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..6520e0a --- /dev/null +++ b/server/models/spotifyAuthModel.ts @@ -0,0 +1,51 @@ +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 +} + +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 + }, + tokenType: { + type: String + } +}) + +export const SpotifyAuth = model('SpotifyAuth', SpotifyAuthSchema) +export type SpotifyAuth = InstanceType diff --git a/server/routes/spotifyRoutes.ts b/server/routes/spotifyRoutes.ts index 762eb04..b00b0d2 100644 --- a/server/routes/spotifyRoutes.ts +++ b/server/routes/spotifyRoutes.ts @@ -2,12 +2,13 @@ import { Router } from 'express' import { hasSpotifyToken } from 'server/middleware/authMiddleware' import * as SpotifyController from '../controllers/spotifyController' import { isAuthenticated } from './../middleware/authMiddleware' +import * as views from '../views/spotifyAuthViews' const router = Router() /**== Spotify Authentication - /api/spotify/ ==**/ -router.get('/login', isAuthenticated, SpotifyController.spotifyLogin) -router.get('/login-callback', SpotifyController.spotifyLoginCallback) +router.get('/login', isAuthenticated, views.spotifyLoginView) +router.get('/login-callback', views.spotifyLoginCallbackView) /**== Spotify Communication - /api/spotify/ ==**/ router.get('/me', isAuthenticated, hasSpotifyToken, SpotifyController.getUserProfile) diff --git a/server/services/spotifyService.ts b/server/services/spotifyService.ts index aa3a212..1cf40e3 100644 --- a/server/services/spotifyService.ts +++ b/server/services/spotifyService.ts @@ -1,115 +1,48 @@ -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 { 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 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) - }) - - if (spotifyRes.status > 299 || !spotifyRes.data) - throw new Error('Error authenticating with spotify.') + private auth: SpotifyAuth + public sdk: SpotifySdk - return spotifyRes + private constructor(auth: SpotifyAuth) { + this.auth = auth + this.sdk = getSpotifySdk(auth) } - private static getExpiresAt = (expiresIn: number): Date => new Date(Date.now() / 1000 + expiresIn) + public static async connect(spotifyEmail: string): Promise { + const spotifyAuth = await SpotifyAuth.findOne({ spotifyEmail }) - static getSpotifyRedirectUri = (userState: { - userId: string - finalRedirect: string - groupId: string - }): string => { - const state = JSON.stringify(userState) + if (!spotifyAuth) + throw new Error(`Unable to find connected spotify account with email ${spotifyEmail}.`) - const url = - 'https://accounts.spotify.com/authorize?' + - stringify({ - response_type: 'code', - client_id: this.clientId, - scope: this.scope, - redirect_uri: this.redirectUri, - state: state - }) - - return url + return new SpotifyService(spotifyAuth) } - static requestSpotifyToken = async (code: string): Promise => { - const spotifyRes = await this.requestAuthorization({ - code: code, - redirect_uri: this.redirectUri, - grant_type: 'authorization_code' - }) - 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(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) } - 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) - - return { accessToken: access_token, refreshToken: refresh_token, expiresAt } + 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, ...tokens }) + } else { + const updated = await query.updateOne({ ...tokens }, { new: true }) + return updated + } } - - 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 - }) - - return res.data + + public async getProfile() { + return await this.sdk.currentUser.profile() } } 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..555c4f3 100644 --- a/server/utils/exceptions/generalExceptions.ts +++ b/server/utils/exceptions/generalExceptions.ts @@ -21,3 +21,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/spotifyAuthViews.ts b/server/views/spotifyAuthViews.ts new file mode 100644 index 0000000..006d46d --- /dev/null +++ b/server/views/spotifyAuthViews.ts @@ -0,0 +1,46 @@ +import { getSpotifyRedirectUri } from 'server/lib' +import { 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 + } +}) 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/*" + ] } From 2f451d44d1f99ca7098067bbd9f6ada7025b25d9 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sat, 7 Sep 2024 15:53:09 -0400 Subject: [PATCH 2/4] update group model to connect with spotify auth --- server/controllers/baseController.ts | 24 -- server/controllers/spotifyController.ts | 115 ---------- server/docs/swagger_output.json | 291 +----------------------- server/models/groupModel.ts | 61 +++-- server/models/userModel.ts | 1 + server/routes/groupRoutes.ts | 24 +- server/routes/spotifyRoutes.ts | 10 +- server/types/index.d.ts | 6 + server/utils/apis/viewsets.ts | 19 +- server/views/groupViews.ts | 18 ++ 10 files changed, 95 insertions(+), 474 deletions(-) delete mode 100644 server/controllers/baseController.ts delete mode 100644 server/controllers/spotifyController.ts create mode 100644 server/views/groupViews.ts 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/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/docs/swagger_output.json b/server/docs/swagger_output.json index 9702bcb..ee4cbb1 100644 --- a/server/docs/swagger_output.json +++ b/server/docs/swagger_output.json @@ -162,153 +162,6 @@ } } }, - "/api/spotify/me": { - "get": { - "tags": [ - "Spotify" - ], - "description": "", - "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" - } - } - } - }, - "/api/spotify/search": { - "get": { - "tags": [ - "Spotify" - ], - "summary": "Not implemented", - "description": "", - "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" - } - } - } - }, - "/api/spotify/tracks": { - "get": { - "tags": [ - "Spotify" - ], - "summary": "Not implemented", - "description": "", - "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" - } - } - } - }, - "/api/spotify/tracks/{id}": { - "get": { - "tags": [ - "Spotify" - ], - "summary": "Not implemented", - "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" - } - } - } - }, "/api/user/register": { "post": { "tags": [ @@ -550,77 +403,6 @@ "Group" ], "description": "", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - } - } - } - } - ], - "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" - } - } - } - }, - "/api/group/groups/{groupId}/members": { - "post": { - "tags": [ - "Group" - ], - "description": "", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string" - }, - { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "options": { - "example": "any" - } - } - } - } - ], "responses": { "400": { "schema": { @@ -649,19 +431,7 @@ } }, "get": { - "tags": [ - "Group" - ], - "summary": "Not implemented", "description": "", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string" - } - ], "responses": { "400": { "schema": { @@ -690,15 +460,12 @@ } } }, - "/api/group/groups/{groupId}": { + "/api/group/groups/{id}": { "get": { - "tags": [ - "Group" - ], "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -732,35 +499,13 @@ } }, "put": { - "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": { @@ -791,35 +536,13 @@ } }, "patch": { - "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": { @@ -850,14 +573,10 @@ } }, "delete": { - "tags": [ - "Group" - ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" diff --git a/server/models/groupModel.ts b/server/models/groupModel.ts index feec34e..ac739d0 100644 --- a/server/models/groupModel.ts +++ b/server/models/groupModel.ts @@ -1,4 +1,39 @@ -import mongoose, { Types } from 'mongoose' +import mongoose, { Types, type Model } from 'mongoose' + +export interface IGroup { + id: string + name: string + ownerId: string + spotifyAuthId: 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 + }, + spotifyAuthId: { + type: Types.ObjectId, + ref: 'SpotifyAuth' + } + }, + { + timestamps: true + } +) + const membershipSchema = new mongoose.Schema( { @@ -27,29 +62,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 type Group = 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..afb890d 100644 --- a/server/routes/groupRoutes.ts +++ b/server/routes/groupRoutes.ts @@ -1,17 +1,25 @@ import { Router } from 'express' import * as GroupController from '../controllers/groupController' import * as JamController from '../controllers/jamController' +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('/groups', views.groupCreateView) +router.get('/groups', views.groupListView) +router.get('/groups/:id', views.groupGetView) +router.put('/groups/:id', views.groupUpdateView) +router.patch('/groups/:id', views.groupPartialUpdateView) +router.delete('/groups/:id', views.groupDeleteView) + +// 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('/groups/:groupId/jam', JamController.startJam) router.delete('/groups/:groupId/jam', JamController.endJam) diff --git a/server/routes/spotifyRoutes.ts b/server/routes/spotifyRoutes.ts index b00b0d2..4e1a379 100644 --- a/server/routes/spotifyRoutes.ts +++ b/server/routes/spotifyRoutes.ts @@ -1,8 +1,6 @@ import { Router } from 'express' -import { hasSpotifyToken } from 'server/middleware/authMiddleware' -import * as SpotifyController from '../controllers/spotifyController' -import { isAuthenticated } from './../middleware/authMiddleware' import * as views from '../views/spotifyAuthViews' +import { isAuthenticated } from './../middleware/authMiddleware' const router = Router() @@ -10,10 +8,4 @@ const router = Router() router.get('/login', isAuthenticated, views.spotifyLoginView) router.get('/login-callback', views.spotifyLoginCallbackView) -/**== 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) - export const spotifyRouter = router diff --git a/server/types/index.d.ts b/server/types/index.d.ts index eb90401..dd4c2b6 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/views/groupViews.ts b/server/views/groupViews.ts new file mode 100644 index 0000000..4a1d75b --- /dev/null +++ b/server/views/groupViews.ts @@ -0,0 +1,18 @@ +import { Group } from 'server/models' +import { apiRequest } from 'server/utils' +import { Viewset } from '../utils/apis/viewsets' + +const groupViewset = new Viewset(Group) + +export const groupCreateView = apiRequest((req, res, next) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + return groupViewset.create(req, res, next) +}) +export const groupListView = groupViewset.list +export const groupGetView = groupViewset.get +export const groupUpdateView = groupViewset.update +export const groupPartialUpdateView = groupViewset.partialUpdate +export const groupDeleteView = groupViewset.delete From 81aa3014ca9b84c294dbfe855947eef558c60066 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sat, 7 Sep 2024 23:01:30 -0400 Subject: [PATCH 3/4] automatically refresh spotify token, connect group to spotify auth --- server/controllers/groupController.ts | 159 ++--------- server/controllers/userController.ts | 7 +- server/docs/swagger.ts | 5 +- server/docs/swagger_output.json | 267 +++++++++++++++++-- server/lib/spotify.ts | 37 ++- server/models/groupModel.ts | 25 +- server/models/spotifyAuthModel.ts | 4 + server/routes/groupRoutes.ts | 19 +- server/routes/spotifyRoutes.ts | 1 + server/routes/userRoutes.ts | 14 +- server/services/authService.ts | 2 +- server/services/spotifyService.ts | 43 ++- server/types/index.d.ts | 2 +- server/utils/exceptions/generalExceptions.ts | 7 +- server/views/groupViews.ts | 86 +++++- server/views/spotifyAuthViews.ts | 17 +- server/views/userViews.ts | 23 +- 17 files changed, 487 insertions(+), 231 deletions(-) diff --git a/server/controllers/groupController.ts b/server/controllers/groupController.ts index 3749c4d..78dc044 100644 --- a/server/controllers/groupController.ts +++ b/server/controllers/groupController.ts @@ -1,151 +1,26 @@ -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' +import { Group, SpotifyAuth, type User } from 'server/models' +import { SpotifyService } from 'server/services' +import { NotFoundError } 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 +export const assignSpotifyToGroup = async (user: User, groupId: string, spotifyEmail: string) => { + const auth = await SpotifyAuth.findOne({ userId: user._id.toString(), spotifyEmail }) - try { - // const group = await Group.create({ ...body, ownerId: user._id }) - const { name } = body // TODO: Validate body, explicitly define fields + if (!auth) + throw new Error(`User ${user.email} is not connected to spotify account ${spotifyEmail}.`) - const group = await GroupService.createGroup(user, name, body) - return httpCreated(res, group) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } -} - -// TODO: Requires permission -export const createGroupMember = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - */ - const { groupId } = req.params - const { email, options } = req.body - - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') - - try { - const userFound: User | null = await User.findOne({ email: email }) - let user: User - - if (!userFound) { - user = await AuthService.inviteUser(email) - } else { - user = userFound - } - - const newMembership = await GroupService.registerGroupMember(group, user, options) - return httpCreated(res, newMembership) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } -} - -export const getGroup = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - */ - const { groupId } = req.params - - const group: Group | null = await Group.findById(groupId) - if (!group) return httpNotFound(res, 'Group not found.') - - return httpOk(res, group) -} - -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 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 getGroupMembers = 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.') + const group = await Group.findById(groupId) + if (!group) throw new NotFoundError(`Group with id ${groupId} not found.`) - try { - const members = await GroupService.getGroupMembers(group) - return httpOk(res, members) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } + await group.updateOne({ spotifyAuthId: auth._id }, { new: true }) + return group } -export const getGroupMemberships = async (req: Request, res: Response) => { - /** - @swagger - #swagger.tags = ['Group'] - #swagger.summary = "Not implemented" - */ - const { groupId } = req.params +export const getGroupSpotify = async (groupId: string) => { + const group = await Group.findById(groupId) + if (!group) throw new NotFoundError(`Group with id ${groupId} not found.`) - 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}.`) - try { - const memberships = await GroupService.getGroupMemberships(group) - return httpOk(res, memberships) - } catch (error: any) { - return httpBadRequest(res, error?.message) - } + return SpotifyService.connect(auth.spotifyEmail) } 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..edfcb87 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' @@ -48,7 +49,9 @@ const doc = { 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 ee4cbb1..2bafb00 100644 --- a/server/docs/swagger_output.json +++ b/server/docs/swagger_output.json @@ -162,10 +162,10 @@ } } }, - "/api/user/register": { - "post": { + "/api/spotify/": { + "delete": { "tags": [ - "User" + "Spotify" ], "description": "", "parameters": [ @@ -175,10 +175,7 @@ "schema": { "type": "object", "properties": { - "email": { - "example": "any" - }, - "password": { + "spotifyEmail": { "example": "any" } } @@ -213,7 +210,7 @@ } } }, - "/api/user/login": { + "/api/user/register": { "post": { "tags": [ "User" @@ -264,12 +261,29 @@ } } }, - "/api/user/me": { - "get": { + "/api/user/token": { + "post": { "tags": [ "User" ], "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "password": { + "example": "any" + } + } + } + } + ], "responses": { "400": { "schema": { @@ -397,10 +411,10 @@ } } }, - "/api/group/groups": { - "post": { + "/api/user/me": { + "get": { "tags": [ - "Group" + "User" ], "description": "", "responses": { @@ -429,8 +443,13 @@ "description": "Not implemented" } } - }, + } + }, + "/api/user/me/spotify-accounts": { "get": { + "tags": [ + "User" + ], "description": "", "responses": { "400": { @@ -460,8 +479,11 @@ } } }, - "/api/group/groups/{id}": { - "get": { + "/api/group/{id}/spotify": { + "post": { + "tags": [ + "Group" + ], "description": "", "parameters": [ { @@ -469,6 +491,18 @@ "in": "path", "required": true, "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "spotifyEmail": { + "example": "any" + } + } + } } ], "responses": { @@ -497,8 +531,13 @@ "description": "Not implemented" } } - }, - "put": { + } + }, + "/api/group/{id}/spotify/current-track": { + "get": { + "tags": [ + "Group" + ], "description": "", "parameters": [ { @@ -534,8 +573,14 @@ "description": "Not implemented" } } - }, - "patch": { + } + }, + "/api/group/{id}/jam": { + "post": { + "tags": [ + "Group" + ], + "summary": "Not implemented", "description": "", "parameters": [ { @@ -573,6 +618,10 @@ } }, "delete": { + "tags": [ + "Group" + ], + "summary": "Not implemented", "description": "", "parameters": [ { @@ -610,16 +659,172 @@ } } }, - "/api/group/groups/{groupId}/jam": { + "/api/group/groups": { "post": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "body", + "in": "body", + "description": "New Group", + "required": true, + "schema": { + "$ref": "#/definitions/Group" + } + } + ], + "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" + } + } + }, + "get": { + "tags": [ + "Group" + ], + "description": "", + "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" + } + } + } + }, + "/api/group/groups/{id}": { + "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" + } + } + }, + "put": { + "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" + } + } + }, + "patch": { + "tags": [ + "Group" + ], + "description": "", + "parameters": [ + { + "name": "id", "in": "path", "required": true, "type": "string" @@ -656,11 +861,10 @@ "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { - "name": "groupId", + "name": "id", "in": "path", "required": true, "type": "string" @@ -696,6 +900,19 @@ } }, "definitions": { + "Group": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "" + }, + "ownerId": { + "type": "string", + "example": "" + } + } + }, "Success200": { "type": "object", "properties": { diff --git a/server/lib/spotify.ts b/server/lib/spotify.ts index ab3ac3c..1241416 100644 --- a/server/lib/spotify.ts +++ b/server/lib/spotify.ts @@ -1,9 +1,21 @@ +/** + * Resources + * - Repo: https://github.com/spotify/spotify-web-api-ts-sdk/tree/main + * - Authentication: https://developer.spotify.com/documentation/web-api/tutorials/code-flow + */ 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' -// tokens: { accessToken: string; refreshToken: string, expiresIn: number } +const SPOTIFY_SCOPES = [ + 'user-read-private', + 'user-read-email', + 'playlist-modify-public', + 'playlist-modify-private', + 'user-read-playback-state', + 'user-modify-playback-state' +] export type SpotifySdk = SpotifyApi @@ -42,7 +54,7 @@ export const getSpotifyRedirectUri = (state: { userId: string; finalRedirect?: s stringify({ response_type: 'code', client_id: SPOTIFY_CLIENT_ID, - scope: 'user-read-private user-read-email', + scope: SPOTIFY_SCOPES.join(', '), redirect_uri: SPOTIFY_REDIRECT_URI, state: stateString }) @@ -50,12 +62,21 @@ export const getSpotifyRedirectUri = (state: { userId: string; finalRedirect?: s return url } -export const authenticateSpotify = async (code: string): Promise => { - const body: SpotifyAuthReqBody = { - grant_type: 'authorization_code', - code, - redirect_uri: SPOTIFY_REDIRECT_URI - } +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 diff --git a/server/models/groupModel.ts b/server/models/groupModel.ts index ac739d0..855b8af 100644 --- a/server/models/groupModel.ts +++ b/server/models/groupModel.ts @@ -4,17 +4,17 @@ export interface IGroup { id: string name: string ownerId: string - spotifyAuthId: string + spotifyAuthId?: string } export interface IGroupFields extends Omit { ownerId: typeof Types.ObjectId - spotifyAuthId: typeof Types.ObjectId + spotifyAuthId?: typeof Types.ObjectId } export interface IGroupMethods extends IModelMethods {} type IGroupModel = Model -const groupSchema = new mongoose.Schema( +const GroupSchema = new mongoose.Schema( { ownerId: { type: Types.ObjectId, @@ -22,11 +22,14 @@ const groupSchema = new mongoose.Schema export const Membership = mongoose.model('Membership', membershipSchema) export type Membership = InstanceType diff --git a/server/models/spotifyAuthModel.ts b/server/models/spotifyAuthModel.ts index 6520e0a..f76f3f5 100644 --- a/server/models/spotifyAuthModel.ts +++ b/server/models/spotifyAuthModel.ts @@ -8,6 +8,7 @@ export interface ISpotifyAuth { spotifyEmail: string expiresIn: number tokenType: string + expiresAt: Date } export interface ISpotifyAuthFields extends Omit { @@ -42,6 +43,9 @@ export const SpotifyAuthSchema = new Schema< expiresIn: { type: Number }, + expiresAt: { + type: Date + }, tokenType: { type: String } diff --git a/server/routes/groupRoutes.ts b/server/routes/groupRoutes.ts index afb890d..d80009c 100644 --- a/server/routes/groupRoutes.ts +++ b/server/routes/groupRoutes.ts @@ -1,10 +1,15 @@ import { Router } from 'express' -import * as GroupController from '../controllers/groupController' +import { isAuthenticated } from 'server/middleware' import * as JamController from '../controllers/jamController' import * as views from '../views/groupViews' const router = Router() +router.post('/:id/spotify', isAuthenticated, views.assignSpotifyAccountView) +router.get('/:id/spotify/current-track', isAuthenticated, views.getGroupCurrentTrackView) +router.post('/:id/jam', JamController.startJam) +router.delete('/:id/jam', JamController.endJam) + router.post('/groups', views.groupCreateView) router.get('/groups', views.groupListView) router.get('/groups/:id', views.groupGetView) @@ -12,16 +17,4 @@ router.put('/groups/:id', views.groupUpdateView) router.patch('/groups/:id', views.groupPartialUpdateView) router.delete('/groups/:id', views.groupDeleteView) -// 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('/groups/:groupId/jam', JamController.startJam) -router.delete('/groups/:groupId/jam', JamController.endJam) - export const groupRoutes = router diff --git a/server/routes/spotifyRoutes.ts b/server/routes/spotifyRoutes.ts index 4e1a379..5c82042 100644 --- a/server/routes/spotifyRoutes.ts +++ b/server/routes/spotifyRoutes.ts @@ -7,5 +7,6 @@ const router = Router() /**== Spotify Authentication - /api/spotify/ ==**/ 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 1cf40e3..42b05f0 100644 --- a/server/services/spotifyService.ts +++ b/server/services/spotifyService.ts @@ -1,4 +1,10 @@ -import { authenticateSpotify, getSpotifyEmail, getSpotifySdk, type SpotifySdk, type SpotifyTokens } from 'server/lib' +import { + authenticateSpotify, + getSpotifyEmail, + getSpotifySdk, + type SpotifySdk, + type SpotifyTokens +} from 'server/lib' import { SpotifyAuth } from 'server/models' export class SpotifyService { @@ -16,11 +22,26 @@ export class SpotifyService { if (!spotifyAuth) throw new Error(`Unable to find connected spotify account with email ${spotifyEmail}.`) - return new SpotifyService(spotifyAuth) + if (spotifyAuth.expiresAt.getTime() > Date.now()) { + return new SpotifyService(spotifyAuth) + } + + const tokens = await authenticateSpotify({ + type: 'refresh_token', + payload: spotifyAuth.refreshToken + }) + + const updatedAuth = await this.udpateOrCreateAuth( + spotifyAuth.userId.toString(), + spotifyAuth.spotifyEmail, + tokens + ) + + return new SpotifyService(updatedAuth) } public static async authenticateUser(userId: string, code: string): Promise { - const tokens = await authenticateSpotify(code) + const tokens = await authenticateSpotify({ type: 'authorization_code', payload: code }) const userEmail = await getSpotifyEmail(tokens) const spotifyAuth = await this.udpateOrCreateAuth(userId, userEmail, tokens) @@ -35,13 +56,21 @@ export class SpotifyService { const query = await SpotifyAuth.findOne({ userId, spotifyEmail }) if (!query) { - return await SpotifyAuth.create({ userId, spotifyEmail, ...tokens }) + return await SpotifyAuth.create({ + userId, + spotifyEmail, + expiresAt: new Date(Date.now() + tokens.expiresIn * 1000), + ...tokens + }) } else { - const updated = await query.updateOne({ ...tokens }, { new: true }) - return updated + // 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 } } - + public async getProfile() { return await this.sdk.currentUser.profile() } diff --git a/server/types/index.d.ts b/server/types/index.d.ts index dd4c2b6..8263a4e 100644 --- a/server/types/index.d.ts +++ b/server/types/index.d.ts @@ -8,5 +8,5 @@ declare interface IModelMethods { // declare type IModelFields = Omit // declare interface IModelFields extends Omit { - + // } diff --git a/server/utils/exceptions/generalExceptions.ts b/server/utils/exceptions/generalExceptions.ts index 555c4f3..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' } } diff --git a/server/views/groupViews.ts b/server/views/groupViews.ts index 4a1d75b..ed3fffb 100644 --- a/server/views/groupViews.ts +++ b/server/views/groupViews.ts @@ -1,18 +1,88 @@ +import type { NextFunction, Request, Response } from 'express' +import { assignSpotifyToGroup, getGroupSpotify } from 'server/controllers/groupController' import { Group } from 'server/models' -import { apiRequest } from 'server/utils' +import { apiAuthRequest } from 'server/utils' import { Viewset } from '../utils/apis/viewsets' const groupViewset = new Viewset(Group) -export const groupCreateView = apiRequest((req, res, next) => { +type ApiArgs = [req: Request, res: Response, next: NextFunction] + +export const assignSpotifyAccountView = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const { user } = res.locals + let { spotifyEmail } = req.body + let { id } = req.params + + spotifyEmail = String(spotifyEmail) + id = String(id) + + return await assignSpotifyToGroup(user, id, spotifyEmail) +}) + +export const getGroupCurrentTrackView = apiAuthRequest(async (req, res, next) => { /** @swagger #swagger.tags = ['Group'] */ - return groupViewset.create(req, res, next) + let {id} = req.params + id = String(id) + + const spotify = await getGroupSpotify(id) + return await spotify.sdk.player.getCurrentlyPlayingTrack() }) -export const groupListView = groupViewset.list -export const groupGetView = groupViewset.get -export const groupUpdateView = groupViewset.update -export const groupPartialUpdateView = groupViewset.partialUpdate -export const groupDeleteView = groupViewset.delete + +/** ========= Resource CRUD Views ========== */ + +export const groupCreateView = (...args: ApiArgs) => { + /** + @swagger + #swagger.tags = ['Group'] + #swagger.parameters['body'] = { + in: "body", + name: "body", + description: "New Group", + required: true, + schema: {$ref: "#/definitions/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 index 006d46d..1d16e9f 100644 --- a/server/views/spotifyAuthViews.ts +++ b/server/views/spotifyAuthViews.ts @@ -1,5 +1,5 @@ import { getSpotifyRedirectUri } from 'server/lib' -import { User } from 'server/models' +import { SpotifyAuth, User } from 'server/models' import { SpotifyService } from 'server/services' import { apiAuthRequest, apiRequest, httpSeeOther, UnauthorizedError } from 'server/utils' @@ -44,3 +44,18 @@ export const spotifyLoginCallbackView = apiRequest(async (req, res) => { 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) From cec5350d2d9c4a5525e6ff686409adb8c89098e1 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sun, 8 Sep 2024 15:11:37 -0400 Subject: [PATCH 4/4] create spotify state controller, spotify responds 502 error --- server/controllers/groupController.ts | 65 +++++- server/docs/swagger.ts | 21 +- server/docs/swagger_output.json | 298 ++++++++++++++++++++++---- server/lib/spotify.ts | 6 +- server/middleware/authMiddleware.ts | 10 +- server/models/groupModel.ts | 4 + server/routes/groupRoutes.ts | 18 +- server/services/spotifyService.ts | 36 ++++ server/views/groupViews.ts | 60 ++++-- 9 files changed, 428 insertions(+), 90 deletions(-) diff --git a/server/controllers/groupController.ts b/server/controllers/groupController.ts index 78dc044..5f4d9a6 100644 --- a/server/controllers/groupController.ts +++ b/server/controllers/groupController.ts @@ -1,26 +1,81 @@ +import type { Model } from 'mongoose' import { Group, SpotifyAuth, type User } from 'server/models' import { SpotifyService } from 'server/services' import { NotFoundError } from 'server/utils' -export const assignSpotifyToGroup = async (user: User, groupId: string, spotifyEmail: string) => { +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.`) + } + + return query +} + +export const assignSpotifyToGroup = async ( + user: User, + groupId: string, + spotifyEmail: string +): Promise => { const auth = await SpotifyAuth.findOne({ userId: user._id.toString(), spotifyEmail }) if (!auth) throw new Error(`User ${user.email} is not connected to spotify account ${spotifyEmail}.`) - const group = await Group.findById(groupId) - if (!group) throw new NotFoundError(`Group with id ${groupId} not found.`) + const group = await getOrError(groupId, Group) await group.updateOne({ spotifyAuthId: auth._id }, { new: true }) return group } export const getGroupSpotify = async (groupId: string) => { - const group = await Group.findById(groupId) - if (!group) throw new NotFoundError(`Group with id ${groupId} not found.`) + const group = await getOrError(groupId, Group) const auth = await SpotifyAuth.findById(group.spotifyAuthId) if (!auth) throw new Error(`No linked Spotify accounts for group ${group.name}.`) return SpotifyService.connect(auth.spotifyEmail) } + +export const getGroupTrack = async (groupId: string) => { + const spotify = await getGroupSpotify(groupId) + return await spotify.sdk.player.getCurrentlyPlayingTrack() +} + +export const getGroupDevices = async (groupId: string) => { + const spotify = await getGroupSpotify(groupId) + return await spotify.sdk.player.getAvailableDevices() +} + +export const setGroupDefaultDevice = async (groupId: string, deviceId: string) => { + const group = await getOrError(groupId, Group) + group.defaultDeviceId = deviceId + await group.save() + + return group +} + +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/docs/swagger.ts b/server/docs/swagger.ts index edfcb87..98959b6 100644 --- a/server/docs/swagger.ts +++ b/server/docs/swagger.ts @@ -33,22 +33,15 @@ 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: { Group: { name: '', ownerId: '' } as IGroup } diff --git a/server/docs/swagger_output.json b/server/docs/swagger_output.json index 2bafb00..6e28d6c 100644 --- a/server/docs/swagger_output.json +++ b/server/docs/swagger_output.json @@ -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": { @@ -207,7 +220,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/register": { @@ -357,7 +375,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/reset-password": { @@ -408,7 +431,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/me": { @@ -442,7 +470,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/me/spotify-accounts": { @@ -476,7 +509,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/group/{id}/spotify": { @@ -530,7 +568,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/group/{id}/spotify/current-track": { @@ -572,15 +615,19 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/group/{id}/jam": { + "/api/group/{id}/spotify/state": { "post": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { @@ -588,6 +635,18 @@ "in": "path", "required": true, "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "state": { + "example": "any" + } + } + } } ], "responses": { @@ -615,13 +674,19 @@ }, "description": "Not implemented" } - } - }, - "delete": { + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/group/{id}/spotify/devices": { + "get": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { @@ -656,26 +721,88 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/group/groups": { + "/api/group/{id}/spotify/default-device": { "post": { "tags": [ "Group" ], "description": "", "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, { "name": "body", "in": "body", - "description": "New Group", - "required": true, "schema": { - "$ref": "#/definitions/Group" + "type": "object", + "properties": { + "deviceId": { + "example": "any" + } + } } } ], + "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/{id}/jam": { + "post": { + "tags": [ + "Group" + ], + "summary": "Not implemented", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], "responses": { "400": { "schema": { @@ -703,11 +830,20 @@ } } }, - "get": { + "delete": { "tags": [ "Group" ], + "summary": "Not implemented", "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], "responses": { "400": { "schema": { @@ -736,6 +872,82 @@ } } }, + "/api/group/groups": { + "post": { + "tags": [ + "Group" + ], + "description": "", + "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": [] + } + ] + }, + "get": { + "tags": [ + "Group" + ], + "description": "", + "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/{id}": { "get": { "tags": [ @@ -775,7 +987,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "put": { "tags": [ @@ -815,7 +1032,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "patch": { "tags": [ @@ -855,7 +1077,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "delete": { "tags": [ @@ -895,7 +1122,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } } }, @@ -1236,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/spotify.ts b/server/lib/spotify.ts index 1241416..9e13a50 100644 --- a/server/lib/spotify.ts +++ b/server/lib/spotify.ts @@ -2,6 +2,7 @@ * 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' @@ -14,7 +15,10 @@ const SPOTIFY_SCOPES = [ 'playlist-modify-public', 'playlist-modify-private', 'user-read-playback-state', - 'user-modify-playback-state' + 'user-modify-playback-state', + 'user-read-currently-playing', + 'app-remote-control', + 'streaming' ] export type SpotifySdk = SpotifyApi diff --git a/server/middleware/authMiddleware.ts b/server/middleware/authMiddleware.ts index 4acb038..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,7 +18,12 @@ 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 diff --git a/server/models/groupModel.ts b/server/models/groupModel.ts index 855b8af..b86eeef 100644 --- a/server/models/groupModel.ts +++ b/server/models/groupModel.ts @@ -5,6 +5,7 @@ export interface IGroup { name: string ownerId: string spotifyAuthId?: string + defaultDeviceId?: string } export interface IGroupFields extends Omit { @@ -30,6 +31,9 @@ const GroupSchema = new mongoose.Schema + // 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/views/groupViews.ts b/server/views/groupViews.ts index ed3fffb..30f9244 100644 --- a/server/views/groupViews.ts +++ b/server/views/groupViews.ts @@ -1,5 +1,11 @@ import type { NextFunction, Request, Response } from 'express' -import { assignSpotifyToGroup, getGroupSpotify } from 'server/controllers/groupController' +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' @@ -14,11 +20,8 @@ export const assignSpotifyAccountView = apiAuthRequest(async (req, res, next) => #swagger.tags = ['Group'] */ const { user } = res.locals - let { spotifyEmail } = req.body - let { id } = req.params - - spotifyEmail = String(spotifyEmail) - id = String(id) + const spotifyEmail = String(req.body.spotifyEmail) + const id = String(req.params.id) return await assignSpotifyToGroup(user, id, spotifyEmail) }) @@ -28,29 +31,48 @@ export const getGroupCurrentTrackView = apiAuthRequest(async (req, res, next) => @swagger #swagger.tags = ['Group'] */ - let {id} = req.params - id = String(id) - - const spotify = await getGroupSpotify(id) - return await spotify.sdk.player.getCurrentlyPlayingTrack() + 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) }) -/** ========= Resource CRUD Views ========== */ +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'] - #swagger.parameters['body'] = { - in: "body", - name: "body", - description: "New Group", - required: true, - schema: {$ref: "#/definitions/Group"} - } */ return groupViewset.create(...args) } + export const groupListView = (...args: ApiArgs) => { /** @swagger