editorconfig

This commit is contained in:
Kaushik Narayan R 2025-01-08 06:55:30 -07:00
parent 481d6fd48d
commit f75988fa3a
28 changed files with 1364 additions and 1339 deletions

25
.editorconfig Normal file
View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
# top-most EditorConfig file
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
max_line_length = 80
[*.txt]
indent_style = tab
indent_size = 4
[*.{diff,md}]
trim_trailing_whitespace = false

View File

@ -2,5 +2,5 @@ require("dotenv-flow").config();
const path = require("path"); const path = require("path");
module.exports = { module.exports = {
"config": path.resolve("config", "sequelize.js") "config": path.resolve("config", "sequelize.js")
}; };

View File

@ -5,53 +5,53 @@ const { baseAPIURL, accountsAPIURL } = require("../constants");
const logger = require("../utils/logger")(module); const logger = require("../utils/logger")(module);
const authInstance = axios.create({ const authInstance = axios.create({
baseURL: accountsAPIURL, baseURL: accountsAPIURL,
timeout: 20000, timeout: 20000,
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + (Buffer.from(process.env.CLIENT_ID + ":" + process.env.CLIENT_SECRET).toString("base64")) "Authorization": "Basic " + (Buffer.from(process.env.CLIENT_ID + ":" + process.env.CLIENT_SECRET).toString("base64"))
}, },
}); });
const uncappedAxiosInstance = axios.create({ const uncappedAxiosInstance = axios.create({
baseURL: baseAPIURL, baseURL: baseAPIURL,
timeout: 20000, timeout: 20000,
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
}); });
const axiosInstance = rateLimit(uncappedAxiosInstance, { const axiosInstance = rateLimit(uncappedAxiosInstance, {
maxRequests: 10, maxRequests: 10,
perMilliseconds: 5000, perMilliseconds: 5000,
}); });
axiosInstance.interceptors.request.use(config => { axiosInstance.interceptors.request.use(config => {
logger.http("API call", { logger.http("API call", {
url: config.url, url: config.url,
method: config.method, method: config.method,
params: config.params ?? {}, params: config.params ?? {},
headers: Object.keys(config.headers), headers: Object.keys(config.headers),
}); });
return config; return config;
}); });
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
logger.warn("AxiosError", { logger.warn("AxiosError", {
error: { error: {
name: error.name, name: error.name,
code: error.code, code: error.code,
message: error.message, message: error.message,
}, },
req: error.config, req: error.config,
}); });
return Promise.reject(error); return Promise.reject(error);
} }
); );
module.exports = { module.exports = {
authInstance, authInstance,
axiosInstance axiosInstance
}; };

View File

