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

View File

@ -18,147 +18,147 @@ const logPrefix = "Spotify API: ";
* @param {boolean} inlineData true if data is to be placed inside config
*/
const singleRequest = async (req, res, method, path, config = {}, data = null, inlineData = false) => {
let resp;
config.headers = { ...config.headers, ...req.sessHeaders };
try {
if (!data || (data && inlineData)) {
if (data)
config.data = data ?? null;
resp = await axiosInstance[method.toLowerCase()](path, config);
} else
resp = await axiosInstance[method.toLowerCase()](path, data, config);
let resp;
config.headers = { ...config.headers, ...req.sessHeaders };
try {
if (!data || (data && inlineData)) {
if (data)
config.data = data ?? null;
resp = await axiosInstance[method.toLowerCase()](path, config);
} else
resp = await axiosInstance[method.toLowerCase()](path, data, config);
logger.debug(logPrefix + "Successful response received.");
return resp;
} catch (error) {
if (error.response) {
// Non 2XX response received
let logMsg;
if (error.response.status >= 400 && error.response.status < 600) {
res.status(error.response.status).send(error.response.data);
logMsg = "" + error.response.status
}
else {
res.sendStatus(error.response.status);
logMsg = "???";
}
logger.warn(logPrefix + logMsg, {
response: {
data: error.response.data,
status: error.response.status,
}
});
} else if (error.request) {
// No response received
res.status(504).send({ message: "No response from Spotify" });
logger.error(logPrefix + "No response", { error });
} else {
// Something happened in setting up the request that triggered an Error
res.status(500).send({ message: "Internal Server Error" });
logger.error(logPrefix + "Request failed?", { error });
}
logger.debug(logPrefix + "Successful response received.");
return resp;
} catch (error) {
if (error.response) {
// Non 2XX response received
let logMsg;
if (error.response.status >= 400 && error.response.status < 600) {
res.status(error.response.status).send(error.response.data);
logMsg = "" + error.response.status
}
else {
res.sendStatus(error.response.status);
logMsg = "???";
}
logger.warn(logPrefix + logMsg, {
response: {
data: error.response.data,
status: error.response.status,
}
});
} else if (error.request) {
// No response received
res.status(504).send({ message: "No response from Spotify" });
logger.error(logPrefix + "No response", { error });
} else {
// Something happened in setting up the request that triggered an Error
res.status(500).send({ message: "Internal Server Error" });
logger.error(logPrefix + "Request failed?", { error });
}
return null;
};
return null;
};
}
const getUserProfile = async (req, res) => {
const response = await singleRequest(req, res,
"GET", "/me",
{ headers: { Authorization: `Bearer ${req.session.accessToken}` } }
);
return res.headersSent ? null : response.data;
const response = await singleRequest(req, res,
"GET", "/me",
{ headers: { Authorization: `Bearer ${req.session.accessToken}` } }
);
return res.headersSent ? null : response.data;
}
const getUserPlaylistsFirstPage = async (req, res) => {
const response = await singleRequest(req, res,
"GET",
`/users/${req.session.user.id}/playlists`,
{
params: {
offset: 0,
limit: 50,
},
});
return res.headersSent ? null : response.data;
const response = await singleRequest(req, res,
"GET",
`/users/${req.session.user.id}/playlists`,
{
params: {
offset: 0,
limit: 50,
},
});
return res.headersSent ? null : response.data;
}
const getUserPlaylistsNextPage = async (req, res, nextURL) => {
const response = await singleRequest(
req, res, "GET", nextURL);
return res.headersSent ? null : response.data;
const response = await singleRequest(
req, res, "GET", nextURL);
return res.headersSent ? null : response.data;
}
const getPlaylistDetailsFirstPage = async (req, res, initialFields, playlistID) => {
const response = await singleRequest(req, res,
"GET",
`/playlists/${playlistID}/`,
{
params: {
fields: initialFields
},
});
return res.headersSent ? null : response.data;
const response = await singleRequest(req, res,
"GET",
`/playlists/${playlistID}/`,
{
params: {
fields: initialFields
},
});
return res.headersSent ? null : response.data;
}
const getPlaylistDetailsNextPage = async (req, res, nextURL) => {
const response = await singleRequest(
req, res, "GET", nextURL);
return res.headersSent ? null : response.data;
const response = await singleRequest(
req, res, "GET", nextURL);
return res.headersSent ? null : response.data;
}
const addItemsToPlaylist = async (req, res, nextBatch, playlistID) => {
const response = await singleRequest(req, res,
"POST",
`/playlists/${playlistID}/tracks`,
{},
{ uris: nextBatch }, false
)
return res.headersSent ? null : response.data;
const response = await singleRequest(req, res,
"POST",
`/playlists/${playlistID}/tracks`,
{},
{ uris: nextBatch }, false
)
return res.headersSent ? null : response.data;
}
const removeItemsFromPlaylist = async (req, res, nextBatch, playlistID, snapshotID) => {
// 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
const response = await singleRequest(req, res,
"DELETE",
`/playlists/${playlistID}/tracks`,
{},
// axios delete method doesn't have separate arg for body so hv to put it in config
{ positions: nextBatch, snapshot_id: snapshotID }, true
);
return res.headersSent ? null : response.data;
// 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
const response = await singleRequest(req, res,
"DELETE",
`/playlists/${playlistID}/tracks`,
{},
// axios delete method doesn't have separate arg for body so hv to put it in config
{ positions: nextBatch, snapshot_id: snapshotID }, true
);
return res.headersSent ? null : response.data;
}
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);
if (res.headersSent) return false;
const checkFromData = await getPlaylistDetailsFirstPage(req, res, checkFields.join(), playlistID);
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
// playlist is editable if it's collaborative (and thus private) or owned by the user
if (checkFromData.collaborative !== true &&
checkFromData.owner.id !== userID) {
res.status(403).send({
message: "You cannot edit this playlist, you must be the owner/the playlist must be collaborative",
playlistID: playlistID
});
logger.info("user cannot edit target playlist", { playlistID: playlistID });
return false;
} else {
return true;
}
// 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
if (checkFromData.collaborative !== true &&
checkFromData.owner.id !== userID) {
res.status(403).send({
message: "You cannot edit this playlist, you must be the owner/the playlist must be collaborative",
playlistID: playlistID
});
logger.info("user cannot edit target playlist", { playlistID: playlistID });
return false;
} else {
return true;
}
}
module.exports = {
singleRequest,
getUserProfile,
getUserPlaylistsFirstPage,
getUserPlaylistsNextPage,
getPlaylistDetailsFirstPage,
getPlaylistDetailsNextPage,
addItemsToPlaylist,
removeItemsFromPlaylist,
checkPlaylistEditable,
}
singleRequest,
getUserProfile,
getUserPlaylistsFirstPage,
getUserPlaylistsNextPage,
getPlaylistDetailsFirstPage,
getPlaylistDetailsNextPage,
addItemsToPlaylist,
removeItemsFromPlaylist,
checkPlaylistEditable,
}

