package checks, healthcheck, lot of cookie fiddling, some logging, bug fixing

This commit is contained in:
Kaushik Narayan R 2024-12-28 12:17:39 -07:00
parent d999db53ae
commit 79060661a6
11 changed files with 368 additions and 351 deletions

View File

@ -1,4 +1,6 @@
BASE_DOMAIN = localhost
REDIRECT_URI = http://localhost:9001/api/auth/callback REDIRECT_URI = http://localhost:9001/api/auth/callback
APP_URI = http://localhost:3000
DB_USER = your_database_username DB_USER = your_database_username
DB_PASSWD = your_database_password DB_PASSWD = your_database_password
DB_NAME = your_database_name DB_NAME = your_database_name

View File

@ -1 +1,3 @@
REDIRECT_URI = https://domain.for.this.app/api/auth/callback BASE_DOMAIN = domain.app
REDIRECT_URI = https://backend.app/api/auth/callback
APP_URI = https://frontend.app

View File

@ -35,6 +35,11 @@ const singleRequest = async (req, res, method, path, config = {}, data = null, i
// Non 2XX response received // Non 2XX response received
let logMsg; let logMsg;
if (error.response.status >= 400 && error.response.status < 600) { if (error.response.status >= 400 && error.response.status < 600) {
if (error.response.status === 401) {
logMsg="reauth, attempting...";
logger.info(logPrefix + logMsg);
// reauth here?
}
res.status(error.response.status).send(error.response.data); res.status(error.response.status).send(error.response.data);
logMsg = "" + error.response.status logMsg = "" + error.response.status
} }
@ -42,7 +47,7 @@ const singleRequest = async (req, res, method, path, config = {}, data = null, i
res.sendStatus(error.response.status); res.sendStatus(error.response.status);
logMsg = "???"; logMsg = "???";
} }
logger.error(logPrefix + logMsg, { logger.warn(logPrefix + logMsg, {
response: { response: {
data: error.response.data, data: error.response.data,
status: error.response.status, status: error.response.status,
@ -51,7 +56,7 @@ const singleRequest = async (req, res, method, path, config = {}, data = null, i
} else if (error.request) { } else if (error.request) {
// No response received // No response received
res.sendStatus(504); res.sendStatus(504);
logger.warn(logPrefix + "No response", { error }); logger.error(logPrefix + "No response", { error });
} else { } else {
// Something happened in setting up the request that triggered an Error // Something happened in setting up the request that triggered an Error
res.sendStatus(500); res.sendStatus(500);
@ -144,7 +149,7 @@ const checkPlaylistEditable = async (req, res, playlistID, userID) => {
message: "You cannot edit this playlist, you must be the owner/the playlist must be collaborative", message: "You cannot edit this playlist, you must be the owner/the playlist must be collaborative",
playlistID: playlistID playlistID: playlistID
}); });
logger.warn("user cannot edit target playlist", { playlistID: playlistID }); logger.info("user cannot edit target playlist", { playlistID: playlistID });
return false; return false;
} else { } else {
return true; return true;

View File

@ -49,10 +49,10 @@ const callback = async (req, res) => {
// check state // check state
if (state === null || state !== storedState) { if (state === null || state !== storedState) {
res.redirect(409, "/"); res.redirect(409, "/");
logger.error("state mismatch"); logger.warn("state mismatch");
return; return;
} else if (error) { } else if (error) {
res.status(401).send("Auth callback error"); res.status(401).send({ message: "Auth callback error" });
logger.error("callback error", { error }); logger.error("callback error", { error });
return; return;
} else { } else {
@ -73,10 +73,9 @@ const callback = async (req, res) => {
logger.debug("Tokens obtained."); logger.debug("Tokens obtained.");
req.session.accessToken = tokenResponse.data.access_token; req.session.accessToken = tokenResponse.data.access_token;
req.session.refreshToken = tokenResponse.data.refresh_token; req.session.refreshToken = tokenResponse.data.refresh_token;
req.session.cookie.maxAge = 7 * 24 * 60 * 60 * 1000 // 1 week
} else { } else {
logger.error("login failed", { statusCode: tokenResponse.status }); logger.error("login failed", { statusCode: tokenResponse.status });
res.status(tokenResponse.status).send("Error: Login failed"); res.status(tokenResponse.status).send({ message: "Error: Login failed" });
} }
const userData = await getUserProfile(req, res); const userData = await getUserProfile(req, res);
@ -88,8 +87,9 @@ const callback = async (req, res) => {
id: userData.id, id: userData.id,
}; };
res.sendStatus(200); // res.sendStatus(200);
logger.info("New login.", { username: userData.display_name }); res.redirect(process.env.APP_URI + "?login=success");
logger.debug("New login.", { username: userData.display_name });
return; return;
} }
} catch (error) { } catch (error) {
@ -120,10 +120,10 @@ const refresh = async (req, res) => {
req.session.refreshToken = response.data.refresh_token ?? req.session.refreshToken; // refresh token rotation req.session.refreshToken = response.data.refresh_token ?? req.session.refreshToken; // refresh token rotation
res.sendStatus(200); res.sendStatus(200);
logger.info(`Access token refreshed${(response.data.refresh_token !== null) ? " and refresh token updated" : ""}.`); logger.debug(`Access token refreshed${(response.data.refresh_token !== null) ? " and refresh token updated" : ""}.`);
return; return;
} else { } else {
res.status(response.status).send("Error: Refresh token flow failed."); res.status(response.status).send({ message: "Error: Refresh token flow failed." });
logger.error("refresh failed", { statusCode: response.status }); logger.error("refresh failed", { statusCode: response.status });
return; return;
} }
@ -141,15 +141,15 @@ const refresh = async (req, res) => {
*/ */
const logout = async (req, res) => { const logout = async (req, res) => {
try { try {
const delSession = req.session.destroy((err) => { const delSession = req.session.destroy((error) => {
if (err) { if (error) {
res.sendStatus(500); res.sendStatus(500);
logger.error("Error while logging out", { err }); logger.error("Error while logging out", { error });
return; return;
} else { } else {
res.clearCookie(sessionName); res.clearCookie(sessionName);
res.sendStatus(200); res.sendStatus(200);
logger.info("Logged out.", { sessionID: delSession.id }); logger.debug("Logged out.", { sessionID: delSession.id });
return; return;
} }
}) })

View File

@ -98,7 +98,7 @@ const updateUser = async (req, res) => {
}); });
if (cleanedUser !== toRemovePls.length) { if (cleanedUser !== toRemovePls.length) {
res.sendStatus(500); res.sendStatus(500);
logger.error("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") }); logger.warn("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") });
return; return;
} }
} }
@ -116,7 +116,7 @@ const updateUser = async (req, res) => {
} }
res.status(200).send({ removedLinks: removedLinks > 0 }); res.status(200).send({ removedLinks: removedLinks > 0 });
logger.info("Updated user data", { delLinks: removedLinks, delPls: cleanedUser, addPls: updatedUser.length }); logger.debug("Updated user data", { delLinks: removedLinks, delPls: cleanedUser, addPls: updatedUser.length });
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);
@ -154,7 +154,7 @@ const fetchUser = async (req, res) => {
playlists: currentPlaylists, playlists: currentPlaylists,
links: currentLinks links: currentLinks
}); });
logger.info("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length }); logger.debug("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length });
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);
@ -174,17 +174,16 @@ const createLink = async (req, res) => {
let fromPl, toPl; let fromPl, toPl;
try { try {
fromPl = parseSpotifyLink(req.body["from"]); fromPl = parseSpotifyLink(req.body.from);
toPl = parseSpotifyLink(req.body["to"]); toPl = parseSpotifyLink(req.body.to);
if (fromPl.type !== "playlist" || toPl.type !== "playlist") { if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" }); res.status(400).send({ message: "Link is not a playlist" });
logger.warn("non-playlist link provided", { from: fromPl, to: toPl }); logger.info("non-playlist link provided", { from: fromPl, to: toPl });
return; return;
} }
} catch (error) { } catch (error) {
res.status(400).send({ message: error.message }); res.status(400).send({ message: "Could not parse link" });
logger.error("parseSpotifyLink", { error }); logger.warn("parseSpotifyLink", { error });
return; return;
} }
@ -197,8 +196,8 @@ const createLink = async (req, res) => {
// if playlists are unknown // if playlists are unknown
if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) { if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) {
res.sendStatus(404); res.status(404).send({ message: "Playlists out of sync "});
logger.error("unknown playlists, resync"); logger.warn("unknown playlists, resync");
return; return;
} }
@ -214,7 +213,7 @@ const createLink = async (req, res) => {
}); });
if (existingLink) { if (existingLink) {
res.sendStatus(409); res.sendStatus(409);
logger.error("link already exists"); logger.info("link already exists");
return; return;
} }
@ -228,7 +227,7 @@ const createLink = async (req, res) => {
if (newGraph.detectCycle()) { if (newGraph.detectCycle()) {
res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" }); res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" });
logger.error("potential cycle detected"); logger.warn("potential cycle detected");
return; return;
} }
@ -244,7 +243,7 @@ const createLink = async (req, res) => {
} }
res.sendStatus(201); res.sendStatus(201);
logger.info("Created link"); logger.debug("Created link");
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);
@ -265,16 +264,16 @@ const removeLink = async (req, res) => {
let fromPl, toPl; let fromPl, toPl;
try { try {
fromPl = parseSpotifyLink(req.body["from"]); fromPl = parseSpotifyLink(req.body.from);
toPl = parseSpotifyLink(req.body["to"]); toPl = parseSpotifyLink(req.body.to);
if (fromPl.type !== "playlist" || toPl.type !== "playlist") { if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" }); res.status(400).send({ message: "Link is not a playlist" });
logger.warn("non-playlist link provided", { from: fromPl, to: toPl }); logger.info("non-playlist link provided", { from: fromPl, to: toPl });
return; return;
} }
} catch (error) { } catch (error) {
res.status(400).send({ message: error.message }); res.status(400).send({ message: "Could not parse link" });
logger.error("parseSpotifyLink", { error }); logger.warn("parseSpotifyLink", { error });
return; return;
} }
@ -290,7 +289,7 @@ const removeLink = async (req, res) => {
}); });
if (!existingLink) { if (!existingLink) {
res.sendStatus(409); res.sendStatus(409);
logger.error("link does not exist"); logger.warn("link does not exist");
return; return;
} }
@ -310,7 +309,7 @@ const removeLink = async (req, res) => {
} }
res.sendStatus(200); res.sendStatus(200);
logger.info("Deleted link"); logger.debug("Deleted link");
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);
@ -350,12 +349,12 @@ const populateSingleLink = async (req, res) => {
toPl = parseSpotifyLink(req.body.to); toPl = parseSpotifyLink(req.body.to);
if (fromPl.type !== "playlist" || toPl.type !== "playlist") { if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" }); res.status(400).send({ message: "Link is not a playlist" });
logger.warn("non-playlist link provided", link); logger.info("non-playlist link provided", link);
return; return;
} }
} catch (error) { } catch (error) {
res.status(400).send({ message: error.message }); res.status(400).send({ message: "Could not parse link" });
logger.error("parseSpotifyLink", { error }); logger.warn("parseSpotifyLink", { error });
return; return;
} }
@ -400,7 +399,7 @@ const populateSingleLink = async (req, res) => {
// keep getting batches of 50 till exhausted // keep getting batches of 50 till exhausted
for (let i = 1; "next" in fromPlaylist; i++) { while (fromPlaylist.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next); const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next);
if (res.headersSent) return; if (res.headersSent) return;
@ -436,7 +435,7 @@ const populateSingleLink = async (req, res) => {
}); });
// keep getting batches of 50 till exhausted // keep getting batches of 50 till exhausted
for (let i = 1; "next" in toPlaylist; i++) { while (toPlaylist.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next); const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next);
if (res.headersSent) return; if (res.headersSent) return;
@ -475,7 +474,7 @@ const populateSingleLink = async (req, res) => {
added: toAddNum, added: toAddNum,
local: localNum, local: localNum,
}); });
logger.info(`Backfilled ${toAddNum} tracks, could not add ${localNum} local files.`); logger.debug(`Backfilled ${toAddNum} tracks, could not add ${localNum} local files.`);
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);
@ -507,16 +506,16 @@ const pruneSingleLink = async (req, res) => {
let fromPl, toPl; let fromPl, toPl;
try { try {
fromPl = parseSpotifyLink(req.body["from"]); fromPl = parseSpotifyLink(req.body.from);
toPl = parseSpotifyLink(req.body["to"]); toPl = parseSpotifyLink(req.body.to);
if (fromPl.type !== "playlist" || toPl.type !== "playlist") { if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" }); res.status(400).send({ message: "Link is not a playlist" });
logger.warn("non-playlist link provided", { from: fromPl, to: toPl }); logger.info("non-playlist link provided", { from: fromPl, to: toPl });
return return;
} }
} catch (error) { } catch (error) {
res.status(400).send({ message: error.message }); res.status(400).send({ message: error.message });
logger.error("parseSpotifyLink", { error }); logger.warn("parseSpotifyLink", { error });
return; return;
} }
@ -532,7 +531,7 @@ const pruneSingleLink = async (req, res) => {
}); });
if (!existingLink) { if (!existingLink) {
res.sendStatus(409); res.sendStatus(409);
logger.error("link does not exist"); logger.warn("link does not exist");
return return
} }
@ -561,7 +560,7 @@ const pruneSingleLink = async (req, res) => {
}); });
// keep getting batches of 50 till exhausted // keep getting batches of 50 till exhausted
for (let i = 1; "next" in fromPlaylist; i++) { while (fromPlaylist.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next); const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next);
if (res.headersSent) return; if (res.headersSent) return;
@ -598,7 +597,7 @@ const pruneSingleLink = async (req, res) => {
}); });
// keep getting batches of 50 till exhausted // keep getting batches of 50 till exhausted
for (let i = 1; "next" in toPlaylist; i++) { while (toPlaylist.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next); const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next);
if (res.headersSent) return; if (res.headersSent) return;
@ -638,7 +637,7 @@ const pruneSingleLink = async (req, res) => {
} }
res.status(200).send({ message: `Removed ${toDelNum} tracks.` }); res.status(200).send({ message: `Removed ${toDelNum} tracks.` });
logger.info(`Pruned ${toDelNum} tracks`); logger.debug(`Pruned ${toDelNum} tracks`);
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);