@ -18,147 +18,147 @@ const logPrefix = "Spotify API: ";
* @param {boolean} inlineData true if data is to be placed inside config * @param {boolean} inlineData true if data is to be placed inside config
*/ */
const singleRequest = async (req, res, method, path, config = {}, data = null, inlineData = false) => { const singleRequest = async (req, res, method, path, config = {}, data = null, inlineData = false) => {
let resp; let resp;
config.headers = { ...config.headers, ...req.sessHeaders }; config.headers = { ...config.headers, ...req.sessHeaders };
try { try {
if (!data || (data && inlineData)) { if (!data || (data && inlineData)) {
if (data) if (data)
config.data = data ?? null; config.data = data ?? null;
resp = await axiosInstance[method.toLowerCase()](path, config); resp = await axiosInstance[method.toLowerCase()](path, config);
} else } else
resp = await axiosInstance[method.toLowerCase()](path, data, config); resp = await axiosInstance[method.toLowerCase()](path, data, config);
logger.debug(logPrefix + "Successful response received."); logger.debug(logPrefix + "Successful response received.");
return resp; return resp;
} catch (error) { } catch (error) {
if (error.response) { if (error.response) {
// 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) {
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
} }
else { else {
res.sendStatus(error.response.status); res.sendStatus(error.response.status);
logMsg = "???"; logMsg = "???";
} }
logger.warn(logPrefix + logMsg, { logger.warn(logPrefix + logMsg, {
response: { response: {
data: error.response.data, data: error.response.data,
status: error.response.status, status: error.response.status,
} }
}); });
} else if (error.request) { } else if (error.request) {
// No response received // No response received
res.status(504).send({ message: "No response from Spotify" }); res.status(504).send({ message: "No response from Spotify" });
logger.error(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.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error(logPrefix + "Request failed?", { error }); logger.error(logPrefix + "Request failed?", { error });
} }
return null; return null;
}; };
} }
const getUserProfile = async (req, res) => { const getUserProfile = async (req, res) => {
const response = await singleRequest(req, res, const response = await singleRequest(req, res,
"GET", "/me", "GET", "/me",
{ headers: { Authorization: `Bearer ${req.session.accessToken}` } } { headers: { Authorization: `Bearer ${req.session.accessToken}` } }
); );
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const getUserPlaylistsFirstPage = async (req, res) => { const getUserPlaylistsFirstPage = async (req, res) => {
const response = await singleRequest(req, res, const response = await singleRequest(req, res,
"GET", "GET",
`/users/${req.session.user.id}/playlists`, `/users/${req.session.user.id}/playlists`,
{ {
params: { params: {
offset: 0, offset: 0,
limit: 50, limit: 50,
}, },
}); });
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const getUserPlaylistsNextPage = async (req, res, nextURL) => { const getUserPlaylistsNextPage = async (req, res, nextURL) => {
const response = await singleRequest( const response = await singleRequest(
req, res, "GET", nextURL); req, res, "GET", nextURL);
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const getPlaylistDetailsFirstPage = async (req, res, initialFields, playlistID) => { const getPlaylistDetailsFirstPage = async (req, res, initialFields, playlistID) => {
const response = await singleRequest(req, res, const response = await singleRequest(req, res,
"GET", "GET",
`/playlists/${playlistID}/`, `/playlists/${playlistID}/`,
{ {
params: { params: {
fields: initialFields fields: initialFields
}, },
}); });
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const getPlaylistDetailsNextPage = async (req, res, nextURL) => { const getPlaylistDetailsNextPage = async (req, res, nextURL) => {
const response = await singleRequest( const response = await singleRequest(
req, res, "GET", nextURL); req, res, "GET", nextURL);
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const addItemsToPlaylist = async (req, res, nextBatch, playlistID) => { const addItemsToPlaylist = async (req, res, nextBatch, playlistID) => {
const response = await singleRequest(req, res, const response = await singleRequest(req, res,
"POST", "POST",
`/playlists/${playlistID}/tracks`, `/playlists/${playlistID}/tracks`,
{}, {},
{ uris: nextBatch }, false { uris: nextBatch }, false
) )
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const removeItemsFromPlaylist = async (req, res, nextBatch, playlistID, snapshotID) => { const removeItemsFromPlaylist = async (req, res, nextBatch, playlistID, snapshotID) => {
// API doesn't document this kind of deletion via the 'positions' field // API doesn't document this kind of deletion via the 'positions' field
// but see here: https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801 // but see here: https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
const response = await singleRequest(req, res, const response = await singleRequest(req, res,
"DELETE", "DELETE",
`/playlists/${playlistID}/tracks`, `/playlists/${playlistID}/tracks`,
{}, {},
// axios delete method doesn't have separate arg for body so hv to put it in config // axios delete method doesn't have separate arg for body so hv to put it in config
{ positions: nextBatch, snapshot_id: snapshotID }, true { positions: nextBatch, snapshot_id: snapshotID }, true
); );
return res.headersSent ? null : response.data; return res.headersSent ? null : response.data;
} }
const checkPlaylistEditable = async (req, res, playlistID, userID) => { const checkPlaylistEditable = async (req, res, playlistID, userID) => {
let checkFields = ["collaborative", "owner(id)"]; let checkFields = ["collaborative", "owner(id)"];
const checkFromData = await getPlaylistDetailsFirstPage(req, res, checkFields.join(), playlistID); const checkFromData = await getPlaylistDetailsFirstPage(req, res, checkFields.join(), playlistID);
if (res.headersSent) return false; if (res.headersSent) return false;
// https://web.archive.org/web/20241226081630/https://developer.spotify.com/documentation/web-api/concepts/playlists#:~:text=A%20playlist%20can%20also%20be%20made%20collaborative // https://web.archive.org/web/20241226081630/https://developer.spotify.com/documentation/web-api/concepts/playlists#:~:text=A%20playlist%20can%20also%20be%20made%20collaborative
// playlist is editable if it's collaborative (and thus private) or owned by the user // playlist is editable if it's collaborative (and thus private) or owned by the user
if (checkFromData.collaborative !== true && if (checkFromData.collaborative !== true &&
checkFromData.owner.id !== userID) { checkFromData.owner.id !== userID) {
res.status(403).send({ res.status(403).send({
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.info("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;
} }
} }
module.exports = { module.exports = {
singleRequest, singleRequest,
getUserProfile, getUserProfile,
getUserPlaylistsFirstPage, getUserPlaylistsFirstPage,
getUserPlaylistsNextPage, getUserPlaylistsNextPage,
getPlaylistDetailsFirstPage, getPlaylistDetailsFirstPage,
getPlaylistDetailsNextPage, getPlaylistDetailsNextPage,
addItemsToPlaylist, addItemsToPlaylist,
removeItemsFromPlaylist, removeItemsFromPlaylist,
checkPlaylistEditable, checkPlaylistEditable,
} }

View File

@ -7,15 +7,15 @@ const typedefs = require("../typedefs");
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const __controller_func = async (req, res) => { const __controller_func = async (req, res) => {
try { try {
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("__controller_func", { error }); logger.error("__controller_func", { error });
return; return;
} }
} }
module.exports = { module.exports = {
__controller_func __controller_func
}; };

View File

@ -8,14 +8,14 @@ const typedefs = require("../typedefs");
* @param {typedefs.Next} next * @param {typedefs.Next} next
*/ */
const __validator_func = async (req, res, next) => { const __validator_func = async (req, res, next) => {
await body("field_name") await body("field_name")
.notEmpty() .notEmpty()
.withMessage("field_name not defined in body") .withMessage("field_name not defined in body")
.run(req); .run(req);
next(); next();
} }
module.exports = { module.exports = {
__validator_func __validator_func
} }

View File

@ -1,28 +1,28 @@
const logger = require("../utils/logger")(module); const logger = require("../utils/logger")(module);
const connConfigs = { const connConfigs = {
development: { development: {
username: process.env.DB_USER || "postgres", username: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWD || "", password: process.env.DB_PASSWD || "",
database: process.env.DB_NAME || "postgres", database: process.env.DB_NAME || "postgres",
host: process.env.DB_HOST || "127.0.0.1", host: process.env.DB_HOST || "127.0.0.1",
port: process.env.DB_PORT || 5432, port: process.env.DB_PORT || 5432,
}, },
staging: { staging: {
use_env_variable: "DB_URL", // use connection string for non-dev env use_env_variable: "DB_URL", // use connection string for non-dev env
}, },
production: { production: {
use_env_variable: "DB_URL", // use connection string for non-dev env use_env_variable: "DB_URL", // use connection string for non-dev env
// dialectOptions: { // dialectOptions: {
// ssl: true, // ssl: true,
// }, // },
} }
} }
// common config // common config
for (const conf in connConfigs) { for (const conf in connConfigs) {
connConfigs[conf]["logging"] = (msg) => logger.debug(msg); connConfigs[conf]["logging"] = (msg) => logger.debug(msg);
connConfigs[conf]["dialect"] = process.env.DB_DIALECT || "postgres"; connConfigs[conf]["dialect"] = process.env.DB_DIALECT || "postgres";
} }
module.exports = connConfigs; module.exports = connConfigs;

View File

@ -4,22 +4,22 @@ const sessionName = "spotify-manager";
const stateKey = "spotify_auth_state"; const stateKey = "spotify_auth_state";
const scopes = { const scopes = {
// ImageUpload: "ugc-image-upload", // ImageUpload: "ugc-image-upload",
AccessPrivatePlaylists: "playlist-read-private", AccessPrivatePlaylists: "playlist-read-private",
AccessCollaborativePlaylists: "playlist-read-collaborative", AccessCollaborativePlaylists: "playlist-read-collaborative",
ModifyPublicPlaylists: "playlist-modify-public", ModifyPublicPlaylists: "playlist-modify-public",
ModifyPrivatePlaylists: "playlist-modify-private", ModifyPrivatePlaylists: "playlist-modify-private",
// ModifyFollow: "user-follow-modify", // ModifyFollow: "user-follow-modify",
AccessFollow: "user-follow-read", AccessFollow: "user-follow-read",
ModifyLibrary: "user-library-modify", ModifyLibrary: "user-library-modify",
AccessLibrary: "user-library-read", AccessLibrary: "user-library-read",
AccessUser: "user-read-private", AccessUser: "user-read-private",
}; };
module.exports = { module.exports = {
accountsAPIURL, accountsAPIURL,
baseAPIURL, baseAPIURL,
sessionName, sessionName,
stateKey, stateKey,
scopes scopes
}; };

View File

@ -13,27 +13,27 @@ const logger = require("../utils/logger")(module);
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const login = (_req, res) => { const login = (_req, res) => {
try { try {
const state = generateRandString(16); const state = generateRandString(16);
res.cookie(stateKey, state); res.cookie(stateKey, state);
const scope = Object.values(scopes).join(" "); const scope = Object.values(scopes).join(" ");
res.redirect( res.redirect(
`${accountsAPIURL}/authorize?` + `${accountsAPIURL}/authorize?` +
new URLSearchParams({ new URLSearchParams({
response_type: "code", response_type: "code",
client_id: process.env.CLIENT_ID, client_id: process.env.CLIENT_ID,
scope: scope, scope: scope,
redirect_uri: process.env.REDIRECT_URI, redirect_uri: process.env.REDIRECT_URI,
state: state state: state
}).toString() }).toString()
); );
return; return;
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("login", { error }); logger.error("login", { error });
return; return;
} }
} }
/** /**
@ -42,61 +42,61 @@ const login = (_req, res) => {
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const callback = async (req, res) => { const callback = async (req, res) => {
try { try {
const { code, state, error } = req.query; const { code, state, error } = req.query;
const storedState = req.cookies ? req.cookies[stateKey] : null; const storedState = req.cookies ? req.cookies[stateKey] : null;
// check state // check state
if (state === null || state !== storedState) { if (state === null || state !== storedState) {
res.redirect(409, "/"); res.redirect(409, "/");
logger.warn("state mismatch"); logger.warn("state mismatch");
return; return;
} else if (error) { } else if (error) {
res.status(401).send({ message: "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 {
// get auth tokens // get auth tokens
res.clearCookie(stateKey); res.clearCookie(stateKey);
const authForm = { const authForm = {
code: code, code: code,
redirect_uri: process.env.REDIRECT_URI, redirect_uri: process.env.REDIRECT_URI,
grant_type: "authorization_code" grant_type: "authorization_code"
} }
const authPayload = (new URLSearchParams(authForm)).toString(); const authPayload = (new URLSearchParams(authForm)).toString();
const tokenResponse = await authInstance.post("/api/token", authPayload); const tokenResponse = await authInstance.post("/api/token", authPayload);
if (tokenResponse.status === 200) { if (tokenResponse.status === 200) {
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;
} else { } else {
logger.error("login failed", { statusCode: tokenResponse.status }); logger.error("login failed", { statusCode: tokenResponse.status });
res.status(tokenResponse.status).send({ message: "Error: Login failed" }); res.status(tokenResponse.status).send({ message: "Error: Login failed" });
} }
const userData = await getUserProfile(req, res); const userData = await getUserProfile(req, res);
if (res.headersSent) return; if (res.headersSent) return;
/** @type {typedefs.User} */ /** @type {typedefs.User} */
req.session.user = { req.session.user = {
username: userData.display_name, username: userData.display_name,
id: userData.id, id: userData.id,
}; };
// res.status(200).send({ message: "OK" }); // res.status(200).send({ message: "OK" });
res.redirect(process.env.APP_URI + "?login=success"); res.redirect(process.env.APP_URI + "?login=success");
logger.debug("New login.", { username: userData.display_name }); logger.debug("New login.", { username: userData.display_name });
return; return;
} }
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("callback", { error }); logger.error("callback", { error });
return; return;
} }
} }
/** /**
@ -105,33 +105,33 @@ const callback = async (req, res) => {
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const refresh = async (req, res) => { const refresh = async (req, res) => {
try { try {
const authForm = { const authForm = {
refresh_token: req.session.refreshToken, refresh_token: req.session.refreshToken,
grant_type: "refresh_token", grant_type: "refresh_token",
} }
const authPayload = (new URLSearchParams(authForm)).toString(); const authPayload = (new URLSearchParams(authForm)).toString();
const response = await authInstance.post("/api/token", authPayload); const response = await authInstance.post("/api/token", authPayload);
if (response.status === 200) { if (response.status === 200) {
req.session.accessToken = response.data.access_token; req.session.accessToken = response.data.access_token;
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.status(200).send({ message: "OK" }); res.status(200).send({ message: "OK" });
logger.debug(`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({ message: "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;
} }
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("refresh", { error }); logger.error("refresh", { error });
return; return;
} }
}; };
/** /**
@ -140,30 +140,30 @@ const refresh = async (req, res) => {
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const logout = async (req, res) => { const logout = async (req, res) => {
try { try {
const delSession = req.session.destroy((error) => { const delSession = req.session.destroy((error) => {
if (error) { if (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("Error while logging out", { error }); logger.error("Error while logging out", { error });
return; return;
} else { } else {
res.clearCookie(sessionName); res.clearCookie(sessionName);
// res.status(200).send({ message: "OK" }); // res.status(200).send({ message: "OK" });
res.redirect(process.env.APP_URI + "?logout=success"); res.redirect(process.env.APP_URI + "?logout=success");
logger.debug("Logged out.", { sessionID: delSession.id }); logger.debug("Logged out.", { sessionID: delSession.id });
return; return;
} }
}) })
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("logout", { error }); logger.error("logout", { error });
return; return;
} }
} }
module.exports = { module.exports = {
login, login,
callback, callback,
refresh, refresh,
logout logout
}; };

File diff suppressed because it is too large Load Diff

View File

@ -10,54 +10,54 @@ const { parseSpotifyLink } = require("../utils/spotifyURITransformer");
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const fetchUserPlaylists = async (req, res) => { const fetchUserPlaylists = async (req, res) => {
try { try {
let userPlaylists = {}; let userPlaylists = {};
// get first 50 // get first 50
const respData = await getUserPlaylistsFirstPage(req, res); const respData = await getUserPlaylistsFirstPage(req, res);
if (res.headersSent) return; if (res.headersSent) return;
userPlaylists.total = respData.total; userPlaylists.total = respData.total;
userPlaylists.items = respData.items.map((playlist) => { userPlaylists.items = respData.items.map((playlist) => {
return { return {
uri: playlist.uri, uri: playlist.uri,
images: playlist.images, images: playlist.images,
name: playlist.name, name: playlist.name,
total: playlist.tracks.total total: playlist.tracks.total
} }
}); });
userPlaylists.next = respData.next; userPlaylists.next = respData.next;
// keep getting batches of 50 till exhausted // keep getting batches of 50 till exhausted
while (userPlaylists.next) { while (userPlaylists.next) {
const nextData = await getUserPlaylistsNextPage(req, res, userPlaylists.next); const nextData = await getUserPlaylistsNextPage(req, res, userPlaylists.next);
if (res.headersSent) return; if (res.headersSent) return;
userPlaylists.items.push( userPlaylists.items.push(
...nextData.items.map((playlist) => { ...nextData.items.map((playlist) => {
return { return {
uri: playlist.uri, uri: playlist.uri,
images: playlist.images, images: playlist.images,
name: playlist.name, name: playlist.name,
total: playlist.tracks.total total: playlist.tracks.total
} }
}) })
); );
userPlaylists.next = nextData.next; userPlaylists.next = nextData.next;
} }
delete userPlaylists.next; delete userPlaylists.next;
res.status(200).send(userPlaylists); res.status(200).send(userPlaylists);
logger.debug("Fetched user playlists", { num: userPlaylists.total }); logger.debug("Fetched user playlists", { num: userPlaylists.total });
return; return;
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("fetchUserPlaylists", { error }); logger.error("fetchUserPlaylists", { error });
return; return;
} }
} }
/** /**
@ -66,94 +66,94 @@ const fetchUserPlaylists = async (req, res) => {
* @param {typedefs.Res} res * @param {typedefs.Res} res
*/ */
const fetchPlaylistDetails = async (req, res) => { const fetchPlaylistDetails = async (req, res) => {
try { try {
let playlist = {}; let playlist = {};
/** @type {typedefs.URIObject} */ /** @type {typedefs.URIObject} */
let uri; let uri;
let initialFields = ["collaborative", "description", "images", "name", "owner(uri,display_name)", "public", let initialFields = ["collaborative", "description", "images", "name", "owner(uri,display_name)", "public",
"snapshot_id", "tracks(next,total,items(is_local,track(name,uri)))"]; "snapshot_id", "tracks(next,total,items(is_local,track(name,uri)))"];
let mainFields = ["next,items(is_local,track(name,uri))"]; let mainFields = ["next,items(is_local,track(name,uri))"];
try { try {
uri = parseSpotifyLink(req.query.playlist_link) uri = parseSpotifyLink(req.query.playlist_link)
if (uri.type !== "playlist") { if (uri.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", { uri }); logger.warn("non-playlist link provided", { uri });
return; return;
} }
} catch (error) { } catch (error) {
res.status(400).send({ message: error.message }); res.status(400).send({ message: error.message });
logger.warn("parseSpotifyLink", { error }); logger.warn("parseSpotifyLink", { error });
return; return;
} }
const respData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), uri.id); const respData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), uri.id);
if (res.headersSent) return; if (res.headersSent) return;
// TODO: this whole section needs to be DRYer // TODO: this whole section needs to be DRYer
// look into serializr // look into serializr
playlist.name = respData.name; playlist.name = respData.name;
playlist.description = respData.description; playlist.description = respData.description;
playlist.collaborative = respData.collaborative; playlist.collaborative = respData.collaborative;
playlist.public = respData.public; playlist.public = respData.public;
playlist.images = [...respData.images]; playlist.images = [...respData.images];
playlist.owner = { ...respData.owner }; playlist.owner = { ...respData.owner };
playlist.snapshot_id = respData.snapshot_id; playlist.snapshot_id = respData.snapshot_id;
playlist.total = respData.tracks.total; playlist.total = respData.tracks.total;
// previous fields get carried over to the next URL, but most of these fields are not present in the new endpoint // previous fields get carried over to the next URL, but most of these fields are not present in the new endpoint
// API shouldn't be returning such URLs, the problem's in the API ig... // API shouldn't be returning such URLs, the problem's in the API ig...
if (respData.tracks.next) { if (respData.tracks.next) {
playlist.next = new URL(respData.tracks.next); playlist.next = new URL(respData.tracks.next);
playlist.next.searchParams.set("fields", mainFields.join()); playlist.next.searchParams.set("fields", mainFields.join());
playlist.next = playlist.next.href; playlist.next = playlist.next.href;
} }
playlist.tracks = respData.tracks.items.map((playlist_item) => { playlist.tracks = respData.tracks.items.map((playlist_item) => {
return { return {
is_local: playlist_item.is_local, is_local: playlist_item.is_local,
track: { track: {
name: playlist_item.track.name, name: playlist_item.track.name,
type: playlist_item.track.type, type: playlist_item.track.type,
uri: playlist_item.track.uri uri: playlist_item.track.uri
} }
} }
}); });
// keep getting batches of 50 till exhausted // keep getting batches of 50 till exhausted
while (playlist.next) { while (playlist.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, playlist.next); const nextData = await getPlaylistDetailsNextPage(req, res, playlist.next);
if (res.headersSent) return; if (res.headersSent) return;
playlist.tracks.push( playlist.tracks.push(
...nextData.items.map((playlist_item) => { ...nextData.items.map((playlist_item) => {
return { return {
is_local: playlist_item.is_local, is_local: playlist_item.is_local,
track: { track: {
name: playlist_item.track.name, name: playlist_item.track.name,
type: playlist_item.track.type, type: playlist_item.track.type,
uri: playlist_item.track.uri uri: playlist_item.track.uri
} }
} }
}) })
); );
playlist.next = nextData.next; playlist.next = nextData.next;
} }
delete playlist.next; delete playlist.next;
res.status(200).send(playlist); res.status(200).send(playlist);
logger.debug("Fetched playlist tracks", { num: playlist.tracks.length }); logger.debug("Fetched playlist tracks", { num: playlist.tracks.length });
return; return;
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("getPlaylistDetails", { error }); logger.error("getPlaylistDetails", { error });
return; return;
} }
} }
module.exports = { module.exports = {
fetchUserPlaylists, fetchUserPlaylists,
fetchPlaylistDetails fetchPlaylistDetails
}; };

View File

@ -24,32 +24,32 @@ app.set("trust proxy", process.env.TRUST_PROXY);
// Configure SQLite store file // Configure SQLite store file
const sqliteStore = new SQLiteStore({ const sqliteStore = new SQLiteStore({
table: "session_store", table: "session_store",
db: "spotify-manager.db" db: "spotify-manager.db"
}); });
// Configure session middleware // Configure session middleware
app.use(session({ app.use(session({
name: sessionName, name: sessionName,
store: sqliteStore, store: sqliteStore,
secret: process.env.SESSION_SECRET, secret: process.env.SESSION_SECRET,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
domain: process.env.BASE_DOMAIN, 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 maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
sameSite: process.env.NODE_ENV === "development" ? "lax" : "none", // cross-site for production 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 secure: process.env.NODE_ENV === "development" ? false : true, // if true only transmit cookie over https
} }
})); }));
app.use(cors({ app.use(cors({
origin: process.env.APP_URI, origin: process.env.APP_URI,
credentials: true credentials: true
})); }));
app.use(helmet({ app.use(helmet({
crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" } crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" }
})); }));
app.disable("x-powered-by"); app.disable("x-powered-by");
@ -62,20 +62,20 @@ app.use(express.static(__dirname + "/static"));
// Healthcheck // Healthcheck
app.use("/health", (req, res) => { app.use("/health", (req, res) => {
res.status(200).send({ message: "OK" }); res.status(200).send({ message: "OK" });
return; return;
}); });
app.use("/auth-health", isAuthenticated, async (req, res) => { app.use("/auth-health", isAuthenticated, async (req, res) => {
try { try {
await getUserProfile(req, res); await getUserProfile(req, res);
if (res.headersSent) return; if (res.headersSent) return;
res.status(200).send({ message: "OK" }); res.status(200).send({ message: "OK" });
return; return;
} catch (error) { } catch (error) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("authHealthCheck", { error }); logger.error("authHealthCheck", { error });
return; return;
} }
}); });
// Routes // Routes
@ -85,32 +85,32 @@ app.use("/api/operations", isAuthenticated, require("./routes/operations"));
// Fallbacks // Fallbacks
app.use((req, res) => { 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("404", { url: req.url }); logger.info("404", { url: req.url });
return; return;
}); });
const port = process.env.PORT || 5000; const port = process.env.PORT || 5000;
const server = app.listen(port, () => { const server = app.listen(port, () => {
logger.info(`App Listening on port ${port}`); logger.info(`App Listening on port ${port}`);
}); });
const cleanupFunc = (signal) => { const cleanupFunc = (signal) => {
if (signal) if (signal)
logger.debug(`${signal} signal received, shutting down now...`); logger.debug(`${signal} signal received, shutting down now...`);
Promise.allSettled([ Promise.allSettled([
db.sequelize.close(), db.sequelize.close(),
util.promisify(server.close), util.promisify(server.close),
]).then(() => { ]).then(() => {
logger.info("Cleaned up, exiting."); logger.info("Cleaned up, exiting.");
process.exit(0); process.exit(0);
}); });
} }
["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM", "SIGUSR1", "SIGUSR2"].forEach((signal) => { ["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM", "SIGUSR1", "SIGUSR2"].forEach((signal) => {
process.on(signal, () => cleanupFunc(signal)); process.on(signal, () => cleanupFunc(signal));
}); });