View File

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

View File

@ -3,19 +3,19 @@ const { body, header, param, query } = require("express-validator");
const typedefs = require("../typedefs");
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
const __validator_func = async (req, res, next) => {
await body("field_name")
.notEmpty()
.withMessage("field_name not defined in body")
.run(req);
await body("field_name")
.notEmpty()
.withMessage("field_name not defined in body")
.run(req);
next();
next();
}
module.exports = {
__validator_func
__validator_func
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -24,32 +24,32 @@ app.set("trust proxy", process.env.TRUST_PROXY);
// Configure SQLite store file
const sqliteStore = new SQLiteStore({
table: "session_store",
db: "spotify-manager.db"
table: "session_store",
db: "spotify-manager.db"
});
// Configure session middleware
app.use(session({
name: sessionName,
store: sqliteStore,
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
domain: process.env.BASE_DOMAIN,
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
}
name: sessionName,
store: sqliteStore,
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
domain: process.env.BASE_DOMAIN,
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({
origin: process.env.APP_URI,
credentials: true
origin: process.env.APP_URI,
credentials: true
}));
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");
@ -62,20 +62,20 @@ app.use(express.static(__dirname + "/static"));
// Healthcheck
app.use("/health", (req, res) => {
res.status(200).send({ message: "OK" });
return;
res.status(200).send({ message: "OK" });
return;
});
app.use("/auth-health", isAuthenticated, async (req, res) => {
try {
await getUserProfile(req, res);
if (res.headersSent) return;
res.status(200).send({ message: "OK" });
return;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("authHealthCheck", { error });
return;
}
try {
await getUserProfile(req, res);
if (res.headersSent) return;
res.status(200).send({ message: "OK" });
return;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("authHealthCheck", { error });
return;
}
});
// Routes
@ -85,32 +85,32 @@ app.use("/api/operations", isAuthenticated, require("./routes/operations"));
// Fallbacks
app.use((req, res) => {
res.status(404).send(
"Guess the <a href=\"https://github.com/20kaushik02/spotify-manager\">cat's</a> out of the bag!"
);
logger.info("404", { url: req.url });
return;
res.status(404).send(
"Guess the <a href=\"https://github.com/20kaushik02/spotify-manager\">cat's</a> out of the bag!"
);
logger.info("404", { url: req.url });
return;
});
const port = process.env.PORT || 5000;
const server = app.listen(port, () => {
logger.info(`App Listening on port ${port}`);
logger.info(`App Listening on port ${port}`);
});
const cleanupFunc = (signal) => {
if (signal)
logger.debug(`${signal} signal received, shutting down now...`);
if (signal)
logger.debug(`${signal} signal received, shutting down now...`);
Promise.allSettled([
db.sequelize.close(),
util.promisify(server.close),
]).then(() => {
logger.info("Cleaned up, exiting.");
process.exit(0);
});
Promise.allSettled([
db.sequelize.close(),
util.promisify(server.close),
]).then(() => {
logger.info("Cleaned up, exiting.");
process.exit(0);
});
}
["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM", "SIGUSR1", "SIGUSR2"].forEach((signal) => {
process.on(signal, () => cleanupFunc(signal));
process.on(signal, () => cleanupFunc(signal));
});

View File

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

View File

@ -31,4 +31,4 @@ module.exports = {
async down(queryInterface, Sequelize) {
await queryInterface.dropTable("playlists");
}
};
};

View File

@ -31,4 +31,4 @@ module.exports = {
async down(queryInterface, Sequelize) {
await queryInterface.dropTable("links");
}
};
};

