From a6415ae694064326bb4fffc8152905a9ad28d758 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 11:37:44 -0400 Subject: [PATCH 01/16] Auth 0 setup --- auth/index.js | 3 +- package-lock.json | 94 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 + 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/auth/index.js b/auth/index.js index 07968c5..e8f00e7 100644 --- a/auth/index.js +++ b/auth/index.js @@ -88,6 +88,7 @@ router.post("/auth0", async (req, res) => { res.send({ message: "Auth0 authentication successful", + token: token, user: { id: user.id, username: user.username, @@ -169,7 +170,6 @@ router.post("/login", async (req, res) => { // Find user const user = await User.findOne({ where: { username } }); - user.checkPassword(password); if (!user) { return res.status(401).send({ error: "Invalid credentials" }); } @@ -200,6 +200,7 @@ router.post("/login", async (req, res) => { res.send({ message: "Login successful", + token: token, user: { id: user.id, username: user.username }, }); } catch (error) { diff --git a/package-lock.json b/package-lock.json index af0cf82..2dc9ed1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "license": "ISC", "dependencies": { + "@auth0/auth0-react": "^2.4.0", + "@neondatabase/serverless": "^1.0.1", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -26,6 +28,53 @@ "win-node-env": "^0.6.1" } }, + "node_modules/@auth0/auth0-react": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.4.0.tgz", + "integrity": "sha512-5bt3sO9FVupNM15IpqyYu/2OPHpLI5El7RgWLQXZOPbnCBbtl+VgdHR+H2NfhNQ4SqQtC/5uKbHWafcVcsxkiw==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ^19", + "react-dom": "^16.11.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.3.0.tgz", + "integrity": "sha512-zAW6w79UO+G1+3AxboVQIUIZy05xluSOb1ymGg2dqG0pIi0JxEtZGec05BOf2LJ9SehzW4WeCYUQsYD9BjrVpQ==", + "license": "MIT" + }, + "node_modules/@neondatabase/serverless": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.1.tgz", + "integrity": "sha512-O6yC5TT0jbw86VZVkmnzCZJB0hfxBl0JJz6f+3KHoZabjb/X08r9eFA+vuY06z1/qaovykvdkrXYq3SPUuvogA==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/@types/node": { + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -50,6 +99,17 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/pg": { + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/validator": { "version": "13.15.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", @@ -1336,6 +1396,29 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1397,6 +1480,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", diff --git a/package.json b/package.json index 7e0a0af..e339607 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "license": "ISC", "description": "", "dependencies": { + "@auth0/auth0-react": "^2.4.0", + "@neondatabase/serverless": "^1.0.1", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", From e3fad3e84bd67b770fc1ba1ddaaab69964c208f7 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 13:05:44 -0400 Subject: [PATCH 02/16] Cors update backend --- app.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 5857036..d454f87 100644 --- a/app.js +++ b/app.js @@ -16,12 +16,17 @@ const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; // body parser middleware app.use(express.json()); -app.use( - cors({ - origin: FRONTEND_URL, - credentials: true, - }) -); +const corsOptions = { + origin: [ + "http://localhost:3000", // Development + "https://capstone-2-frontend-one.vercel.app", // Production frontend + ], + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "Cookie"], +}; + +app.use(cors(corsOptions)); // cookie parser middleware app.use(cookieParser()); From c5cc9a080a88b912e4e4f7c044bbf378aa4012b5 Mon Sep 17 00:00:00 2001 From: Webbney Vallon Date: Thu, 31 Jul 2025 13:33:10 -0400 Subject: [PATCH 03/16] feat: added posts model --- .env.example | 3 --- database/db.js | 2 +- database/index.js | 2 ++ database/posts.js | 32 ++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- 5 files changed, 37 insertions(+), 6 deletions(-) delete mode 100644 .env.example create mode 100644 database/posts.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 848ad61..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -DATABASE_URL=postgres://hostname:5432/database-name -JWT_SECRET=super-secret-jwt-key -FRONTEND_URL=http://localhost:3000 diff --git a/database/db.js b/database/db.js index b251a9d..3b580f1 100644 --- a/database/db.js +++ b/database/db.js @@ -3,7 +3,7 @@ const { Sequelize } = require("sequelize"); const pg = require("pg"); // Feel free to rename the database to whatever you want! -const dbName = "capstone-1"; +const dbName = "capstone-2"; const db = new Sequelize( process.env.DATABASE_URL || `postgres://localhost:5432/${dbName}`, diff --git a/database/index.js b/database/index.js index e498df6..9e7350b 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,9 @@ const db = require("./db"); const User = require("./user"); +const Posts = require("./posts"); module.exports = { db, User, + Posts, }; diff --git a/database/posts.js b/database/posts.js new file mode 100644 index 0000000..a7d9a9b --- /dev/null +++ b/database/posts.js @@ -0,0 +1,32 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Posts = db.define("poats", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + }, + + title: { + type: DataTypes.STRING, + }, + + body: { + type: DataTypes.TEXT, + }, + + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + + status: { + type: DataTypes.STRING, + }, + + created_at: { + type: DataTypes.INTEGER, + }, +}); + +module.exports = Posts; diff --git a/package-lock.json b/package-lock.json index af0cf82..1b30289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "license": "ISC", "dependencies": { From a44842ed81ef261de9996b2745dcdb0cb394c451 Mon Sep 17 00:00:00 2001 From: Webbney Vallon Date: Thu, 31 Jul 2025 14:21:12 -0400 Subject: [PATCH 04/16] feat: added follows model --- database/follows.js | 24 ++++++++++++++++++++++++ database/index.js | 2 ++ database/posts.js | 4 ++-- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 database/follows.js diff --git a/database/follows.js b/database/follows.js new file mode 100644 index 0000000..9fa3eef --- /dev/null +++ b/database/follows.js @@ -0,0 +1,24 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Follows = db.define("follows", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + }, + + following_user_id: { + type: DataTypes.INTEGER, + }, + follows_user_id: { + type: DataTypes.INTEGER, + }, + follows_user_id: { + type: DataTypes.INTEGER, + }, + created_at: { + type: DataTypes.INTEGER, + }, +}); + +module.exports = Follows; diff --git a/database/index.js b/database/index.js index 9e7350b..f7c52f2 100644 --- a/database/index.js +++ b/database/index.js @@ -1,9 +1,11 @@ const db = require("./db"); const User = require("./user"); const Posts = require("./posts"); +const Follows = require("./follows"); module.exports = { db, User, Posts, + Follows, }; diff --git a/database/posts.js b/database/posts.js index a7d9a9b..233a9d4 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,7 +1,7 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const Posts = db.define("poats", { +const Posts = db.define("posts", { id: { type: DataTypes.INTEGER, primaryKey: true, @@ -11,7 +11,7 @@ const Posts = db.define("poats", { type: DataTypes.STRING, }, - body: { + descriptions: { type: DataTypes.TEXT, }, From adeb205601b86c3314a55f3cb4fe7000abd8ba95 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:22:50 -0400 Subject: [PATCH 05/16] will undo this --- app.js | 2 +- auth/index.js | 3 + auth/spotify.js | 195 ++++++++++++++++++++++++++++++++++++++++++++++ database/user.js | 35 ++++++++- package-lock.json | 132 +++++++++++++++++++++++++++++++ package.json | 1 + 6 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 auth/spotify.js diff --git a/app.js b/app.js index d454f87..c13a244 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ const corsOptions = { "https://capstone-2-frontend-one.vercel.app", // Production frontend ], credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "Cookie"], }; diff --git a/auth/index.js b/auth/index.js index e8f00e7..efc5b50 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,6 +1,7 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); +const spotifyRouter = require("./spotify"); const router = express.Router(); @@ -231,4 +232,6 @@ router.get("/me", (req, res) => { }); }); +router.use("/spotify", spotifyRouter); + module.exports = { router, authenticateJWT }; diff --git a/auth/spotify.js b/auth/spotify.js new file mode 100644 index 0000000..dc76d71 --- /dev/null +++ b/auth/spotify.js @@ -0,0 +1,195 @@ +const express = require("express"); +const axios = require("axios"); +const { User } = require("../database"); + +const router = express.Router(); + +const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; + +// Import authenticateJWT middleware +const jwt = require("jsonwebtoken"); +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +// Define authenticateJWT middleware locally or import it properly +const authenticateJWT = (req, res, next) => { + const token = req.cookies.token; + + if (!token) { + return res.status(401).send({ error: "Access token required" }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).send({ error: "Invalid or expired token" }); + } + req.user = user; + next(); + }); +}; + +// Generate Spotify authorization URL +router.get("/auth-url", authenticateJWT, (req, res) => { + const scopes = [ + "user-read-private", + "user-read-email", + "user-top-read", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative" + ].join(" "); + + const params = new URLSearchParams({ + response_type: "code", + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: req.user.id.toString(), + }); + + const authUrl = `https://accounts.spotify.com/authorize?${params}`; + res.json({ authUrl }); +}); + +// Handle Spotify callback +router.post("/callback", authenticateJWT, async (req, res) => { + try { + const { code, state } = req.body; + + // Verify state matches user ID + if (state !== req.user.id.toString()) { + return res.status(400).json({ error: "Invalid state parameter" }); + } + + // Exchange code for access token + const tokenResponse = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, refresh_token, expires_in } = tokenResponse.data; + + // Get user's Spotify profile + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.data; + + // Update user with Spotify data + const user = await User.findByPk(req.user.id); + await user.update({ + spotifyId: spotifyProfile.id, + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + }); + + res.json({ + message: "Spotify connected successfully", + spotify: { + id: spotifyProfile.id, + displayName: spotifyProfile.display_name, + profileImage: spotifyProfile.images?.[0]?.url, + }, + }); + } catch (error) { + console.error("Spotify callback error:", error); + res.status(500).json({ error: "Failed to connect Spotify account" }); + } +}); + +// Get user's Spotify data +router.get("/profile", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user.spotifyId) { + return res.json({ connected: false }); + } + + // Check if token needs refresh + if (!user.isSpotifyTokenValid()) { + return res.status(401).json({ error: "Spotify token expired" }); + } + + // Get current Spotify profile + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${user.spotifyAccessToken}`, + }, + }); + + res.json({ + connected: true, + profile: profileResponse.data, + }); + } catch (error) { + console.error("Spotify profile error:", error); + res.status(500).json({ error: "Failed to get Spotify profile" }); + } +}); + +// Get user's top tracks +router.get("/top-tracks", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user.spotifyAccessToken || !user.isSpotifyTokenValid()) { + return res.status(401).json({ error: "Spotify not connected or token expired" }); + } + + const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { + headers: { + Authorization: `Bearer ${user.spotifyAccessToken}`, + }, + params: { + limit: 20, + time_range: "medium_term", + }, + }); + + res.json(response.data); + } catch (error) { + console.error("Spotify top tracks error:", error); + res.status(500).json({ error: "Failed to get top tracks" }); + } +}); + +// Disconnect Spotify +router.delete("/disconnect", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + await user.update({ + spotifyId: null, + spotifyAccessToken: null, + spotifyRefreshToken: null, + spotifyTokenExpiresAt: null, + spotifyDisplayName: null, + spotifyProfileImage: null, + }); + + res.json({ message: "Spotify disconnected successfully" }); + } catch (error) { + console.error("Spotify disconnect error:", error); + res.status(500).json({ error: "Failed to disconnect Spotify" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/database/user.js b/database/user.js index 755c757..59b83a7 100644 --- a/database/user.js +++ b/database/user.js @@ -28,6 +28,32 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, }, + // Add Spotify fields + spotifyId: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + spotifyAccessToken: { + type: DataTypes.TEXT, + allowNull: true, + }, + spotifyRefreshToken: { + type: DataTypes.TEXT, + allowNull: true, + }, + spotifyTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true, + }, + spotifyDisplayName: { + type: DataTypes.STRING, + allowNull: true, + }, + spotifyProfileImage: { + type: DataTypes.STRING, + allowNull: true, + }, }); // Instance method to check password @@ -43,4 +69,11 @@ User.hashPassword = function (password) { return bcrypt.hashSync(password, 10); }; -module.exports = User; +// Method to check if Spotify token is valid +User.prototype.isSpotifyTokenValid = function () { + return this.spotifyAccessToken && + this.spotifyTokenExpiresAt && + new Date() < this.spotifyTokenExpiresAt; +}; + +module.exports = User; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2dc9ed1..d6c6754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@auth0/auth0-react": "^2.4.0", "@neondatabase/serverless": "^1.0.1", + "axios": "^1.11.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -143,6 +144,23 @@ "node": ">= 8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -308,6 +326,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -403,6 +433,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -498,6 +537,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -585,6 +639,63 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -711,6 +822,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1350,6 +1476,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index e339607..ebb9f40 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@auth0/auth0-react": "^2.4.0", "@neondatabase/serverless": "^1.0.1", + "axios": "^1.11.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", From a0c247df2dd93fef668c3913f97232b2d66e71e1 Mon Sep 17 00:00:00 2001 From: Webbney Vallon Date: Thu, 31 Jul 2025 14:34:02 -0400 Subject: [PATCH 06/16] refactor: fixed typo --- database/posts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/posts.js b/database/posts.js index a7d9a9b..14116e9 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,7 +1,7 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const Posts = db.define("poats", { +const Posts = db.define("posts", { id: { type: DataTypes.INTEGER, primaryKey: true, From a4202ddf7978163a908e5d0fe5686853b42314a0 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:45:04 -0400 Subject: [PATCH 07/16] . --- auth/index.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/auth/index.js b/auth/index.js index efc5b50..62973c6 100644 --- a/auth/index.js +++ b/auth/index.js @@ -24,6 +24,17 @@ const authenticateJWT = (req, res, next) => { }); }; +router.get("/migrate", async (req, res) => { + try { + const { User } = require("../database"); + await User.sync({ alter: true }); // This will add missing columns + res.json({ message: "Database synced successfully" }); + } catch (error) { + console.error("Migration error:", error); + res.status(500).json({ error: error.message }); + } +}); + // Auth0 authentication route router.post("/auth0", async (req, res) => { try { From 7d53b9a98c97f5d0faf47620a82d6d75ee76ea79 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:51:43 -0400 Subject: [PATCH 08/16] g --- auth/index.js | 3 ++- vercel.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/auth/index.js b/auth/index.js index 62973c6..d5e4a8a 100644 --- a/auth/index.js +++ b/auth/index.js @@ -2,6 +2,7 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); const spotifyRouter = require("./spotify"); +const app = require("../app"); const router = express.Router(); @@ -245,4 +246,4 @@ router.get("/me", (req, res) => { router.use("/spotify", spotifyRouter); -module.exports = { router, authenticateJWT }; +module.exports = { router, authenticateJWT, app }; diff --git a/vercel.json b/vercel.json index 1b0a308..82898b1 100644 --- a/vercel.json +++ b/vercel.json @@ -2,14 +2,14 @@ "version": 2, "builds": [ { - "src": "app.js", + "src": "api/index.js", "use": "@vercel/node" } ], "routes": [ { "src": "/(.*)", - "dest": "app.js" + "dest": "api/index.js" } ] -} +} \ No newline at end of file From a9c449f433e9973dc7e773a7a59d25bdeb51d87a Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:54:14 -0400 Subject: [PATCH 09/16] g --- api/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/index.js b/api/index.js index f08162e..3c0bb61 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,8 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); +const app = require ("../app"); router.use("/test-db", testDbRouter); -module.exports = router; +module.exports = {router, app}; From f6311f0c93fc1f27ad8d7ca4320112197021944a Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:55:00 -0400 Subject: [PATCH 10/16] g --- api/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/index.js b/api/index.js index 3c0bb61..4433252 100644 --- a/api/index.js +++ b/api/index.js @@ -5,4 +5,4 @@ const app = require ("../app"); router.use("/test-db", testDbRouter); -module.exports = {router, app}; +module.exports = app; From 50045095ffbc6fa620fd4c3e8aa785fdf461f444 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 15:04:40 -0400 Subject: [PATCH 11/16] undid --- api/index.js | 3 +-- auth/index.js | 3 +-- vercel.json | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/api/index.js b/api/index.js index 4433252..f08162e 100644 --- a/api/index.js +++ b/api/index.js @@ -1,8 +1,7 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); -const app = require ("../app"); router.use("/test-db", testDbRouter); -module.exports = app; +module.exports = router; diff --git a/auth/index.js b/auth/index.js index d5e4a8a..62973c6 100644 --- a/auth/index.js +++ b/auth/index.js @@ -2,7 +2,6 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); const spotifyRouter = require("./spotify"); -const app = require("../app"); const router = express.Router(); @@ -246,4 +245,4 @@ router.get("/me", (req, res) => { router.use("/spotify", spotifyRouter); -module.exports = { router, authenticateJWT, app }; +module.exports = { router, authenticateJWT }; diff --git a/vercel.json b/vercel.json index 82898b1..1b0a308 100644 --- a/vercel.json +++ b/vercel.json @@ -2,14 +2,14 @@ "version": 2, "builds": [ { - "src": "api/index.js", + "src": "app.js", "use": "@vercel/node" } ], "routes": [ { "src": "/(.*)", - "dest": "api/index.js" + "dest": "app.js" } ] -} \ No newline at end of file +} From b6bff0075535ba0b317837bde346df71bfa336d9 Mon Sep 17 00:00:00 2001 From: Micksterious <134881306+Micksterious@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:08:57 -0400 Subject: [PATCH 12/16] Delete database/posts.js this file is already on main --- database/posts.js | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 database/posts.js diff --git a/database/posts.js b/database/posts.js deleted file mode 100644 index 233a9d4..0000000 --- a/database/posts.js +++ /dev/null @@ -1,32 +0,0 @@ -const { DataTypes } = require("sequelize"); -const db = require("./db"); - -const Posts = db.define("posts", { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - }, - - title: { - type: DataTypes.STRING, - }, - - descriptions: { - type: DataTypes.TEXT, - }, - - user_id: { - type: DataTypes.INTEGER, - allowNull: false, - }, - - status: { - type: DataTypes.STRING, - }, - - created_at: { - type: DataTypes.INTEGER, - }, -}); - -module.exports = Posts; From f52422f3cbe8c8fd5fd14cc8afef0b4d834384e5 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 1 Aug 2025 11:23:18 -0400 Subject: [PATCH 13/16] Restored posts.js to resolve merge conflict --- database/posts.js | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 database/posts.js diff --git a/database/posts.js b/database/posts.js new file mode 100644 index 0000000..25945be --- /dev/null +++ b/database/posts.js @@ -0,0 +1,60 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); +const bcrypt = require("bcrypt"); + +const Post = db.define("post", { + title: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + len: [3, 20], + }, + }, + email: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + validate: { + isEmail: true, + }, + }, + auth0Id: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + passwordHash: { + type: DataTypes.STRING, + allowNull: true, + }, + role: { + type: DataTypes.ENUM("User", "Admin"), + defaultValue: "User", + allowNull: false, + }, +}); + +// Instance method to check password +User.prototype.checkPassword = function (password) { + if (!this.passwordHash) { + return false; // Auth0 users don't have passwords + } + return bcrypt.compareSync(password, this.passwordHash); +}; + +// Class method to hash password +User.hashPassword = function (password) { + return bcrypt.hashSync(password, 10); +}; + +// Method to check if Spotify token is valid +User.prototype.isSpotifyTokenValid = function () { + return ( + this.spotifyAccessToken && + this.spotifyTokenExpiresAt && + new Date() < this.spotifyTokenExpiresAt + ); +}; + +module.exports = User; From 50fb32fc89f37c56c1699e04b48bed30ebcd79b3 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Fri, 1 Aug 2025 11:53:58 -0400 Subject: [PATCH 14/16] added spotify login --- app.js | 24 ++-- auth/index.js | 110 +++++++------------ auth/spotify.js | 280 ++++++++++++++++++++++++++++++++++++++++------- database/user.js | 6 +- 4 files changed, 291 insertions(+), 129 deletions(-) diff --git a/app.js b/app.js index c13a244..9ad88fe 100644 --- a/app.js +++ b/app.js @@ -2,24 +2,23 @@ require("dotenv").config(); const express = require("express"); const morgan = require("morgan"); const path = require("path"); -const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); const app = express(); const apiRouter = require("./api"); const { router: authRouter } = require("./auth"); +const spotifyRouter = require("./auth/spotify"); const { db } = require("./database"); const cors = require("cors"); const PORT = process.env.PORT || 8080; -const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; -// body parser middleware app.use(express.json()); const corsOptions = { origin: [ - "http://localhost:3000", // Development - "https://capstone-2-frontend-one.vercel.app", // Production frontend + "http://localhost:3000", + "http://127.0.0.1:3000", + "https://capstone-2-frontend-one.vercel.app", ], credentials: true, methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], @@ -27,16 +26,13 @@ const corsOptions = { }; app.use(cors(corsOptions)); - -// cookie parser middleware app.use(cookieParser()); +app.use(morgan("dev")); +app.use(express.static(path.join(__dirname, "public"))); +app.use("/api", apiRouter); +app.use("/auth", authRouter); +app.use("/auth/spotify", spotifyRouter); -app.use(morgan("dev")); // logging middleware -app.use(express.static(path.join(__dirname, "public"))); // serve static files from public folder -app.use("/api", apiRouter); // mount api router -app.use("/auth", authRouter); // mount auth router - -// error handling middleware app.use((err, req, res, next) => { console.error(err.stack); res.sendStatus(500); @@ -56,4 +52,4 @@ const runApp = async () => { runApp(); -module.exports = app; +module.exports = app; \ No newline at end of file diff --git a/auth/index.js b/auth/index.js index 62973c6..eed714c 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,15 +1,27 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); -const spotifyRouter = require("./spotify"); const router = express.Router(); - const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; -// Middleware to authenticate JWT tokens +const cookieSettings = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 24 * 60 * 60 * 1000, + path: "/", +}; + const authenticateJWT = (req, res, next) => { - const token = req.cookies.token; + let token = req.cookies.token; + + if (!token && req.headers.authorization) { + const authHeader = req.headers.authorization; + if (authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + } if (!token) { return res.status(401).send({ error: "Access token required" }); @@ -24,18 +36,6 @@ const authenticateJWT = (req, res, next) => { }); }; -router.get("/migrate", async (req, res) => { - try { - const { User } = require("../database"); - await User.sync({ alter: true }); // This will add missing columns - res.json({ message: "Database synced successfully" }); - } catch (error) { - console.error("Migration error:", error); - res.status(500).json({ error: error.message }); - } -}); - -// Auth0 authentication route router.post("/auth0", async (req, res) => { try { const { auth0Id, email, username } = req.body; @@ -44,30 +44,24 @@ router.post("/auth0", async (req, res) => { return res.status(400).send({ error: "Auth0 ID is required" }); } - // Try to find existing user by auth0Id first let user = await User.findOne({ where: { auth0Id } }); if (!user && email) { - // If no user found by auth0Id, try to find by email user = await User.findOne({ where: { email } }); - if (user) { - // Update existing user with auth0Id user.auth0Id = auth0Id; await user.save(); } } if (!user) { - // Create new user if not found const userData = { auth0Id, email: email || null, - username: username || email?.split("@")[0] || `user_${Date.now()}`, // Use email prefix as username if no username provided - passwordHash: null, // Auth0 users don't have passwords + username: username || email?.split("@")[0] || `user_${Date.now()}`, + passwordHash: null, }; - // Ensure username is unique let finalUsername = userData.username; let counter = 1; while (await User.findOne({ where: { username: finalUsername } })) { @@ -79,7 +73,6 @@ router.post("/auth0", async (req, res) => { user = await User.create(userData); } - // Generate JWT token with auth0Id included const token = jwt.sign( { id: user.id, @@ -91,12 +84,7 @@ router.post("/auth0", async (req, res) => { { expiresIn: "24h" } ); - res.cookie("token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + res.cookie("token", token, cookieSettings); res.send({ message: "Auth0 authentication successful", @@ -109,39 +97,30 @@ router.post("/auth0", async (req, res) => { }, }); } catch (error) { - console.error("Auth0 authentication error:", error); - res.sendStatus(500); + res.status(500).send({ error: "Internal server error" }); } }); -// Signup route router.post("/signup", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { - return res - .status(400) - .send({ error: "Username and password are required" }); + return res.status(400).send({ error: "Username and password are required" }); } if (password.length < 6) { - return res - .status(400) - .send({ error: "Password must be at least 6 characters long" }); + return res.status(400).send({ error: "Password must be at least 6 characters long" }); } - // Check if user already exists const existingUser = await User.findOne({ where: { username } }); if (existingUser) { return res.status(409).send({ error: "Username already exists" }); } - // Create new user const passwordHash = User.hashPassword(password); const user = await User.create({ username, passwordHash }); - // Generate JWT token const token = jwt.sign( { id: user.id, @@ -153,45 +132,35 @@ router.post("/signup", async (req, res) => { { expiresIn: "24h" } ); - res.cookie("token", token, { - httpOnly: true, - secure: true, - sameSite: "strict", - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + res.cookie("token", token, cookieSettings); res.send({ message: "User created successfully", + token: token, user: { id: user.id, username: user.username }, }); } catch (error) { - console.error("Signup error:", error); - res.sendStatus(500); + res.status(500).send({ error: "Internal server error" }); } }); -// Login route router.post("/login", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { - res.status(400).send({ error: "Username and password are required" }); - return; + return res.status(400).send({ error: "Username and password are required" }); } - // Find user const user = await User.findOne({ where: { username } }); if (!user) { return res.status(401).send({ error: "Invalid credentials" }); } - // Check password if (!user.checkPassword(password)) { return res.status(401).send({ error: "Invalid credentials" }); } - // Generate JWT token const token = jwt.sign( { id: user.id, @@ -203,12 +172,7 @@ router.post("/login", async (req, res) => { { expiresIn: "24h" } ); - res.cookie("token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + res.cookie("token", token, cookieSettings); res.send({ message: "Login successful", @@ -216,23 +180,27 @@ router.post("/login", async (req, res) => { user: { id: user.id, username: user.username }, }); } catch (error) { - console.error("Login error:", error); - res.sendStatus(500); + res.status(500).send({ error: "Internal server error" }); } }); -// Logout route router.post("/logout", (req, res) => { res.clearCookie("token"); res.send({ message: "Logout successful" }); }); -// Get current user route (protected) router.get("/me", (req, res) => { - const token = req.cookies.token; + let token = req.cookies.token; + + if (!token && req.headers.authorization) { + const authHeader = req.headers.authorization; + if (authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + } if (!token) { - return res.send({}); + return res.send({ user: null }); } jwt.verify(token, JWT_SECRET, (err, user) => { @@ -243,6 +211,4 @@ router.get("/me", (req, res) => { }); }); -router.use("/spotify", spotifyRouter); - -module.exports = { router, authenticateJWT }; +module.exports = { router, authenticateJWT }; \ No newline at end of file diff --git a/auth/spotify.js b/auth/spotify.js index dc76d71..3deb366 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -1,35 +1,240 @@ const express = require("express"); const axios = require("axios"); +const jwt = require("jsonwebtoken"); const { User } = require("../database"); +const { authenticateJWT } = require("./index"); const router = express.Router(); const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; - -// Import authenticateJWT middleware -const jwt = require("jsonwebtoken"); const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; -// Define authenticateJWT middleware locally or import it properly -const authenticateJWT = (req, res, next) => { - const token = req.cookies.token; +const cookieSettings = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 24 * 60 * 60 * 1000, + path: "/", +}; + +const refreshSpotifyToken = async (user) => { + try { + const response = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: user.spotifyRefreshToken, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, expires_in, refresh_token } = response.data; + + await user.update({ + spotifyAccessToken: access_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyRefreshToken: refresh_token || user.spotifyRefreshToken, + }); - if (!token) { - return res.status(401).send({ error: "Access token required" }); + return access_token; + } catch (error) { + throw error; + } +}; + +const getValidSpotifyToken = async (user) => { + if (!user.spotifyAccessToken || !user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); } - jwt.verify(token, JWT_SECRET, (err, user) => { - if (err) { - return res.status(403).send({ error: "Invalid or expired token" }); + if (user.isSpotifyTokenValid()) { + return user.spotifyAccessToken; + } + + return await refreshSpotifyToken(user); +}; + +// Generate safe username from Spotify data +const generateUsername = async (spotifyProfile) => { + let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; + + // Clean username - remove invalid characters and limit length + baseUsername = baseUsername + .replace(/[^a-zA-Z0-9_]/g, '_') + .substring(0, 15) + .toLowerCase(); + + // Ensure minimum length + if (baseUsername.length < 3) { + baseUsername = `spotify_${spotifyProfile.id}`.substring(0, 20); + } + + // Check for uniqueness and modify if needed + let finalUsername = baseUsername; + let counter = 1; + + while (await User.findOne({ where: { username: finalUsername } })) { + finalUsername = `${baseUsername}_${counter}`; + counter++; + + // Prevent infinite loop + if (counter > 1000) { + finalUsername = `spotify_${Date.now()}`; + break; } - req.user = user; - next(); - }); + } + + return finalUsername; }; -// Generate Spotify authorization URL +router.get("/login-url", (req, res) => { + const scopes = [ + "user-read-private", + "user-read-email", + "user-top-read", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative" + ].join(" "); + + const params = new URLSearchParams({ + response_type: "code", + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: "spotify_login", + }); + + const authUrl = `https://accounts.spotify.com/authorize?${params}`; + res.json({ authUrl }); +}); + +router.post("/login", async (req, res) => { + try { + console.log("🎵 Spotify login attempt"); + const { code, state } = req.body; + + if (state !== "spotify_login") { + console.log("❌ Invalid state parameter:", state); + return res.status(400).json({ error: "Invalid state parameter" }); + } + + if (!code) { + console.log("❌ No authorization code"); + return res.status(400).json({ error: "No authorization code provided" }); + } + + console.log("🎵 Exchanging code for tokens..."); + const tokenResponse = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, refresh_token, expires_in } = tokenResponse.data; + console.log("🎵 Tokens received successfully"); + + console.log("🎵 Getting Spotify profile..."); + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.data; + console.log("🎵 Profile received for:", spotifyProfile.id); + + // Check if user already exists with this Spotify ID + let user = await User.findOne({ where: { spotifyId: spotifyProfile.id } }); + + if (!user) { + console.log("🎵 Creating new user..."); + + // Generate safe username + const username = await generateUsername(spotifyProfile); + console.log("🎵 Generated username:", username); + + // Create new user with Spotify data + user = await User.create({ + username: username, + email: spotifyProfile.email || null, // Email might be null + spotifyId: spotifyProfile.id, + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + passwordHash: null, // No password for Spotify-only users + }); + + console.log("🎵 User created with ID:", user.id); + } else { + console.log("🎵 Updating existing user..."); + // Update existing user's Spotify tokens + await user.update({ + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + }); + } + + // Create JWT token + const token = jwt.sign( + { + id: user.id, + username: user.username, + auth0Id: user.auth0Id, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.cookie("token", token, cookieSettings); + + console.log("🎵 Spotify login successful for user:", user.username); + + res.json({ + message: "Spotify login successful", + token: token, + user: { + id: user.id, + username: user.username, + email: user.email, + }, + }); + } catch (error) { + console.error("❌ Spotify login error:", error); + console.error("❌ Error details:", { + message: error.message, + response: error.response?.data, + status: error.response?.status, + stack: error.stack + }); + res.status(500).json({ error: "Failed to login with Spotify" }); + } +}); + router.get("/auth-url", authenticateJWT, (req, res) => { const scopes = [ "user-read-private", @@ -52,17 +257,18 @@ router.get("/auth-url", authenticateJWT, (req, res) => { res.json({ authUrl }); }); -// Handle Spotify callback router.post("/callback", authenticateJWT, async (req, res) => { try { const { code, state } = req.body; - // Verify state matches user ID + if (!code) { + return res.status(400).json({ error: "No authorization code received" }); + } + if (state !== req.user.id.toString()) { return res.status(400).json({ error: "Invalid state parameter" }); } - // Exchange code for access token const tokenResponse = await axios.post( "https://accounts.spotify.com/api/token", new URLSearchParams({ @@ -81,7 +287,6 @@ router.post("/callback", authenticateJWT, async (req, res) => { const { access_token, refresh_token, expires_in } = tokenResponse.data; - // Get user's Spotify profile const profileResponse = await axios.get("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${access_token}`, @@ -90,8 +295,12 @@ router.post("/callback", authenticateJWT, async (req, res) => { const spotifyProfile = profileResponse.data; - // Update user with Spotify data const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + await user.update({ spotifyId: spotifyProfile.id, spotifyAccessToken: access_token, @@ -110,12 +319,10 @@ router.post("/callback", authenticateJWT, async (req, res) => { }, }); } catch (error) { - console.error("Spotify callback error:", error); res.status(500).json({ error: "Failed to connect Spotify account" }); } }); -// Get user's Spotify data router.get("/profile", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -124,15 +331,11 @@ router.get("/profile", authenticateJWT, async (req, res) => { return res.json({ connected: false }); } - // Check if token needs refresh - if (!user.isSpotifyTokenValid()) { - return res.status(401).json({ error: "Spotify token expired" }); - } + const accessToken = await getValidSpotifyToken(user); - // Get current Spotify profile const profileResponse = await axios.get("https://api.spotify.com/v1/me", { headers: { - Authorization: `Bearer ${user.spotifyAccessToken}`, + Authorization: `Bearer ${accessToken}`, }, }); @@ -141,38 +344,40 @@ router.get("/profile", authenticateJWT, async (req, res) => { profile: profileResponse.data, }); } catch (error) { - console.error("Spotify profile error:", error); + if (error.message === "Spotify not connected") { + return res.json({ connected: false }); + } res.status(500).json({ error: "Failed to get Spotify profile" }); } }); -// Get user's top tracks router.get("/top-tracks", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - if (!user.spotifyAccessToken || !user.isSpotifyTokenValid()) { - return res.status(401).json({ error: "Spotify not connected or token expired" }); - } + const accessToken = await getValidSpotifyToken(user); + + const timeRange = req.query.time_range || "short_term"; const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { headers: { - Authorization: `Bearer ${user.spotifyAccessToken}`, + Authorization: `Bearer ${accessToken}`, }, params: { limit: 20, - time_range: "medium_term", + time_range: timeRange, }, }); res.json(response.data); } catch (error) { - console.error("Spotify top tracks error:", error); + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } res.status(500).json({ error: "Failed to get top tracks" }); } }); -// Disconnect Spotify router.delete("/disconnect", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -187,7 +392,6 @@ router.delete("/disconnect", authenticateJWT, async (req, res) => { res.json({ message: "Spotify disconnected successfully" }); } catch (error) { - console.error("Spotify disconnect error:", error); res.status(500).json({ error: "Failed to disconnect Spotify" }); } }); diff --git a/database/user.js b/database/user.js index 59b83a7..e2147c3 100644 --- a/database/user.js +++ b/database/user.js @@ -28,7 +28,6 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, }, - // Add Spotify fields spotifyId: { type: DataTypes.STRING, allowNull: true, @@ -56,20 +55,17 @@ const User = db.define("user", { }, }); -// Instance method to check password User.prototype.checkPassword = function (password) { if (!this.passwordHash) { - return false; // Auth0 users don't have passwords + return false; } return bcrypt.compareSync(password, this.passwordHash); }; -// Class method to hash password User.hashPassword = function (password) { return bcrypt.hashSync(password, 10); }; -// Method to check if Spotify token is valid User.prototype.isSpotifyTokenValid = function () { return this.spotifyAccessToken && this.spotifyTokenExpiresAt && From bb84c2d9cf0b080ae4880ed3c4feb475d3d5902b Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Fri, 1 Aug 2025 13:21:31 -0400 Subject: [PATCH 15/16] new backend --- database/follows.js | 38 ++++++++++++++++------- database/index.js | 32 +++++++++++++++++-- database/posts.js | 76 ++++++++++++++++++++++----------------------- database/user.js | 11 +++++++ 4 files changed, 105 insertions(+), 52 deletions(-) diff --git a/database/follows.js b/database/follows.js index 9fa3eef..9aaff37 100644 --- a/database/follows.js +++ b/database/follows.js @@ -5,20 +5,36 @@ const Follows = db.define("follows", { id: { type: DataTypes.INTEGER, primaryKey: true, + autoIncrement: true, }, - - following_user_id: { - type: DataTypes.INTEGER, - }, - follows_user_id: { + followerId: { type: DataTypes.INTEGER, + allowNull: false, + field: 'follower_id', // Map to snake_case database column + references: { + model: 'users', + key: 'id' + } }, - follows_user_id: { + followingId: { type: DataTypes.INTEGER, - }, - created_at: { - type: DataTypes.INTEGER, - }, + allowNull: false, + field: 'following_id', // Map to snake_case database column + references: { + model: 'users', + key: 'id' + } + } +}, { + tableName: 'follows', + underscored: true, // This ensures snake_case column names + // Ensure a user can't follow the same person twice + indexes: [ + { + unique: true, + fields: ['follower_id', 'following_id'] // Use snake_case here + } + ] }); -module.exports = Follows; +module.exports = Follows; \ No newline at end of file diff --git a/database/index.js b/database/index.js index f7c52f2..3142d8e 100644 --- a/database/index.js +++ b/database/index.js @@ -1,11 +1,37 @@ const db = require("./db"); const User = require("./user"); -const Posts = require("./posts"); +const Post = require("./posts"); const Follows = require("./follows"); +// Set up associations +User.hasMany(Post, { + foreignKey: 'user_id', // Use snake_case for foreign key + as: 'posts' +}); + +Post.belongsTo(User, { + foreignKey: 'user_id', // Use snake_case for foreign key + as: 'author' +}); + +// User following relationships +User.belongsToMany(User, { + through: Follows, + as: 'following', + foreignKey: 'follower_id', // Use snake_case + otherKey: 'following_id' // Use snake_case +}); + +User.belongsToMany(User, { + through: Follows, + as: 'followers', + foreignKey: 'following_id', // Use snake_case + otherKey: 'follower_id' // Use snake_case +}); + module.exports = { db, User, - Posts, + Post, Follows, -}; +}; \ No newline at end of file diff --git a/database/posts.js b/database/posts.js index 25945be..40fa368 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,60 +1,60 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const bcrypt = require("bcrypt"); const Post = db.define("post", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, title: { type: DataTypes.STRING, allowNull: false, - unique: true, validate: { - len: [3, 20], + len: [1, 200], }, }, - email: { + content: { + type: DataTypes.TEXT, + allowNull: true, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id', // Map to snake_case + references: { + model: 'users', + key: 'id' + } + }, + spotifyTrackId: { type: DataTypes.STRING, allowNull: true, - unique: true, - validate: { - isEmail: true, - }, + field: 'spotify_track_id' }, - auth0Id: { + spotifyTrackName: { type: DataTypes.STRING, allowNull: true, - unique: true, + field: 'spotify_track_name' }, - passwordHash: { + spotifyArtistName: { type: DataTypes.STRING, allowNull: true, + field: 'spotify_artist_name' }, - role: { - type: DataTypes.ENUM("User", "Admin"), - defaultValue: "User", - allowNull: false, + likesCount: { + type: DataTypes.INTEGER, + defaultValue: 0, + field: 'likes_count' }, -}); - -// Instance method to check password -User.prototype.checkPassword = function (password) { - if (!this.passwordHash) { - return false; // Auth0 users don't have passwords + isPublic: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: 'is_public' } - return bcrypt.compareSync(password, this.passwordHash); -}; - -// Class method to hash password -User.hashPassword = function (password) { - return bcrypt.hashSync(password, 10); -}; - -// Method to check if Spotify token is valid -User.prototype.isSpotifyTokenValid = function () { - return ( - this.spotifyAccessToken && - this.spotifyTokenExpiresAt && - new Date() < this.spotifyTokenExpiresAt - ); -}; +}, { + tableName: 'posts', + underscored: true // Ensures snake_case column names +}); -module.exports = User; +module.exports = Post; \ No newline at end of file diff --git a/database/user.js b/database/user.js index e2147c3..5107299 100644 --- a/database/user.js +++ b/database/user.js @@ -23,36 +23,47 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, unique: true, + field: 'auth0_id' }, passwordHash: { type: DataTypes.STRING, allowNull: true, + field: 'password_hash' }, spotifyId: { type: DataTypes.STRING, allowNull: true, unique: true, + field: 'spotify_id' }, spotifyAccessToken: { type: DataTypes.TEXT, allowNull: true, + field: 'spotify_access_token' }, spotifyRefreshToken: { type: DataTypes.TEXT, allowNull: true, + field: 'spotify_refresh_token' }, spotifyTokenExpiresAt: { type: DataTypes.DATE, allowNull: true, + field: 'spotify_token_expires_at' }, spotifyDisplayName: { type: DataTypes.STRING, allowNull: true, + field: 'spotify_display_name' }, spotifyProfileImage: { type: DataTypes.STRING, allowNull: true, + field: 'spotify_profile_image' }, +}, { + tableName: 'users', + underscored: true // Ensures snake_case column names }); User.prototype.checkPassword = function (password) { From 2c165df93665ada7cb99a10f076a91a6d091ef96 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Fri, 1 Aug 2025 14:26:16 -0400 Subject: [PATCH 16/16] WOrk? --- auth/spotify.js | 285 +++++++++++++++++++++++++---------------------- database/user.js | 19 +++- 2 files changed, 168 insertions(+), 136 deletions(-) diff --git a/auth/spotify.js b/auth/spotify.js index 3deb366..dc6ce75 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -8,7 +8,7 @@ const router = express.Router(); const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; -const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3000"; const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; const cookieSettings = { @@ -36,22 +36,22 @@ const refreshSpotifyToken = async (user) => { } ); - const { access_token, expires_in, refresh_token } = response.data; - + const { access_token, expires_in } = response.data; + await user.update({ spotifyAccessToken: access_token, spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyRefreshToken: refresh_token || user.spotifyRefreshToken, }); return access_token; } catch (error) { - throw error; + console.error("Failed to refresh Spotify token:", error.message); + throw new Error("Token refresh failed"); } }; const getValidSpotifyToken = async (user) => { - if (!user.spotifyAccessToken || !user.spotifyRefreshToken) { + if (!user.spotifyAccessToken) { throw new Error("Spotify not connected"); } @@ -59,35 +59,40 @@ const getValidSpotifyToken = async (user) => { return user.spotifyAccessToken; } + if (!user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); + } + return await refreshSpotifyToken(user); }; -// Generate safe username from Spotify data const generateUsername = async (spotifyProfile) => { let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; - // Clean username - remove invalid characters and limit length baseUsername = baseUsername .replace(/[^a-zA-Z0-9_]/g, '_') .substring(0, 15) .toLowerCase(); - // Ensure minimum length if (baseUsername.length < 3) { - baseUsername = `spotify_${spotifyProfile.id}`.substring(0, 20); + baseUsername = `spotify_${spotifyProfile.id.substring(0, 10)}`; + } + + if (baseUsername.length > 20) { + baseUsername = baseUsername.substring(0, 20); } - // Check for uniqueness and modify if needed let finalUsername = baseUsername; let counter = 1; while (await User.findOne({ where: { username: finalUsername } })) { - finalUsername = `${baseUsername}_${counter}`; + const suffix = `_${counter}`; + const maxLength = 20 - suffix.length; + finalUsername = baseUsername.substring(0, maxLength) + suffix; counter++; - // Prevent infinite loop if (counter > 1000) { - finalUsername = `spotify_${Date.now()}`; + finalUsername = `spotify_${Date.now()}`.substring(0, 20); break; } } @@ -95,44 +100,81 @@ const generateUsername = async (spotifyProfile) => { return finalUsername; }; +// Login URL endpoint router.get("/login-url", (req, res) => { - const scopes = [ - "user-read-private", - "user-read-email", - "user-top-read", - "user-read-recently-played", - "playlist-read-private", - "playlist-read-collaborative" - ].join(" "); - - const params = new URLSearchParams({ - response_type: "code", - client_id: SPOTIFY_CLIENT_ID, - scope: scopes, - redirect_uri: `${FRONTEND_URL}/callback/spotify`, - state: "spotify_login", - }); - - const authUrl = `https://accounts.spotify.com/authorize?${params}`; - res.json({ authUrl }); + try { + const scopes = [ + 'user-read-private', + 'user-read-email', + 'user-top-read', + 'user-read-recently-played', + 'playlist-read-private', + 'playlist-read-collaborative' + ].join(' '); + + const authUrl = 'https://accounts.spotify.com/authorize?' + + new URLSearchParams({ + response_type: 'code', + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: 'spotify_login', + show_dialog: true + }); + + res.json({ authUrl }); + } catch (error) { + console.error("Error generating Spotify login URL:", error.message); + res.status(500).json({ error: "Failed to generate Spotify login URL" }); + } }); +// Auth URL for connected users +router.get("/auth-url", authenticateJWT, (req, res) => { + try { + const scopes = [ + 'user-read-private', + 'user-read-email', + 'user-top-read', + 'user-read-recently-played', + 'playlist-read-private', + 'playlist-read-collaborative' + ].join(' '); + + const authUrl = 'https://accounts.spotify.com/authorize?' + + new URLSearchParams({ + response_type: 'code', + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: req.user.id.toString(), + show_dialog: true + }); + + res.json({ authUrl }); + } catch (error) { + console.error("Error generating Spotify auth URL:", error.message); + res.status(500).json({ error: "Failed to generate auth URL" }); + } +}); + +// Spotify login endpoint router.post("/login", async (req, res) => { try { - console.log("🎵 Spotify login attempt"); const { code, state } = req.body; if (state !== "spotify_login") { - console.log("❌ Invalid state parameter:", state); return res.status(400).json({ error: "Invalid state parameter" }); } if (!code) { - console.log("❌ No authorization code"); return res.status(400).json({ error: "No authorization code provided" }); } - console.log("🎵 Exchanging code for tokens..."); + if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) { + return res.status(500).json({ error: "Server configuration error" }); + } + const tokenResponse = await axios.post( "https://accounts.spotify.com/api/token", new URLSearchParams({ @@ -150,9 +192,7 @@ router.post("/login", async (req, res) => { ); const { access_token, refresh_token, expires_in } = tokenResponse.data; - console.log("🎵 Tokens received successfully"); - console.log("🎵 Getting Spotify profile..."); const profileResponse = await axios.get("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${access_token}`, @@ -160,45 +200,58 @@ router.post("/login", async (req, res) => { }); const spotifyProfile = profileResponse.data; - console.log("🎵 Profile received for:", spotifyProfile.id); - // Check if user already exists with this Spotify ID let user = await User.findOne({ where: { spotifyId: spotifyProfile.id } }); if (!user) { - console.log("🎵 Creating new user..."); - - // Generate safe username - const username = await generateUsername(spotifyProfile); - console.log("🎵 Generated username:", username); - - // Create new user with Spotify data - user = await User.create({ - username: username, - email: spotifyProfile.email || null, // Email might be null - spotifyId: spotifyProfile.id, - spotifyAccessToken: access_token, - spotifyRefreshToken: refresh_token, - spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyDisplayName: spotifyProfile.display_name, - spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, - passwordHash: null, // No password for Spotify-only users - }); - - console.log("🎵 User created with ID:", user.id); + try { + const username = await generateUsername(spotifyProfile); + + if (username.length < 3 || username.length > 20) { + throw new Error(`Invalid username length: ${username.length}`); + } + + const userData = { + username: username, + email: spotifyProfile.email || null, + spotifyId: spotifyProfile.id, + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name || null, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + passwordHash: null, + }; + + user = await User.create(userData); + } catch (validationError) { + if (validationError.name === 'SequelizeValidationError') { + const errorMessages = validationError.errors.map(err => err.message); + return res.status(400).json({ + error: "User validation failed", + details: errorMessages.join(', ') + }); + } + + if (validationError.name === 'SequelizeUniqueConstraintError') { + return res.status(400).json({ + error: "User already exists with this data", + details: validationError.message + }); + } + + throw validationError; + } } else { - console.log("🎵 Updating existing user..."); - // Update existing user's Spotify tokens await user.update({ spotifyAccessToken: access_token, spotifyRefreshToken: refresh_token, spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyDisplayName: spotifyProfile.display_name, - spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + spotifyDisplayName: spotifyProfile.display_name || user.spotifyDisplayName, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || user.spotifyProfileImage, }); } - // Create JWT token const token = jwt.sign( { id: user.id, @@ -211,8 +264,6 @@ router.post("/login", async (req, res) => { ); res.cookie("token", token, cookieSettings); - - console.log("🎵 Spotify login successful for user:", user.username); res.json({ message: "Spotify login successful", @@ -224,49 +275,34 @@ router.post("/login", async (req, res) => { }, }); } catch (error) { - console.error("❌ Spotify login error:", error); - console.error("❌ Error details:", { - message: error.message, - response: error.response?.data, - status: error.response?.status, - stack: error.stack + console.error("Spotify login error:", error.message); + + if (error.response?.status === 400) { + return res.status(400).json({ + error: "Invalid authorization code or expired", + details: error.response?.data + }); + } + + res.status(500).json({ + error: "Failed to login with Spotify", + details: process.env.NODE_ENV === 'development' ? error.message : undefined }); - res.status(500).json({ error: "Failed to login with Spotify" }); } }); -router.get("/auth-url", authenticateJWT, (req, res) => { - const scopes = [ - "user-read-private", - "user-read-email", - "user-top-read", - "user-read-recently-played", - "playlist-read-private", - "playlist-read-collaborative" - ].join(" "); - - const params = new URLSearchParams({ - response_type: "code", - client_id: SPOTIFY_CLIENT_ID, - scope: scopes, - redirect_uri: `${FRONTEND_URL}/callback/spotify`, - state: req.user.id.toString(), - }); - - const authUrl = `https://accounts.spotify.com/authorize?${params}`; - res.json({ authUrl }); -}); - +// Spotify callback for connected users router.post("/callback", authenticateJWT, async (req, res) => { try { const { code, state } = req.body; + const userId = parseInt(state); - if (!code) { - return res.status(400).json({ error: "No authorization code received" }); + if (userId !== req.user.id) { + return res.status(400).json({ error: "Invalid state parameter" }); } - if (state !== req.user.id.toString()) { - return res.status(400).json({ error: "Invalid state parameter" }); + if (!code) { + return res.status(400).json({ error: "No authorization code provided" }); } const tokenResponse = await axios.post( @@ -296,11 +332,6 @@ router.post("/callback", authenticateJWT, async (req, res) => { const spotifyProfile = profileResponse.data; const user = await User.findByPk(req.user.id); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - await user.update({ spotifyId: spotifyProfile.id, spotifyAccessToken: access_token, @@ -310,53 +341,41 @@ router.post("/callback", authenticateJWT, async (req, res) => { spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, }); - res.json({ - message: "Spotify connected successfully", - spotify: { - id: spotifyProfile.id, - displayName: spotifyProfile.display_name, - profileImage: spotifyProfile.images?.[0]?.url, - }, - }); + res.json({ message: "Spotify connected successfully" }); } catch (error) { - res.status(500).json({ error: "Failed to connect Spotify account" }); + console.error("Spotify callback error:", error.message); + res.status(500).json({ error: "Failed to connect Spotify" }); } }); +// Get Spotify profile router.get("/profile", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - + if (!user.spotifyId) { return res.json({ connected: false }); } - const accessToken = await getValidSpotifyToken(user); - - const profileResponse = await axios.get("https://api.spotify.com/v1/me", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - res.json({ connected: true, - profile: profileResponse.data, + profile: { + id: user.spotifyId, + display_name: user.spotifyDisplayName, + images: user.spotifyProfileImage ? [{ url: user.spotifyProfileImage }] : [], + }, }); } catch (error) { - if (error.message === "Spotify not connected") { - return res.json({ connected: false }); - } - res.status(500).json({ error: "Failed to get Spotify profile" }); + console.error("Error getting Spotify profile:", error.message); + res.status(500).json({ error: "Failed to get profile" }); } }); +// Get top tracks router.get("/top-tracks", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); - const timeRange = req.query.time_range || "short_term"; const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { @@ -374,10 +393,12 @@ router.get("/top-tracks", authenticateJWT, async (req, res) => { if (error.message === "Spotify not connected") { return res.status(401).json({ error: "Spotify not connected" }); } + console.error("Error getting top tracks:", error.message); res.status(500).json({ error: "Failed to get top tracks" }); } }); +// Disconnect Spotify router.delete("/disconnect", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -389,9 +410,9 @@ router.delete("/disconnect", authenticateJWT, async (req, res) => { spotifyDisplayName: null, spotifyProfileImage: null, }); - res.json({ message: "Spotify disconnected successfully" }); } catch (error) { + console.error("Error disconnecting Spotify:", error.message); res.status(500).json({ error: "Failed to disconnect Spotify" }); } }); diff --git a/database/user.js b/database/user.js index 5107299..34e9c17 100644 --- a/database/user.js +++ b/database/user.js @@ -9,14 +9,17 @@ const User = db.define("user", { unique: true, validate: { len: [3, 20], + notEmpty: true, }, }, email: { type: DataTypes.STRING, - allowNull: true, + allowNull: true, // Allow null for Spotify-only users unique: true, validate: { - isEmail: true, + isEmail: { + msg: "Must be a valid email address" + }, }, }, auth0Id: { @@ -27,7 +30,7 @@ const User = db.define("user", { }, passwordHash: { type: DataTypes.STRING, - allowNull: true, + allowNull: true, // Allow null for Spotify-only users field: 'password_hash' }, spotifyId: { @@ -63,7 +66,15 @@ const User = db.define("user", { }, }, { tableName: 'users', - underscored: true // Ensures snake_case column names + underscored: true, + validate: { + // Custom validator: user must have either password or spotify connection + mustHaveAuthMethod() { + if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { + throw new Error('User must have at least one authentication method'); + } + } + } }); User.prototype.checkPassword = function (password) {