View File

@ -9,28 +9,28 @@ const logger = require("../utils/logger")(module);
* @param {typedefs.Next} next * @param {typedefs.Next} next
*/ */
const isAuthenticated = (req, res, next) => { const isAuthenticated = (req, res, next) => {
if (req.session.accessToken) { if (req.session.accessToken) {
req.sessHeaders = { req.sessHeaders = {
"Authorization": `Bearer ${req.session.accessToken}`, "Authorization": `Bearer ${req.session.accessToken}`,
// "X-RateLimit-SessID": `${req.sessionID}_${req.session.user.username}` // "X-RateLimit-SessID": `${req.sessionID}_${req.session.user.username}`
}; };
next(); next();
} else { } else {
const delSession = req.session.destroy((err) => { const delSession = req.session.destroy((err) => {
if (err) { if (err) {
res.status(500).send({ message: "Internal Server Error" }); res.status(500).send({ message: "Internal Server Error" });
logger.error("session.destroy", { err }); logger.error("session.destroy", { err });
return; return;
} else { } else {
res.clearCookie(sessionName); res.clearCookie(sessionName);
res.status(401).send({ message: "Unauthorized" }); res.status(401).send({ message: "Unauthorized" });
logger.debug("Session invalid, destroyed.", { sessionID: delSession.id }); logger.debug("Session invalid, destroyed.", { sessionID: delSession.id });
return; return;
} }
}); });
} }
} }
module.exports = { module.exports = {
isAuthenticated, isAuthenticated,
} }