View File

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

View File

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

View File

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

View File

@ -5,15 +5,15 @@ const { getPlaylistDetailsValidator } = require("../validators/playlists");
const { validate } = require("../validators");
router.get(
"/me",
fetchUserPlaylists
"/me",
fetchUserPlaylists
);
router.get(
"/details",
getPlaylistDetailsValidator,
validate,
fetchPlaylistDetails
"/details",
getPlaylistDetailsValidator,
validate,
fetchPlaylistDetails
);
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;
module.exports = {
sleep, randomBool
};
sleep, randomBool
};

View File

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

View File

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

View File

@ -1,23 +1,23 @@
/**
* Stringifies only values of a JSON object, including nested ones
*
*
* @param {any} obj JSON object
* @param {string} delimiter Delimiter of final string
* @returns {string}
*/
const getNestedValuesString = (obj, delimiter = ", ") => {
let values = [];
for (key in obj) {
if (typeof obj[key] !== "object") {
values.push(obj[key]);
} else {
values = values.concat(getNestedValuesString(obj[key]));
}
}
let values = [];
for (key in obj) {
if (typeof obj[key] !== "object") {
values.push(obj[key]);
} else {
values = values.concat(getNestedValuesString(obj[key]));
}
}
return values.join(delimiter);
return values.join(delimiter);
}
module.exports = {
getNestedValuesString
getNestedValuesString
}

View File

