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/app.js b/app.js index 5857036..9ad88fe 100644 --- a/app.js +++ b/app.js @@ -2,36 +2,37 @@ 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()); -app.use( - cors({ - origin: FRONTEND_URL, - credentials: true, - }) -); +const corsOptions = { + origin: [ + "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"], + allowedHeaders: ["Content-Type", "Authorization", "Cookie"], +}; -// cookie parser middleware +app.use(cors(corsOptions)); 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); @@ -51,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 07968c5..eed714c 100644 --- a/auth/index.js +++ b/auth/index.js @@ -3,12 +3,25 @@ const jwt = require("jsonwebtoken"); const { User } = require("../database"); 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" }); @@ -23,7 +36,6 @@ const authenticateJWT = (req, res, next) => { }); }; -// Auth0 authentication route router.post("/auth0", async (req, res) => { try { const { auth0Id, email, username } = req.body; @@ -32,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 } })) { @@ -67,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, @@ -79,15 +84,11 @@ 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", + token: token, user: { id: user.id, username: user.username, @@ -96,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, @@ -140,46 +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 } }); - user.checkPassword(password); 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, @@ -191,35 +172,35 @@ 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", + token: token, 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) => { @@ -230,4 +211,4 @@ router.get("/me", (req, res) => { }); }); -module.exports = { router, authenticateJWT }; +module.exports = { router, authenticateJWT }; \ No newline at end of file diff --git a/auth/spotify.js b/auth/spotify.js new file mode 100644 index 0000000..dc6ce75 --- /dev/null +++ b/auth/spotify.js @@ -0,0 +1,420 @@ +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://127.0.0.1:3000"; +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +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 } = response.data; + + await user.update({ + spotifyAccessToken: access_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + }); + + return access_token; + } catch (error) { + console.error("Failed to refresh Spotify token:", error.message); + throw new Error("Token refresh failed"); + } +}; + +const getValidSpotifyToken = async (user) => { + if (!user.spotifyAccessToken) { + throw new Error("Spotify not connected"); + } + + if (user.isSpotifyTokenValid()) { + return user.spotifyAccessToken; + } + + if (!user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); + } + + return await refreshSpotifyToken(user); +}; + +const generateUsername = async (spotifyProfile) => { + let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; + + baseUsername = baseUsername + .replace(/[^a-zA-Z0-9_]/g, '_') + .substring(0, 15) + .toLowerCase(); + + if (baseUsername.length < 3) { + baseUsername = `spotify_${spotifyProfile.id.substring(0, 10)}`; + } + + if (baseUsername.length > 20) { + baseUsername = baseUsername.substring(0, 20); + } + + let finalUsername = baseUsername; + let counter = 1; + + while (await User.findOne({ where: { username: finalUsername } })) { + const suffix = `_${counter}`; + const maxLength = 20 - suffix.length; + finalUsername = baseUsername.substring(0, maxLength) + suffix; + counter++; + + if (counter > 1000) { + finalUsername = `spotify_${Date.now()}`.substring(0, 20); + break; + } + } + + return finalUsername; +}; + +// Login URL endpoint +router.get("/login-url", (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: '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 { + const { code, state } = req.body; + + if (state !== "spotify_login") { + return res.status(400).json({ error: "Invalid state parameter" }); + } + + if (!code) { + return res.status(400).json({ error: "No authorization code provided" }); + } + + 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({ + 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; + + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.data; + + let user = await User.findOne({ where: { spotifyId: spotifyProfile.id } }); + + if (!user) { + 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 { + await user.update({ + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name || user.spotifyDisplayName, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || user.spotifyProfileImage, + }); + } + + 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); + + 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.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 + }); + } +}); + +// Spotify callback for connected users +router.post("/callback", authenticateJWT, async (req, res) => { + try { + const { code, state } = req.body; + const userId = parseInt(state); + + if (userId !== req.user.id) { + 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( + "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; + + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.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" }); + } catch (error) { + 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 }); + } + + res.json({ + connected: true, + profile: { + id: user.spotifyId, + display_name: user.spotifyDisplayName, + images: user.spotifyProfileImage ? [{ url: user.spotifyProfileImage }] : [], + }, + }); + } catch (error) { + 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", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 20, + time_range: timeRange, + }, + }); + + res.json(response.data); + } catch (error) { + 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); + 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("Error disconnecting Spotify:", error.message); + res.status(500).json({ error: "Failed to disconnect Spotify" }); + } +}); + +module.exports = router; \ No newline at end of file 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/follows.js b/database/follows.js new file mode 100644 index 0000000..9aaff37 --- /dev/null +++ b/database/follows.js @@ -0,0 +1,40 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Follows = db.define("follows", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + followerId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'follower_id', // Map to snake_case database column + references: { + model: 'users', + key: 'id' + } + }, + followingId: { + 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; \ No newline at end of file diff --git a/database/index.js b/database/index.js index e498df6..3142d8e 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,37 @@ const db = require("./db"); const User = require("./user"); +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, -}; + Post, + Follows, +}; \ No newline at end of file diff --git a/database/posts.js b/database/posts.js new file mode 100644 index 0000000..40fa368 --- /dev/null +++ b/database/posts.js @@ -0,0 +1,60 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Post = db.define("post", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + validate: { + len: [1, 200], + }, + }, + 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, + field: 'spotify_track_id' + }, + spotifyTrackName: { + type: DataTypes.STRING, + allowNull: true, + field: 'spotify_track_name' + }, + spotifyArtistName: { + type: DataTypes.STRING, + allowNull: true, + field: 'spotify_artist_name' + }, + likesCount: { + type: DataTypes.INTEGER, + defaultValue: 0, + field: 'likes_count' + }, + isPublic: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: 'is_public' + } +}, { + tableName: 'posts', + underscored: true // Ensures snake_case column names +}); + +module.exports = Post; \ No newline at end of file diff --git a/database/user.js b/database/user.js index 755c757..34e9c17 100644 --- a/database/user.js +++ b/database/user.js @@ -9,38 +9,89 @@ 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: { type: DataTypes.STRING, allowNull: true, unique: true, + field: 'auth0_id' }, passwordHash: { type: DataTypes.STRING, + allowNull: true, // Allow null for Spotify-only users + 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, + 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'); + } + } + } }); -// 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); }; -module.exports = User; +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 af0cf82..d6c6754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { - "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", + "axios": "^1.11.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -26,6 +29,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 +100,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", @@ -83,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", @@ -248,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", @@ -343,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", @@ -438,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", @@ -525,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", @@ -651,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", @@ -1290,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", @@ -1336,6 +1528,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 +1612,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..ebb9f40 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "license": "ISC", "description": "", "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",