View File

@ -10,37 +10,37 @@ const db = {};
let sequelize; let sequelize;
if (config.use_env_variable) { if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config); sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else { } else {
sequelize = new Sequelize(config.database, config.username, config.password, config); sequelize = new Sequelize(config.database, config.username, config.password, config);
} }
(async () => { (async () => {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
logger.debug("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;
} }
})(); })();
// Read model definitions from folder // Read model definitions from folder
fs fs
.readdirSync(__dirname) .readdirSync(__dirname)
.filter(file => { .filter(file => {
return (file.indexOf(".") !== 0) && (file !== basename) && (file.slice(-3) === ".js"); return (file.indexOf(".") !== 0) && (file !== basename) && (file.slice(-3) === ".js");
}) })
.forEach(file => { .forEach(file => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
db[model.name] = model; db[model.name] = model;
}); });
// Setup defined associations // Setup defined associations
Object.keys(db).forEach(modelName => { Object.keys(db).forEach(modelName => {
if (db[modelName].associate) { if (db[modelName].associate) {
db[modelName].associate(db); db[modelName].associate(db);
} }
}); });
db.sequelize = sequelize; db.sequelize = sequelize;

View File

@ -5,24 +5,24 @@ const { isAuthenticated } = require("../middleware/authCheck");
const validator = require("../validators"); const validator = require("../validators");
router.get( router.get(
"/login", "/login",
login login
); );
router.get( router.get(
"/callback", "/callback",
callback callback
); );
router.get( router.get(
"/refresh", "/refresh",
isAuthenticated, isAuthenticated,
refresh refresh
); );
router.get( router.get(
"/logout", "/logout",
logout logout
); );
module.exports = router; module.exports = router;