@ -6,31 +6,31 @@ const { combine, label, timestamp, printf, errors } = format;
const typedefs = require("../typedefs");
const getLabel = (callingModule) => {
if (!callingModule.filename) return "repl";
const parts = callingModule.filename?.split(path.sep);
return path.join(parts[parts.length - 2], parts.pop());
if (!callingModule.filename) return "repl";
const parts = callingModule.filename?.split(path.sep);
return path.join(parts[parts.length - 2], parts.pop());
};
const allowedErrorKeys = ["name", "code", "message", "stack"];
const metaFormat = (meta) => {
if (Object.keys(meta).length > 0)
return "\n" + JSON.stringify(meta, null, "\t");
return "";
if (Object.keys(meta).length > 0)
return "\n" + JSON.stringify(meta, null, "\t");
return "";
}
const logFormat = printf(({ level, message, label, timestamp, ...meta }) => {
if (meta.error) { // if the error was passed
for (const key in meta.error) {
if (!allowedErrorKeys.includes(key)) {
delete meta.error[key];
}
}
const { stack, ...rest } = meta.error;
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` +
`${stack ?? ""}`;
if (meta.error) { // if the error was passed
for (const key in meta.error) {
if (!allowedErrorKeys.includes(key)) {
delete meta.error[key];
}
}
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
*/
const curriedLogger = (callingModule) => {
let winstonLogger = createLogger({
levels: config.npm.levels,
format: combine(
errors({ stack: true }),
label({ label: getLabel(callingModule) }),
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
logFormat,
),
transports: [
new transports.Console({ level: "info" }),
new transports.File({
filename: __dirname + "/../logs/debug.log",
level: "debug",
maxsize: 10485760,
}),
new transports.File({
filename: __dirname + "/../logs/error.log",
level: "error",
maxsize: 1048576,
}),
]
});
winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error }));
return winstonLogger;
let winstonLogger = createLogger({
levels: config.npm.levels,
format: combine(
errors({ stack: true }),
label({ label: getLabel(callingModule) }),
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
logFormat,
),
transports: [
new transports.Console({ level: "info" }),
new transports.File({
filename: __dirname + "/../logs/debug.log",
level: "debug",
maxsize: 10485760,
}),
new transports.File({
filename: __dirname + "/../logs/error.log",
level: "error",
maxsize: 1048576,
}),
]
});
winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error }));
return winstonLogger;
}
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
*/
const parseSpotifyURI = (uri) => {
const parts = uri.split(":");
const parts = uri.split(":");
if (parts[0] !== "spotify") {
throw new TypeError(`${uri} is not a valid Spotify URI`);
}
if (parts[0] !== "spotify") {
throw new TypeError(`${uri} is not a valid Spotify URI`);
}
let type = parts[1];
let type = parts[1];
if (type === "local") {
// Local file format: spotify:local:<artist>:<album>:<title>:<duration>
let idParts = parts.slice(2);
if (idParts.length < 4) {
throw new TypeError(`${uri} is not a valid local file URI`);
}
if (type === "local") {
// Local file format: spotify:local:<artist>:<album>:<title>:<duration>
let idParts = parts.slice(2);
if (idParts.length < 4) {
throw new TypeError(`${uri} is not a valid local file URI`);
}
// URL decode artist, album, and title
const artist = decodeURIComponent(idParts[0] || "");
const album = decodeURIComponent(idParts[1] || "");
const title = decodeURIComponent(idParts[2]);
const duration = parseInt(idParts[3], 10);
// URL decode artist, album, and title
const artist = decodeURIComponent(idParts[0] || "");
const album = decodeURIComponent(idParts[1] || "");
const title = decodeURIComponent(idParts[2]);
const duration = parseInt(idParts[3], 10);
if (isNaN(duration)) {
throw new TypeError(`${uri} has an invalid duration`);
}
if (isNaN(duration)) {
throw new TypeError(`${uri} has an invalid duration`);
}
return { type: "track", is_local: true, artist, album, title, duration };
} else {
// Not a local file
if (parts.length !== 3) {
throw new TypeError(`${uri} is not a valid Spotify URI`);
}
return { type: "track", is_local: true, artist, album, title, duration };
} else {
// Not a local file
if (parts.length !== 3) {
throw new TypeError(`${uri} is not a valid Spotify URI`);
}
const id = parts[2];
const id = parts[2];
if (!base62Pattern.test(id)) {
throw new TypeError(`${uri} has an invalid ID`);
}
if (!base62Pattern.test(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
*/
const parseSpotifyLink = (link) => {
const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/;
const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/;
if (localPattern.test(link)) {
// Local file format: https://open.spotify.com/local/artist/album/title/duration
const matches = link.match(localPattern);
if (!matches) {
throw new TypeError(`${link} is not a valid Spotify local file link`);
}
if (localPattern.test(link)) {
// Local file format: https://open.spotify.com/local/artist/album/title/duration
const matches = link.match(localPattern);
if (!matches) {
throw new TypeError(`${link} is not a valid Spotify local file link`);
}
// URL decode artist, album, and title
const artist = decodeURIComponent(matches[1] || "");
const album = decodeURIComponent(matches[2] || "");
const title = decodeURIComponent(matches[3]);
const duration = parseInt(matches[4], 10);
// URL decode artist, album, and title
const artist = decodeURIComponent(matches[1] || "");
const album = decodeURIComponent(matches[2] || "");
const title = decodeURIComponent(matches[3]);
const duration = parseInt(matches[4], 10);
if (isNaN(duration)) {
throw new TypeError(`${link} has an invalid duration`);
}
if (isNaN(duration)) {
throw new TypeError(`${link} has an invalid duration`);
}
return { type: "track", is_local: true, artist, album, title, duration };
} else if (standardPattern.test(link)) {
// Not a local file
const matches = link.match(standardPattern);
if (!matches || matches.length < 3) {
throw new TypeError(`${link} is not a valid Spotify link`);
}
return { type: "track", is_local: true, artist, album, title, duration };
} else if (standardPattern.test(link)) {
// Not a local file
const matches = link.match(standardPattern);
if (!matches || matches.length < 3) {
throw new TypeError(`${link} is not a valid Spotify link`);
}
const type = matches[1];
const id = matches[2];
const type = matches[1];
const id = matches[2];
if (!base62Pattern.test(id)) {
throw new TypeError(`${link} has an invalid ID`);
}
if (!base62Pattern.test(id)) {
throw new TypeError(`${link} has an invalid ID`);
}
return { type, is_local: false, id };
} else {
throw new TypeError(`${link} is not a valid Spotify link`);
}
return { type, is_local: false, id };
} else {
throw new TypeError(`${link} is not a valid Spotify link`);
}
}
/**
@ -107,14 +107,14 @@ const parseSpotifyLink = (link) => {
* @returns {string}
*/
const buildSpotifyURI = (uriObj) => {
if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? "");
const title = encodeURIComponent(uriObj.title ?? "");
const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `spotify:local:${artist}:${album}:${title}:${duration}`;
}
return `spotify:${uriObj.type}:${uriObj.id}`;
if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? "");
const title = encodeURIComponent(uriObj.title ?? "");
const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `spotify:local:${artist}:${album}:${title}:${duration}`;
}
return `spotify:${uriObj.type}:${uriObj.id}`;
}
/**
@ -123,19 +123,19 @@ const buildSpotifyURI = (uriObj) => {
* @returns {string}
*/
const buildSpotifyLink = (uriObj) => {
if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? "");
const title = encodeURIComponent(uriObj.title ?? "");
const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`;
}
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`
if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? "");
const title = encodeURIComponent(uriObj.title ?? "");
const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`;
}
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`
}
module.exports = {
parseSpotifyURI,
parseSpotifyLink,
buildSpotifyURI,
buildSpotifyLink
parseSpotifyURI,
parseSpotifyLink,
buildSpotifyURI,
buildSpotifyLink
}

View File

@ -7,40 +7,40 @@ const typedefs = require("../typedefs");
/**
* Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator
*
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
const validate = (req, res, next) => {
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
const extractedErrors = [];
errors.array().forEach(err => {
if (err.type === "alternative") {
err.nestedErrors.forEach(nestedErr => {
extractedErrors.push({
[nestedErr.path]: nestedErr.msg
});
});
} else if (err.type === "field") {
extractedErrors.push({
[err.path]: err.msg
});
}
});
const extractedErrors = [];
errors.array().forEach(err => {
if (err.type === "alternative") {
err.nestedErrors.forEach(nestedErr => {
extractedErrors.push({
[nestedErr.path]: nestedErr.msg
});
});
} else if (err.type === "field") {
extractedErrors.push({
[err.path]: err.msg
});
}
});
res.status(400).json({
message: getNestedValuesString(extractedErrors),
errors: extractedErrors
});
logger.warn("invalid request", { extractedErrors });
return;
res.status(400).json({
message: getNestedValuesString(extractedErrors),
errors: extractedErrors
});
logger.warn("invalid request", { extractedErrors });
return;
}
module.exports = {
validate
validate
};

View File

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

View File

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