View File

@ -51,7 +51,7 @@ const fetchUserPlaylists = async (req, res) => {
delete userPlaylists.next; delete userPlaylists.next;
res.status(200).send(userPlaylists); res.status(200).send(userPlaylists);
logger.info("Fetched user playlists", { num: userPlaylists.total }); logger.debug("Fetched user playlists", { num: userPlaylists.total });
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);
@ -83,7 +83,7 @@ const fetchPlaylistDetails = async (req, res) => {
} }
} catch (error) { } catch (error) {
res.status(400).send({ message: error.message }); res.status(400).send({ message: error.message });
logger.error("parseSpotifyLink", { error }); logger.warn("parseSpotifyLink", { error });
return; return;
} }
@ -144,7 +144,7 @@ const fetchPlaylistDetails = async (req, res) => {
delete playlist.next; delete playlist.next;
res.status(200).send(playlist); res.status(200).send(playlist);
logger.info("Fetched playlist tracks", { num: playlist.tracks.length }); logger.debug("Fetched playlist tracks", { num: playlist.tracks.length });
return; return;
} catch (error) { } catch (error) {
res.sendStatus(500); res.sendStatus(500);

View File

@ -13,6 +13,7 @@ const { sessionName } = require("./constants");
const db = require("./models"); const db = require("./models");
const { isAuthenticated } = require("./middleware/authCheck"); const { isAuthenticated } = require("./middleware/authCheck");
const { getUserProfile } = require("./api/spotify");
const logger = require("./utils/logger")(module); const logger = require("./utils/logger")(module);
@ -35,13 +36,21 @@ app.use(session({
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: "auto", // if true only transmit cookie over https domain: process.env.BASE_DOMAIN,
httpOnly: true, // if true prevent client side JS from reading the cookie httpOnly: true, // if true prevent client side JS from reading the cookie
maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
sameSite: process.env.NODE_ENV === "development" ? "lax" : "none", // cross-site for production
secure: process.env.NODE_ENV === "development" ? false : true, // if true only transmit cookie over https
} }
})); }));
app.use(cors()); app.use(cors({
app.use(helmet()); origin: process.env.APP_URI,
credentials: true
}));
app.use(helmet({
crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" }
}));
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(cookieParser()); app.use(cookieParser());
@ -51,6 +60,24 @@ app.use(express.urlencoded({ extended: true }));
// Static // Static
app.use(express.static(__dirname + "/static")); app.use(express.static(__dirname + "/static"));
// Healthcheck
app.use("/health", (req, res) => {
res.sendStatus(200);
return;
});
app.use("/auth-health", isAuthenticated, async (req, res) => {
try {
await getUserProfile(req, res);
if (res.headersSent) return;
res.sendStatus(200);
return;
} catch (error) {
res.sendStatus(500);
logger.error("authHealthCheck", { error });
return;
}
});
// Routes // Routes
app.use("/api/auth/", require("./routes/auth")); app.use("/api/auth/", require("./routes/auth"));
app.use("/api/playlists", isAuthenticated, require("./routes/playlists")); app.use("/api/playlists", isAuthenticated, require("./routes/playlists"));
@ -61,7 +88,7 @@ app.use((req, res) => {
res.status(404).send( res.status(404).send(
"Guess the <a href=\"https://github.com/20kaushik02/spotify-manager\">cat's</a> out of the bag!" "Guess the <a href=\"https://github.com/20kaushik02/spotify-manager\">cat's</a> out of the bag!"
); );
logger.info("Unrecognized URL", { url: req.url }); logger.info("404", { url: req.url });
return; return;
}); });
@ -73,7 +100,7 @@ const server = app.listen(port, () => {
const cleanupFunc = (signal) => { const cleanupFunc = (signal) => {
if (signal) if (signal)
logger.info(`${signal} signal received, shutting down now...`); logger.debug(`${signal} signal received, shutting down now...`);
Promise.allSettled([ Promise.allSettled([
db.sequelize.close(), db.sequelize.close(),

View File

@ -19,7 +19,7 @@ const isAuthenticated = (req, res, next) => {
const delSession = req.session.destroy((err) => { const delSession = req.session.destroy((err) => {
if (err) { if (err) {
res.sendStatus(500); res.sendStatus(500);
logger.error("Error while destroying session.", { err }); logger.error("session.destroy", { err });
return; return;
} else { } else {
res.clearCookie(sessionName); res.clearCookie(sessionName);

View File

@ -18,7 +18,7 @@ if (config.use_env_variable) {
(async () => { (async () => {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
logger.info("Sequelize auth success"); logger.debug("Sequelize auth success");
} catch (error) { } catch (error) {
logger.error("Sequelize auth error", { error }); logger.error("Sequelize auth error", { error });
throw error; throw error;

542
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,10 @@
"description": "Personal Spotify playlist manager", "description": "Personal Spotify playlist manager",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "dev": "cross-env NODE_ENV=development nodemon --delay 2 --exitcrash index.js",
"dev": "cross-env NODE_ENV=development nodemon --exitcrash index.js" "test_setup": "npm i && cross-env NODE_ENV=test npx sequelize-cli db:migrate",
"test": "cross-env NODE_ENV=test node index.js",
"prod": "NODE_ENV=production node index.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -18,7 +20,7 @@
}, },
"homepage": "https://github.com/20kaushik02/spotify-manager#readme", "homepage": "https://github.com/20kaushik02/spotify-manager#readme",
"dependencies": { "dependencies": {
"axios": "^1.7.8", "axios": "^1.7.9",
"connect-sqlite3": "^0.9.15", "connect-sqlite3": "^0.9.15",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
@ -26,7 +28,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
"helmet": "^7.2.0", "helmet": "^8.0.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"pg": "^8.13.1", "pg": "^8.13.1",
"serializr": "^3.0.3", "serializr": "^3.0.3",
@ -34,10 +36,10 @@
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.2.0", "@types/node": "^22.10.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"nodemon": "^3.1.4", "nodemon": "^3.1.9",
"sequelize-cli": "^6.6.2", "sequelize-cli": "^6.6.2",
"typescript": "^5.5.4" "typescript": "^5.7.2"
} }
} }