View File

@ -5,41 +5,41 @@ const { validate } = require("../validators");
const { createLinkValidator, removeLinkValidator, populateSingleLinkValidator, pruneSingleLinkValidator } = require("../validators/operations"); const { createLinkValidator, removeLinkValidator, populateSingleLinkValidator, pruneSingleLinkValidator } = require("../validators/operations");
router.put( router.put(
"/update", "/update",
updateUser updateUser
); );
router.get( router.get(
"/fetch", "/fetch",
fetchUser fetchUser
); );
router.post( router.post(
"/link", "/link",
createLinkValidator, createLinkValidator,
validate, validate,
createLink createLink
); );
router.delete( router.delete(
"/link", "/link",
removeLinkValidator, removeLinkValidator,
validate, validate,
removeLink removeLink
); );
router.put( router.put(
"/populate/link", "/populate/link",
populateSingleLinkValidator, populateSingleLinkValidator,
validate, validate,
populateSingleLink populateSingleLink
); );
router.put( router.put(
"/prune/link", "/prune/link",
pruneSingleLinkValidator, pruneSingleLinkValidator,
validate, validate,
pruneSingleLink pruneSingleLink
); );
module.exports = router; module.exports = router;

View File

@ -5,15 +5,15 @@ const { getPlaylistDetailsValidator } = require("../validators/playlists");
const { validate } = require("../validators"); const { validate } = require("../validators");
router.get( router.get(
"/me", "/me",
fetchUserPlaylists fetchUserPlaylists
); );
router.get( router.get(
"/details", "/details",
getPlaylistDetailsValidator, getPlaylistDetailsValidator,
validate, validate,
fetchPlaylistDetails fetchPlaylistDetails
); );
module.exports = router; module.exports = router;

