mirror of
https://github.com/20kaushik02/spotify-manager.git
synced 2025-12-06 06:34:06 +00:00
editorconfig
This commit is contained in:
parent
481d6fd48d
commit
f75988fa3a
25
.editorconfig
Normal file
25
.editorconfig
Normal 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
|
||||
@ -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")
|
||||
};
|
||||
|
||||
68
api/axios.js
68
api/axios.js
@ -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
|
||||
};
|
||||
|
||||
226
api/spotify.js
226
api/spotify.js
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
30
constants.js
30
constants.js
@ -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
|
||||
};
|
||||
|
||||
@ -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
@ -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
|
||||
};
|
||||
|
||||
90
index.js
90
index.js
@ -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));
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -31,4 +31,4 @@ module.exports = {
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable("playlists");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -31,4 +31,4 @@ module.exports = {
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable("links");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
134
utils/graph.js
134
utils/graph.js
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user