View File

@ -3,5 +3,5 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const randomBool = (chance_of_failure = 0.25) => Math.random() < chance_of_failure; const randomBool = (chance_of_failure = 0.25) => Math.random() < chance_of_failure;
module.exports = { module.exports = {
sleep, randomBool sleep, randomBool
}; };

View File

@ -4,11 +4,11 @@
* @return {string} The generated string * @return {string} The generated string
*/ */
module.exports = (length) => { module.exports = (length) => {
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let text = ""; let text = "";
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length)); text += possible.charAt(Math.floor(Math.random() * possible.length));
} }
return text; return text;
}; };

View File

@ -22,80 +22,80 @@ const typedefs = require("../typedefs");
* ``` * ```
*/ */
class myGraph { class myGraph {
/** /**
* @param {string[]} nodes Graph nodes IDs * @param {string[]} nodes Graph nodes IDs
* @param {{ from: string, to: string }[]} edges Graph edges b/w nodes * @param {{ from: string, to: string }[]} edges Graph edges b/w nodes
*/ */
constructor(nodes, edges) { constructor(nodes, edges) {
this.nodes = [...nodes]; this.nodes = [...nodes];
this.edges = structuredClone(edges); this.edges = structuredClone(edges);
} }
/** /**
* @param {string} node * @param {string} node
* @returns {string[]} * @returns {string[]}
*/ */
getDirectHeads(node) { getDirectHeads(node) {
return this.edges.filter(edge => edge.to == node).map(edge => edge.from); return this.edges.filter(edge => edge.to == node).map(edge => edge.from);
} }
/** /**
* @param {string} node * @param {string} node
* @returns {string[]} * @returns {string[]}
*/ */
getDirectTails(node) { getDirectTails(node) {
return this.edges.filter(edge => edge.from == node).map(edge => edge.to); return this.edges.filter(edge => edge.from == node).map(edge => edge.to);
} }
/** /**
* Kahn's topological sort * Kahn's topological sort
* @returns {string[]} * @returns {string[]}
*/ */
topoSort() { topoSort() {
let inDegree = {}; let inDegree = {};
let zeroInDegreeQueue = []; let zeroInDegreeQueue = [];
let topologicalOrder = []; let topologicalOrder = [];
// Initialize inDegree of all nodes to 0 // Initialize inDegree of all nodes to 0
for (let node of this.nodes) { for (let node of this.nodes) {
inDegree[node] = 0; inDegree[node] = 0;
} }
// Calculate inDegree of each node // Calculate inDegree of each node
for (let edge of this.edges) { for (let edge of this.edges) {
inDegree[edge.to]++; inDegree[edge.to]++;
} }
// Collect nodes with 0 inDegree // Collect nodes with 0 inDegree
for (let node of this.nodes) { for (let node of this.nodes) {
if (inDegree[node] === 0) { if (inDegree[node] === 0) {
zeroInDegreeQueue.push(node); zeroInDegreeQueue.push(node);
} }
} }
// process nodes with 0 inDegree // process nodes with 0 inDegree
while (zeroInDegreeQueue.length > 0) { while (zeroInDegreeQueue.length > 0) {
let node = zeroInDegreeQueue.shift(); let node = zeroInDegreeQueue.shift();
topologicalOrder.push(node); topologicalOrder.push(node);
for (let tail of this.getDirectTails(node)) { for (let tail of this.getDirectTails(node)) {
inDegree[tail]--; inDegree[tail]--;
if (inDegree[tail] === 0) { if (inDegree[tail] === 0) {
zeroInDegreeQueue.push(tail); zeroInDegreeQueue.push(tail);
} }
} }
} }
return topologicalOrder; return topologicalOrder;
} }
/** /**
* Check if the graph contains a cycle * Check if the graph contains a cycle
* @returns {boolean} * @returns {boolean}
*/ */
detectCycle() { detectCycle() {
// If topological order includes all nodes, no cycle exists // If topological order includes all nodes, no cycle exists
return this.topoSort().length < this.nodes.length; return this.topoSort().length < this.nodes.length;
} }
} }
module.exports = myGraph; module.exports = myGraph;

View File

@ -6,18 +6,18 @@
* @returns {string} * @returns {string}
*/ */
const getNestedValuesString = (obj, delimiter = ", ") => { const getNestedValuesString = (obj, delimiter = ", ") => {
let values = []; let values = [];
for (key in obj) { for (key in obj) {
if (typeof obj[key] !== "object") { if (typeof obj[key] !== "object") {
values.push(obj[key]); values.push(obj[key]);
} else { } else {
values = values.concat(getNestedValuesString(obj[key])); values = values.concat(getNestedValuesString(obj[key]));
} }
} }
return values.join(delimiter); return values.join(delimiter);
} }
module.exports = { module.exports = {
getNestedValuesString getNestedValuesString
} }

View File

@ -6,31 +6,31 @@ const { combine, label, timestamp, printf, errors } = format;
const typedefs = require("../typedefs"); const typedefs = require("../typedefs");
const getLabel = (callingModule) => { const getLabel = (callingModule) => {
if (!callingModule.filename) return "repl"; if (!callingModule.filename) return "repl";
const parts = callingModule.filename?.split(path.sep); const parts = callingModule.filename?.split(path.sep);
return path.join(parts[parts.length - 2], parts.pop()); return path.join(parts[parts.length - 2], parts.pop());
}; };
const allowedErrorKeys = ["name", "code", "message", "stack"]; const allowedErrorKeys = ["name", "code", "message", "stack"];
const metaFormat = (meta) => { const metaFormat = (meta) => {
if (Object.keys(meta).length > 0) if (Object.keys(meta).length > 0)
return "\n" + JSON.stringify(meta, null, "\t"); return "\n" + JSON.stringify(meta, null, "\t");
return ""; return "";
} }
const logFormat = printf(({ level, message, label, timestamp, ...meta }) => { const logFormat = printf(({ level, message, label, timestamp, ...meta }) => {
if (meta.error) { // if the error was passed if (meta.error) { // if the error was passed
for (const key in meta.error) { for (const key in meta.error) {
if (!allowedErrorKeys.includes(key)) { if (!allowedErrorKeys.includes(key)) {
delete meta.error[key]; delete meta.error[key];
} }
}
const { stack, ...rest } = meta.error;
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` +
`${stack ?? ""}`;
} }
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`; const { stack, ...rest } = meta.error;
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` +
`${stack ?? ""}`;
}
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`;
}); });
/** /**
@ -38,30 +38,30 @@ const logFormat = printf(({ level, message, label, timestamp, ...meta }) => {
* @param {typedefs.Module} callingModule The module from which the logger is called * @param {typedefs.Module} callingModule The module from which the logger is called
*/ */
const curriedLogger = (callingModule) => { const curriedLogger = (callingModule) => {
let winstonLogger = createLogger({ let winstonLogger = createLogger({
levels: config.npm.levels, levels: config.npm.levels,
format: combine( format: combine(
errors({ stack: true }), errors({ stack: true }),
label({ label: getLabel(callingModule) }), label({ label: getLabel(callingModule) }),
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
logFormat, logFormat,
), ),
transports: [ transports: [
new transports.Console({ level: "info" }), new transports.Console({ level: "info" }),
new transports.File({ new transports.File({
filename: __dirname + "/../logs/debug.log", filename: __dirname + "/../logs/debug.log",
level: "debug", level: "debug",
maxsize: 10485760, maxsize: 10485760,
}), }),
new transports.File({ new transports.File({
filename: __dirname + "/../logs/error.log", filename: __dirname + "/../logs/error.log",
level: "error", level: "error",
maxsize: 1048576, maxsize: 1048576,
}), }),
] ]
}); });
winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error })); winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error }));
return winstonLogger; return winstonLogger;
} }
module.exports = curriedLogger; module.exports = curriedLogger;

View File

@ -11,46 +11,46 @@ const base62Pattern = /^[A-Za-z0-9]+$/;
* @throws {TypeError} If the input is not a valid Spotify URI * @throws {TypeError} If the input is not a valid Spotify URI
*/ */
const parseSpotifyURI = (uri) => { const parseSpotifyURI = (uri) => {
const parts = uri.split(":"); const parts = uri.split(":");
if (parts[0] !== "spotify") { if (parts[0] !== "spotify") {
throw new TypeError(`${uri} is not a valid Spotify URI`); throw new TypeError(`${uri} is not a valid Spotify URI`);
} }
let type = parts[1]; let type = parts[1];
if (type === "local") { if (type === "local") {
// Local file format: spotify:local:<artist>:<album>:<title>:<duration> // Local file format: spotify:local:<artist>:<album>:<title>:<duration>
let idParts = parts.slice(2); let idParts = parts.slice(2);
if (idParts.length < 4) { if (idParts.length < 4) {
throw new TypeError(`${uri} is not a valid local file URI`); throw new TypeError(`${uri} is not a valid local file URI`);
} }
// URL decode artist, album, and title // URL decode artist, album, and title
const artist = decodeURIComponent(idParts[0] || ""); const artist = decodeURIComponent(idParts[0] || "");
const album = decodeURIComponent(idParts[1] || ""); const album = decodeURIComponent(idParts[1] || "");
const title = decodeURIComponent(idParts[2]); const title = decodeURIComponent(idParts[2]);
const duration = parseInt(idParts[3], 10); const duration = parseInt(idParts[3], 10);
if (isNaN(duration)) { if (isNaN(duration)) {
throw new TypeError(`${uri} has an invalid duration`); throw new TypeError(`${uri} has an invalid duration`);
} }
return { type: "track", is_local: true, artist, album, title, duration }; return { type: "track", is_local: true, artist, album, title, duration };
} else { } else {
// Not a local file // Not a local file
if (parts.length !== 3) { if (parts.length !== 3) {
throw new TypeError(`${uri} is not a valid Spotify URI`); throw new TypeError(`${uri} is not a valid Spotify URI`);
} }
const id = parts[2]; const id = parts[2];
if (!base62Pattern.test(id)) { if (!base62Pattern.test(id)) {
throw new TypeError(`${uri} has an invalid ID`); throw new TypeError(`${uri} has an invalid ID`);
} }
return { type, is_local: false, id }; return { type, is_local: false, id };
} }
} }
/** /**
@ -60,45 +60,45 @@ const parseSpotifyURI = (uri) => {
* @throws {TypeError} If the input is not a valid Spotify link * @throws {TypeError} If the input is not a valid Spotify link
*/ */
const parseSpotifyLink = (link) => { const parseSpotifyLink = (link) => {
const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/; const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/; const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/;
if (localPattern.test(link)) { if (localPattern.test(link)) {
// Local file format: https://open.spotify.com/local/artist/album/title/duration // Local file format: https://open.spotify.com/local/artist/album/title/duration
const matches = link.match(localPattern); const matches = link.match(localPattern);
if (!matches) { if (!matches) {
throw new TypeError(`${link} is not a valid Spotify local file link`); throw new TypeError(`${link} is not a valid Spotify local file link`);
} }
// URL decode artist, album, and title // URL decode artist, album, and title
const artist = decodeURIComponent(matches[1] || ""); const artist = decodeURIComponent(matches[1] || "");
const album = decodeURIComponent(matches[2] || ""); const album = decodeURIComponent(matches[2] || "");
const title = decodeURIComponent(matches[3]); const title = decodeURIComponent(matches[3]);
const duration = parseInt(matches[4], 10); const duration = parseInt(matches[4], 10);
if (isNaN(duration)) { if (isNaN(duration)) {
throw new TypeError(`${link} has an invalid duration`); throw new TypeError(`${link} has an invalid duration`);
} }
return { type: "track", is_local: true, artist, album, title, duration }; return { type: "track", is_local: true, artist, album, title, duration };
} else if (standardPattern.test(link)) { } else if (standardPattern.test(link)) {
// Not a local file // Not a local file
const matches = link.match(standardPattern); const matches = link.match(standardPattern);
if (!matches || matches.length < 3) { if (!matches || matches.length < 3) {
throw new TypeError(`${link} is not a valid Spotify link`); throw new TypeError(`${link} is not a valid Spotify link`);
} }
const type = matches[1]; const type = matches[1];
const id = matches[2]; const id = matches[2];
if (!base62Pattern.test(id)) { if (!base62Pattern.test(id)) {
throw new TypeError(`${link} has an invalid ID`); throw new TypeError(`${link} has an invalid ID`);
} }
return { type, is_local: false, id }; return { type, is_local: false, id };
} else { } else {
throw new TypeError(`${link} is not a valid Spotify link`); throw new TypeError(`${link} is not a valid Spotify link`);
} }
} }
/** /**
@ -107,14 +107,14 @@ const parseSpotifyLink = (link) => {
* @returns {string} * @returns {string}
*/ */
const buildSpotifyURI = (uriObj) => { const buildSpotifyURI = (uriObj) => {
if (uriObj.is_local) { if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? ""); const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? ""); const album = encodeURIComponent(uriObj.album ?? "");
const title = encodeURIComponent(uriObj.title ?? ""); const title = encodeURIComponent(uriObj.title ?? "");
const duration = uriObj.duration ? uriObj.duration.toString() : ""; const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `spotify:local:${artist}:${album}:${title}:${duration}`; return `spotify:local:${artist}:${album}:${title}:${duration}`;
} }
return `spotify:${uriObj.type}:${uriObj.id}`; return `spotify:${uriObj.type}:${uriObj.id}`;
} }
/** /**
@ -123,19 +123,19 @@ const buildSpotifyURI = (uriObj) => {
* @returns {string} * @returns {string}
*/ */
const buildSpotifyLink = (uriObj) => { const buildSpotifyLink = (uriObj) => {
if (uriObj.is_local) { if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? ""); const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? ""); const album = encodeURIComponent(uriObj.album ?? "");
const title = encodeURIComponent(uriObj.title ?? ""); const title = encodeURIComponent(uriObj.title ?? "");
const duration = uriObj.duration ? uriObj.duration.toString() : ""; const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`; return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`;
} }
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}` return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`
} }
module.exports = { module.exports = {
parseSpotifyURI, parseSpotifyURI,
parseSpotifyLink, parseSpotifyLink,
buildSpotifyURI, buildSpotifyURI,
buildSpotifyLink buildSpotifyLink
} }

View File

@ -13,34 +13,34 @@ const typedefs = require("../typedefs");
* @param {typedefs.Next} next * @param {typedefs.Next} next
*/ */
const validate = (req, res, next) => { const validate = (req, res, next) => {
const errors = validationResult(req); const errors = validationResult(req);
if (errors.isEmpty()) { if (errors.isEmpty()) {
return next(); return next();
} }
const extractedErrors = []; const extractedErrors = [];
errors.array().forEach(err => { errors.array().forEach(err => {
if (err.type === "alternative") { if (err.type === "alternative") {
err.nestedErrors.forEach(nestedErr => { err.nestedErrors.forEach(nestedErr => {
extractedErrors.push({ extractedErrors.push({
[nestedErr.path]: nestedErr.msg [nestedErr.path]: nestedErr.msg
}); });
}); });
} else if (err.type === "field") { } else if (err.type === "field") {
extractedErrors.push({ extractedErrors.push({
[err.path]: err.msg [err.path]: err.msg
}); });
} }
}); });
res.status(400).json({ res.status(400).json({
message: getNestedValuesString(extractedErrors), message: getNestedValuesString(extractedErrors),
errors: extractedErrors errors: extractedErrors
}); });
logger.warn("invalid request", { extractedErrors }); logger.warn("invalid request", { extractedErrors });
return; return;
} }
module.exports = { module.exports = {
validate validate
}; };

View File

@ -8,24 +8,24 @@ const typedefs = require("../typedefs");
* @param {typedefs.Next} next * @param {typedefs.Next} next
*/ */
const createLinkValidator = async (req, res, next) => { const createLinkValidator = async (req, res, next) => {
await body("from") await body("from")
.notEmpty() .notEmpty()
.withMessage("from not defined in body") .withMessage("from not defined in body")
.isURL() .isURL()
.withMessage("from must be a valid link") .withMessage("from must be a valid link")
.run(req); .run(req);
await body("to") await body("to")
.notEmpty() .notEmpty()
.withMessage("to not defined in body") .withMessage("to not defined in body")
.isURL() .isURL()
.withMessage("to must be a valid link") .withMessage("to must be a valid link")
.run(req); .run(req);
next(); next();
} }
module.exports = { module.exports = {
createLinkValidator, createLinkValidator,
removeLinkValidator: createLinkValidator, removeLinkValidator: createLinkValidator,
populateSingleLinkValidator: createLinkValidator, populateSingleLinkValidator: createLinkValidator,
pruneSingleLinkValidator: createLinkValidator, pruneSingleLinkValidator: createLinkValidator,
} }

View File

@ -8,15 +8,15 @@ const typedefs = require("../typedefs");
* @param {typedefs.Next} next * @param {typedefs.Next} next
*/ */
const getPlaylistDetailsValidator = async (req, res, next) => { const getPlaylistDetailsValidator = async (req, res, next) => {
await query("playlist_link") await query("playlist_link")
.notEmpty() .notEmpty()
.withMessage("playlist_link not defined in query") .withMessage("playlist_link not defined in query")
.isURL() .isURL()
.withMessage("playlist_link must be a valid link") .withMessage("playlist_link must be a valid link")
.run(req); .run(req);
next(); next();
} }
module.exports = { module.exports = {
getPlaylistDetailsValidator getPlaylistDetailsValidator
} }