mirror of
https://github.com/20kaushik02/spotify-manager.git
synced 2025-12-06 06:34:06 +00:00
MASSIVE commit
- moved to typescript - axios rate limitmodule is busted, removed for now, do something else for that - sequelize-typescript - dotenv, not dotenv-flow - removed playlist details route types for API ton of minor fixes and improvements
This commit is contained in:
parent
bcc39d5f38
commit
a74ffc453e
4
.env
4
.env
@ -1,5 +1,5 @@
|
|||||||
CLIENT_ID = your_client_id_here
|
CLIENT_ID = your_spotify_client_id_here
|
||||||
CLIENT_SECRET = your_client_secret_here
|
CLIENT_SECRET = your_spotify_client_secret_here
|
||||||
SESSION_SECRET = 'your_session_secret_string_here'
|
SESSION_SECRET = 'your_session_secret_string_here'
|
||||||
PORT = 9001
|
PORT = 9001
|
||||||
TRUST_PROXY = 1
|
TRUST_PROXY = 1
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
BASE_DOMAIN = 127.0.0.1
|
BASE_DOMAIN = 127.0.0.1
|
||||||
REDIRECT_URI = http://127.0.0.1:9001/api/auth/callback
|
REDIRECT_URI = http://127.0.0.1:9001/api/auth/callback
|
||||||
APP_URI = http://127.0.0.1:3000
|
APP_URI = http://127.0.0.1:3000
|
||||||
DB_USER = your_database_username
|
DB_URI = postgres://your_database_username:your_database_password@127.0.0.1:your_database_port/your_database_name
|
||||||
DB_PASSWD = your_database_password
|
|
||||||
DB_NAME = your_database_name
|
|
||||||
DB_HOST = 127.0.0.1
|
|
||||||
DB_PORT = your_database_port
|
|
||||||
REDIS_HOST = 127.0.0.1
|
REDIS_HOST = 127.0.0.1
|
||||||
REDIS_PORT = 6379
|
REDIS_PORT = 6379
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -105,3 +105,7 @@ dist
|
|||||||
|
|
||||||
# SQLite db
|
# SQLite db
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
/tsout
|
||||||
|
|||||||
@ -3,5 +3,5 @@ dotenvFlow.config();
|
|||||||
|
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
export default {
|
export default {
|
||||||
"config": resolve("config", "sequelize.js")
|
config: resolve("config", "sequelize.ts"),
|
||||||
};
|
};
|
||||||
|
|||||||
53
api/axios.js
53
api/axios.js
@ -1,53 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import rateLimit from "axios-rate-limit";
|
|
||||||
|
|
||||||
import { baseAPIURL, accountsAPIURL } from "../constants.js";
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
export 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"))
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const uncappedAxiosInstance = axios.create({
|
|
||||||
baseURL: baseAPIURL,
|
|
||||||
timeout: 20000,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const axiosInstance = rateLimit(uncappedAxiosInstance, {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
54
api/axios.ts
Normal file
54
api/axios.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// TODO: rate limit module is busted (CJS types), do something for rate limiting
|
||||||
|
import axios, { type AxiosInstance } from "axios";
|
||||||
|
import { baseAPIURL, accountsAPIURL } from "../constants.ts";
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
const authInstance: AxiosInstance = 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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const axiosInstance: AxiosInstance = axios.create({
|
||||||
|
baseURL: baseAPIURL,
|
||||||
|
timeout: 20000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export { authInstance, axiosInstance };
|
||||||
153
api/spotify.js
153
api/spotify.js
@ -1,153 +0,0 @@
|
|||||||
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
import { axiosInstance } from "./axios.js";
|
|
||||||
|
|
||||||
const logPrefix = "Spotify API: ";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spotify API - one-off request handler
|
|
||||||
* @param {typedefs.Req} req convenient auto-placing headers from middleware (not a good approach?)
|
|
||||||
* @param {typedefs.Res} res handle failure responses here itself (not a good approach?)
|
|
||||||
* @param {import("axios").Method} method HTTP method
|
|
||||||
* @param {string} path request path
|
|
||||||
* @param {import("axios").AxiosRequestConfig} config request params, headers, etc.
|
|
||||||
* @param {any} data request body
|
|
||||||
* @param {boolean} inlineData true if data is to be placed inside config
|
|
||||||
*/
|
|
||||||
export 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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getUserPlaylistsNextPage = async (req, res, nextURL) => {
|
|
||||||
const response = await singleRequest(
|
|
||||||
req, res, "GET", nextURL);
|
|
||||||
return res.headersSent ? null : response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPlaylistDetailsNextPage = async (req, res, nextURL) => {
|
|
||||||
const response = await singleRequest(
|
|
||||||
req, res, "GET", nextURL);
|
|
||||||
return res.headersSent ? null : response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkPlaylistEditable = async (req, res, playlistID, userID) => {
|
|
||||||
let checkFields = ["collaborative", "owner(id)"];
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
286
api/spotify.ts
Normal file
286
api/spotify.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { axiosInstance } from "./axios.ts";
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
|
||||||
|
import { type AxiosResponse, type AxiosRequestConfig } from "axios";
|
||||||
|
import type {
|
||||||
|
AddItemsToPlaylist,
|
||||||
|
EndpointHandlerBaseArgs,
|
||||||
|
GetCurrentUsersPlaylists,
|
||||||
|
GetCurrentUsersProfile,
|
||||||
|
GetPlaylist,
|
||||||
|
GetPlaylistItems,
|
||||||
|
RemovePlaylistItems,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from "spotify_manager/index.d.ts";
|
||||||
|
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
const logPrefix = "Spotify API: ";
|
||||||
|
enum allowedMethods {
|
||||||
|
Get = "get",
|
||||||
|
Post = "post",
|
||||||
|
Put = "put",
|
||||||
|
Delete = "delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify API - one-off request handler
|
||||||
|
* @param req convenient auto-placing headers from middleware (not a good approach?)
|
||||||
|
* @param res handle failure responses here itself (not a good approach?)
|
||||||
|
* @param method HTTP method
|
||||||
|
* @param path request path
|
||||||
|
* @param config request params, headers, etc.
|
||||||
|
* @param data request body
|
||||||
|
* @param inlineData true if `data` is to be placed inside config (say, axios' delete method)
|
||||||
|
*/
|
||||||
|
const singleRequest = async <RespDataType>(
|
||||||
|
req: Req,
|
||||||
|
res: Res,
|
||||||
|
method: allowedMethods,
|
||||||
|
path: string,
|
||||||
|
config: AxiosRequestConfig = {},
|
||||||
|
data: any = null,
|
||||||
|
inlineData: boolean = false
|
||||||
|
): Promise<AxiosResponse<RespDataType, any> | null> => {
|
||||||
|
let resp: AxiosResponse<RespDataType, any>;
|
||||||
|
config.headers = { ...config.headers, ...req.session.authHeaders };
|
||||||
|
try {
|
||||||
|
if (!data || inlineData) {
|
||||||
|
if (data) config.data = data ?? null;
|
||||||
|
resp = await axiosInstance[method](path, config);
|
||||||
|
} else {
|
||||||
|
resp = await axiosInstance[method](path, data, config);
|
||||||
|
}
|
||||||
|
logger.debug(logPrefix + "Successful response received.");
|
||||||
|
return resp;
|
||||||
|
} catch (error: any) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetCurrentUsersProfileArgs extends EndpointHandlerBaseArgs {}
|
||||||
|
const getCurrentUsersProfile: (
|
||||||
|
opts: GetCurrentUsersProfileArgs
|
||||||
|
) => Promise<GetCurrentUsersProfile | null> = async ({ req, res }) => {
|
||||||
|
const response = await singleRequest<GetCurrentUsersProfile>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.Get,
|
||||||
|
"/me",
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${req.session.accessToken}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response ? response.data : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetCurrentUsersPlaylistsFirstPageArgs
|
||||||
|
extends EndpointHandlerBaseArgs {}
|
||||||
|
const getCurrentUsersPlaylistsFirstPage: (
|
||||||
|
opts: GetCurrentUsersPlaylistsFirstPageArgs
|
||||||
|
) => Promise<GetCurrentUsersPlaylists | null> = async ({ req, res }) => {
|
||||||
|
const response = await singleRequest<GetCurrentUsersPlaylists>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.Get,
|
||||||
|
`/me/playlists`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
offset: 0,
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetCurrentUsersPlaylistsNextPageArgs extends EndpointHandlerBaseArgs {
|
||||||
|
nextURL: string;
|
||||||
|
}
|
||||||
|
const getCurrentUsersPlaylistsNextPage: (
|
||||||
|
opts: GetCurrentUsersPlaylistsNextPageArgs
|
||||||
|
) => Promise<GetCurrentUsersPlaylists | null> = async ({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
nextURL,
|
||||||
|
}) => {
|
||||||
|
const response = await singleRequest<GetCurrentUsersPlaylists>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.Get,
|
||||||
|
nextURL
|
||||||
|
);
|
||||||
|
return response?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs {
|
||||||
|
initialFields: string;
|
||||||
|
playlistID: string;
|
||||||
|
}
|
||||||
|
const getPlaylistDetailsFirstPage: (
|
||||||
|
opts: GetPlaylistDetailsFirstPageArgs
|
||||||
|
) => Promise<GetPlaylist | null> = async ({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
initialFields,
|
||||||
|
playlistID,
|
||||||
|
}) => {
|
||||||
|
const response = await singleRequest<GetPlaylist>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.Get,
|
||||||
|
`/playlists/${playlistID}/`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
fields: initialFields,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerBaseArgs {
|
||||||
|
nextURL: string;
|
||||||
|
}
|
||||||
|
const getPlaylistDetailsNextPage: (
|
||||||
|
opts: GetPlaylistDetailsNextPageArgs
|
||||||
|
) => Promise<GetPlaylistItems | null> = async ({ req, res, nextURL }) => {
|
||||||
|
const response = await singleRequest<GetPlaylistItems>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.Get,
|
||||||
|
nextURL
|
||||||
|
);
|
||||||
|
return response?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs {
|
||||||
|
nextBatch: string[];
|
||||||
|
playlistID: string;
|
||||||
|
}
|
||||||
|
const addItemsToPlaylist: (
|
||||||
|
opts: AddItemsToPlaylistArgs
|
||||||
|
) => Promise<AddItemsToPlaylist | null> = async ({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
nextBatch,
|
||||||
|
playlistID,
|
||||||
|
}) => {
|
||||||
|
const response = await singleRequest<AddItemsToPlaylist>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.Post,
|
||||||
|
`/playlists/${playlistID}/tracks`,
|
||||||
|
{},
|
||||||
|
{ uris: nextBatch },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
return response?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
|
||||||
|
nextBatch: string[] | number[]; // see note below
|
||||||
|
playlistID: string;
|
||||||
|
snapshotID: string;
|
||||||
|
}
|
||||||
|
const removePlaylistItems: (
|
||||||
|
opts: RemovePlaylistItemsArgs
|
||||||
|
) => Promise<RemovePlaylistItems | null> = 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<RemovePlaylistItems>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
allowedMethods.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 response?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------
|
||||||
|
// non-endpoints, i.e. convenience wrappers
|
||||||
|
// ---------
|
||||||
|
|
||||||
|
interface CheckPlaylistEditableArgs extends EndpointHandlerBaseArgs {
|
||||||
|
playlistID: string;
|
||||||
|
userID: string;
|
||||||
|
}
|
||||||
|
const checkPlaylistEditable: (
|
||||||
|
opts: CheckPlaylistEditableArgs
|
||||||
|
) => Promise<boolean> = async ({ req, res, playlistID, userID }) => {
|
||||||
|
let checkFields = ["collaborative", "owner(id)"];
|
||||||
|
|
||||||
|
const checkFromData = await getPlaylistDetailsFirstPage({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
initialFields: checkFields.join(),
|
||||||
|
playlistID,
|
||||||
|
});
|
||||||
|
if (!checkFromData) 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,
|
||||||
|
});
|
||||||
|
logger.info("user cannot edit target playlist", { playlistID });
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
singleRequest,
|
||||||
|
getCurrentUsersProfile,
|
||||||
|
getCurrentUsersPlaylistsFirstPage,
|
||||||
|
getCurrentUsersPlaylistsNextPage,
|
||||||
|
getPlaylistDetailsFirstPage,
|
||||||
|
getPlaylistDetailsNextPage,
|
||||||
|
addItemsToPlaylist,
|
||||||
|
removePlaylistItems,
|
||||||
|
checkPlaylistEditable,
|
||||||
|
};
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
|
||||||
export const __controller_func = async (req, res) => {
|
|
||||||
try {
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
|
||||||
logger.error("__controller_func", { error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
boilerplates/controller.ts
Normal file
16
boilerplates/controller.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
const __controller_func: RequestHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
|
logger.error("__controller_func", { error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { __controller_func };
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
import { validate } from "../validators/index.js";
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
10
boilerplates/route.ts
Normal file
10
boilerplates/route.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
import { validate } from "../validators/index.ts";
|
||||||
|
|
||||||
|
router.get("");
|
||||||
|
|
||||||
|
router.post("");
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { body, header, param, query } from "express-validator";
|
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {typedefs.Next} next
|
|
||||||
*/
|
|
||||||
export const __validator_func = async (req, res, next) => {
|
|
||||||
await body("field_name")
|
|
||||||
.notEmpty()
|
|
||||||
.withMessage("field_name not defined in body")
|
|
||||||
.run(req);
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
13
boilerplates/validator.ts
Normal file
13
boilerplates/validator.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import { body, header, param, query } from "express-validator";
|
||||||
|
|
||||||
|
const __validator_func: RequestHandler = async (req, _res, next) => {
|
||||||
|
await body("field_name")
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage("field_name not defined in body")
|
||||||
|
.run(req);
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { __validator_func };
|
||||||
@ -1,3 +0,0 @@
|
|||||||
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
|
|
||||||
import DotenvFlow from "dotenv-flow";
|
|
||||||
export default DotenvFlow.config();
|
|
||||||
18
config/dotenv.ts
Normal file
18
config/dotenv.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
|
||||||
|
// explanation: ESM import statements execute first
|
||||||
|
// so the .config gets called after all other imports in index.ts
|
||||||
|
// and one of those imports is the sequelize loader, which depends on env being loaded
|
||||||
|
// soln: raise the priority of dotenv to match by placing it in a separate module like this
|
||||||
|
|
||||||
|
import { config, type DotenvConfigOutput } from "dotenv";
|
||||||
|
|
||||||
|
const result: DotenvConfigOutput = config({
|
||||||
|
path: [
|
||||||
|
`.env.${process.env["NODE_ENV"]}.local`,
|
||||||
|
`.env.${process.env["NODE_ENV"]}`,
|
||||||
|
".env.local",
|
||||||
|
".env",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default result;
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connConfigs;
|
|
||||||
25
config/sequelize.ts
Normal file
25
config/sequelize.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { SequelizeOptions } from "sequelize-typescript";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
type ConnConfigs = Record<string, SequelizeOptions>;
|
||||||
|
|
||||||
|
// env-specific config
|
||||||
|
const connConfigs: ConnConfigs = {
|
||||||
|
development: {},
|
||||||
|
test: {},
|
||||||
|
production: {
|
||||||
|
// dialectOptions: {
|
||||||
|
// ssl: true,
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// common config
|
||||||
|
for (const conf in connConfigs) {
|
||||||
|
connConfigs[conf]!.logging = (msg: any) => logger.debug(msg);
|
||||||
|
connConfigs[conf]!.dialect = "postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connConfigs;
|
||||||
17
constants.js
17
constants.js
@ -1,17 +0,0 @@
|
|||||||
export const accountsAPIURL = "https://accounts.spotify.com";
|
|
||||||
export const baseAPIURL = "https://api.spotify.com/v1";
|
|
||||||
export const sessionName = "spotify-manager";
|
|
||||||
export const stateKey = "spotify_auth_state";
|
|
||||||
|
|
||||||
export 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",
|
|
||||||
};
|
|
||||||
16
constants.ts
Normal file
16
constants.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const accountsAPIURL = "https://accounts.spotify.com";
|
||||||
|
const baseAPIURL = "https://api.spotify.com/v1";
|
||||||
|
const sessionName = "spotify-manager";
|
||||||
|
const stateKey = "spotify_auth_state";
|
||||||
|
|
||||||
|
const requiredScopes = {
|
||||||
|
// Playlists
|
||||||
|
GetCollaborativePlaylists: "playlist-read-collaborative",
|
||||||
|
GetPrivatePlaylists: "playlist-read-private",
|
||||||
|
ModifyPrivatePlaylists: "playlist-modify-private",
|
||||||
|
ModifyPublicPlaylists: "playlist-modify-public",
|
||||||
|
// User
|
||||||
|
AccessUser: "user-read-private",
|
||||||
|
};
|
||||||
|
|
||||||
|
export { accountsAPIURL, baseAPIURL, sessionName, stateKey, requiredScopes };
|
||||||
@ -1,48 +1,51 @@
|
|||||||
import { authInstance } from "../api/axios.js";
|
import { authInstance } from "../api/axios.ts";
|
||||||
|
import { getCurrentUsersProfile } from "../api/spotify.ts";
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
import {
|
||||||
import { scopes, stateKey, accountsAPIURL, sessionName } from "../constants.js";
|
requiredScopes,
|
||||||
|
stateKey,
|
||||||
|
accountsAPIURL,
|
||||||
|
sessionName,
|
||||||
|
} from "../constants.ts";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
import generateRandString from "../utils/generateRandString.js";
|
import { generateRandString } from "../utils/generateRandString.ts";
|
||||||
import { getUserProfile } from "../api/spotify.js";
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
import curriedLogger from "../utils/logger.ts";
|
||||||
const logger = curriedLogger(import.meta);
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful redirect to Spotify login with credentials
|
* Stateful redirect to Spotify login with credentials
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const login = (_req, res) => {
|
const login: RequestHandler = async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = generateRandString(16);
|
const state = generateRandString(16);
|
||||||
res.cookie(stateKey, state);
|
res.cookie(stateKey, state);
|
||||||
|
|
||||||
const scope = Object.values(scopes).join(" ");
|
const scope = Object.values(requiredScopes).join(" ");
|
||||||
|
|
||||||
res.redirect(
|
res.redirect(
|
||||||
`${accountsAPIURL}/authorize?` +
|
`${accountsAPIURL}/authorize?` +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
client_id: process.env.CLIENT_ID,
|
client_id: process.env["CLIENT_ID"],
|
||||||
scope: scope,
|
scope: scope,
|
||||||
redirect_uri: process.env.REDIRECT_URI,
|
redirect_uri: process.env["REDIRECT_URI"],
|
||||||
state: state
|
state: state,
|
||||||
}).toString()
|
} as Record<string, string>).toString()
|
||||||
);
|
);
|
||||||
return;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("login", { error });
|
logger.error("login", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange authorization code for refresh and access tokens
|
* Exchange authorization code for refresh and access tokens
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const callback = async (req, res) => {
|
const callback: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code, state, error } = req.query;
|
const { code, state, error } = req.query;
|
||||||
const storedState = req.cookies ? req.cookies[stateKey] : null;
|
const storedState = req.cookies ? req.cookies[stateKey] : null;
|
||||||
@ -51,22 +54,22 @@ export const callback = async (req, res) => {
|
|||||||
if (state === null || state !== storedState) {
|
if (state === null || state !== storedState) {
|
||||||
res.status(409).send({ message: "Invalid state" });
|
res.status(409).send({ message: "Invalid state" });
|
||||||
logger.warn("state mismatch");
|
logger.warn("state mismatch");
|
||||||
return;
|
return null;
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
res.status(401).send({ message: "Auth callback error" });
|
res.status(401).send({ message: "Auth callback error" });
|
||||||
logger.error("callback error", { error });
|
logger.error("callback error", { error });
|
||||||
return;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
// get auth tokens
|
// get auth tokens
|
||||||
res.clearCookie(stateKey);
|
res.clearCookie(stateKey);
|
||||||
|
|
||||||
const authForm = {
|
const authForm = {
|
||||||
code: code,
|
code: code,
|
||||||
redirect_uri: process.env.REDIRECT_URI,
|
redirect_uri: process.env["REDIRECT_URI"],
|
||||||
grant_type: "authorization_code"
|
grant_type: "authorization_code",
|
||||||
}
|
} as Record<string, string>;
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -76,88 +79,96 @@ export const callback = async (req, res) => {
|
|||||||
req.session.refreshToken = tokenResponse.data.refresh_token;
|
req.session.refreshToken = tokenResponse.data.refresh_token;
|
||||||
} else {
|
} else {
|
||||||
logger.error("login failed", { statusCode: tokenResponse.status });
|
logger.error("login failed", { statusCode: tokenResponse.status });
|
||||||
res.status(tokenResponse.status).send({ message: "Error: Login failed" });
|
res
|
||||||
|
.status(tokenResponse.status)
|
||||||
|
.send({ message: "Error: Login failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData = await getUserProfile(req, res);
|
const userData = await getCurrentUsersProfile({ req, res });
|
||||||
if (res.headersSent) return;
|
if (!userData) return null;
|
||||||
|
|
||||||
/** @type {typedefs.User} */
|
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
username: userData.display_name,
|
username: userData.display_name ?? "",
|
||||||
id: userData.id,
|
id: userData.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
// res.status(200).send({ message: "OK" });
|
// res.status(200).send({ message: "OK" });
|
||||||
res.redirect(process.env.APP_URI + "?login=success");
|
res.redirect(process.env["APP_URI"] + "?login=success");
|
||||||
logger.debug("New login.", { username: userData.display_name });
|
logger.debug("New login.", { username: userData.display_name });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("callback", { error });
|
logger.error("callback", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request new access token using refresh token
|
* Request new access token using refresh token
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const refresh = async (req, res) => {
|
const refresh: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const authForm = {
|
const authForm = {
|
||||||
refresh_token: req.session.refreshToken,
|
refresh_token: req.session.refreshToken ?? "",
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
}
|
};
|
||||||
|
|
||||||
const authPayload = (new URLSearchParams(authForm)).toString();
|
const authPayload = new URLSearchParams(authForm).toString();
|
||||||
|
|
||||||
|
// TODO: types for this and other auth endpoints... but is it necessary?
|
||||||
const response = await authInstance.post("/api/token", authPayload);
|
const response = await authInstance.post("/api/token", authPayload);
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
req.session.accessToken = response.data.access_token;
|
req.session.accessToken = response.data.access_token;
|
||||||
req.session.refreshToken = response.data.refresh_token ?? req.session.refreshToken; // refresh token rotation
|
req.session.refreshToken =
|
||||||
|
response.data.refresh_token ?? req.session.refreshToken; // refresh token rotation
|
||||||
|
|
||||||
res.status(200).send({ message: "OK" });
|
res.status(200).send({ message: "OK" });
|
||||||
logger.debug(`Access token refreshed${(response.data.refresh_token !== null) ? " and refresh token updated" : ""}.`);
|
logger.debug(
|
||||||
return;
|
`Access token refreshed${
|
||||||
|
response.data.refresh_token !== null
|
||||||
|
? " and refresh token updated"
|
||||||
|
: ""
|
||||||
|
}.`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
res.status(response.status).send({ message: "Error: Refresh token flow failed." });
|
res
|
||||||
|
.status(response.status)
|
||||||
|
.send({ message: "Error: Refresh token flow failed." });
|
||||||
logger.error("refresh failed", { statusCode: response.status });
|
logger.error("refresh failed", { statusCode: response.status });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("refresh", { error });
|
logger.error("refresh", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear session
|
* Clear session
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const logout = async (req, res) => {
|
const logout: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const delSession = req.session.destroy((error) => {
|
const delSession = req.session.destroy((error) => {
|
||||||
if (Object.keys(error).length) {
|
if (Object.keys(error).length) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("Error while logging out", { error });
|
logger.error("Error while logging out", { error });
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
res.clearCookie(sessionName);
|
res.clearCookie(sessionName);
|
||||||
// res.status(200).send({ message: "OK" });
|
// res.status(200).send({ message: "OK" });
|
||||||
res.redirect(process.env.APP_URI + "?logout=success");
|
res.redirect(process.env["APP_URI"] + "?logout=success");
|
||||||
logger.debug("Logged out.", { sessionID: delSession.id });
|
logger.debug("Logged out.", { sessionID: delSession.id });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("logout", { error });
|
logger.error("logout", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { login, callback, refresh, logout };
|
||||||
@ -1,52 +1,73 @@
|
|||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
import { getUserPlaylistsFirstPage, getUserPlaylistsNextPage, getPlaylistDetailsFirstPage, getPlaylistDetailsNextPage, addItemsToPlaylist, removeItemsFromPlaylist, checkPlaylistEditable } from "../api/spotify.js";
|
|
||||||
|
|
||||||
import { parseSpotifyLink } from "../utils/spotifyURITransformer.js";
|
|
||||||
import { randomBool, sleep } from "../utils/flake.js";
|
|
||||||
import myGraph from "../utils/graph.js";
|
|
||||||
|
|
||||||
import { Op } from "sequelize";
|
import { Op } from "sequelize";
|
||||||
|
|
||||||
import models, { sequelize } from "../models/index.js";
|
import {
|
||||||
const Playlists = models.playlists;
|
getCurrentUsersPlaylistsFirstPage,
|
||||||
const Links = models.links;
|
getCurrentUsersPlaylistsNextPage,
|
||||||
|
getPlaylistDetailsFirstPage,
|
||||||
|
getPlaylistDetailsNextPage,
|
||||||
|
addItemsToPlaylist,
|
||||||
|
removePlaylistItems,
|
||||||
|
checkPlaylistEditable,
|
||||||
|
} from "../api/spotify.ts";
|
||||||
|
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import type {
|
||||||
|
EndpointHandlerBaseArgs,
|
||||||
|
LinkModel_Edge,
|
||||||
|
PlaylistModel_Pl,
|
||||||
|
URIObject,
|
||||||
|
} from "spotify_manager/index.d.ts";
|
||||||
|
|
||||||
|
import seqConn from "../models/index.ts";
|
||||||
|
|
||||||
|
import myGraph from "../utils/graph.ts";
|
||||||
|
import { parseSpotifyLink } from "../utils/spotifyUriTransformer.ts";
|
||||||
|
// import { randomBool, sleep } from "../utils/flake.ts";
|
||||||
|
|
||||||
|
// load db models
|
||||||
|
import Playlists from "../models/playlists.ts";
|
||||||
|
import Links from "../models/links.ts";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync user's Spotify data
|
* Sync user's Spotify data
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const updateUser = async (req, res) => {
|
const updateUser: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let currentPlaylists = [];
|
let currentPlaylists: PlaylistModel_Pl[] = [];
|
||||||
|
if (!req.session.user)
|
||||||
|
throw new ReferenceError("sessionData does not have user object");
|
||||||
const uID = req.session.user.id;
|
const uID = req.session.user.id;
|
||||||
|
|
||||||
// get first 50
|
// get first 50
|
||||||
const respData = await getUserPlaylistsFirstPage(req, res);
|
const respData = await getCurrentUsersPlaylistsFirstPage({ req, res });
|
||||||
if (res.headersSent) return;
|
if (!respData) return null;
|
||||||
|
|
||||||
currentPlaylists = respData.items.map(playlist => {
|
currentPlaylists = respData.items.map((playlist) => {
|
||||||
return {
|
return {
|
||||||
playlistID: playlist.id,
|
playlistID: playlist.id,
|
||||||
playlistName: playlist.name
|
playlistName: playlist.name,
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
let nextURL = respData.next;
|
let nextURL = respData.next;
|
||||||
|
|
||||||
// keep getting batches of 50 till exhausted
|
// keep getting batches of 50 till exhausted
|
||||||
while (nextURL) {
|
while (nextURL) {
|
||||||
const nextData = await getUserPlaylistsNextPage(req, res, nextURL);
|
const nextData = await getCurrentUsersPlaylistsNextPage({
|
||||||
if (res.headersSent) return;
|
req,
|
||||||
|
res,
|
||||||
|
nextURL,
|
||||||
|
});
|
||||||
|
if (!nextData) return null;
|
||||||
|
|
||||||
currentPlaylists.push(
|
currentPlaylists.push(
|
||||||
...nextData.items.map(playlist => {
|
...nextData.items.map((playlist) => {
|
||||||
return {
|
return {
|
||||||
playlistID: playlist.id,
|
playlistID: playlist.id,
|
||||||
playlistName: playlist.name
|
playlistName: playlist.name,
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -57,17 +78,20 @@ export const updateUser = async (req, res) => {
|
|||||||
attributes: ["playlistID", "playlistName"],
|
attributes: ["playlistID", "playlistName"],
|
||||||
raw: true,
|
raw: true,
|
||||||
where: {
|
where: {
|
||||||
userID: uID
|
userID: uID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleted = [];
|
const deleted: PlaylistModel_Pl[] = [];
|
||||||
const added = [];
|
const added: PlaylistModel_Pl[] = [];
|
||||||
const renamed = [];
|
const renamed: { playlistID: string; oldName: string; newName: string }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
if (oldPlaylists.length) {
|
if (oldPlaylists.length) {
|
||||||
const oldMap = new Map(oldPlaylists.map((p) => [p.playlistID, p]));
|
const oldMap = new Map(oldPlaylists.map((p) => [p.playlistID, p]));
|
||||||
const currentMap = new Map(currentPlaylists.map((p) => [p.playlistID, p]));
|
const currentMap = new Map(
|
||||||
|
currentPlaylists.map((p) => [p.playlistID, p])
|
||||||
|
);
|
||||||
|
|
||||||
// Check for added and renamed playlists
|
// Check for added and renamed playlists
|
||||||
currentPlaylists.forEach((pl) => {
|
currentPlaylists.forEach((pl) => {
|
||||||
@ -96,9 +120,12 @@ export const updateUser = async (req, res) => {
|
|||||||
added.push(...currentPlaylists);
|
added.push(...currentPlaylists);
|
||||||
}
|
}
|
||||||
|
|
||||||
let removedLinks = 0, delNum = 0, updateNum = 0, addPls = [];
|
let removedLinks = 0,
|
||||||
|
delNum = 0,
|
||||||
|
updateNum = 0,
|
||||||
|
addPls = [];
|
||||||
|
|
||||||
const deletedIDs = deleted.map(pl => pl.playlistID);
|
const deletedIDs = deleted.map((pl) => pl.playlistID);
|
||||||
if (deleted.length) {
|
if (deleted.length) {
|
||||||
// clean up any links dependent on the playlists
|
// clean up any links dependent on the playlists
|
||||||
removedLinks = await Links.destroy({
|
removedLinks = await Links.destroy({
|
||||||
@ -109,87 +136,94 @@ export const updateUser = async (req, res) => {
|
|||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ from: { [Op.in]: deletedIDs } },
|
{ from: { [Op.in]: deletedIDs } },
|
||||||
{ to: { [Op.in]: deletedIDs } },
|
{ to: { [Op.in]: deletedIDs } },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// only then remove
|
// only then remove
|
||||||
delNum = await Playlists.destroy({
|
delNum = await Playlists.destroy({
|
||||||
where: { playlistID: deletedIDs, userID: uID }
|
where: { playlistID: deletedIDs, userID: uID },
|
||||||
});
|
});
|
||||||
if (delNum !== deleted.length) {
|
if (delNum !== deleted.length) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") });
|
logger.error("Could not remove all old playlists", {
|
||||||
return;
|
error: new Error("Playlists.destroy failed?"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added.length) {
|
if (added.length) {
|
||||||
addPls = await Playlists.bulkCreate(
|
addPls = await Playlists.bulkCreate(
|
||||||
added.map(pl => { return { ...pl, userID: uID } }),
|
added.map((pl) => {
|
||||||
|
return { ...pl, userID: uID };
|
||||||
|
}),
|
||||||
{ validate: true }
|
{ validate: true }
|
||||||
);
|
);
|
||||||
if (addPls.length !== added.length) {
|
if (addPls.length !== added.length) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("Could not add all new playlists", { error: new Error("Playlists.bulkCreate failed?") });
|
logger.error("Could not add all new playlists", {
|
||||||
return;
|
error: new Error("Playlists.bulkCreate failed?"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction = await sequelize.transaction();
|
|
||||||
try {
|
try {
|
||||||
for (const { playlistID, newName } of renamed) {
|
await seqConn.transaction(async (transaction) => {
|
||||||
const updateRes = await Playlists.update(
|
for (const { playlistID, newName } of renamed) {
|
||||||
{ playlistName: newName },
|
const updateRes = await Playlists.update(
|
||||||
{ where: { playlistID, userID: uID } },
|
{ playlistName: newName },
|
||||||
{ transaction }
|
{ where: { playlistID, userID: uID }, transaction }
|
||||||
);
|
);
|
||||||
updateNum += Number(updateRes[0]);
|
updateNum += Number(updateRes[0]);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("Could not update playlist names", { error: new Error("Playlists.update failed?") });
|
logger.error("Could not update playlist names", {
|
||||||
return;
|
error: new Error("Playlists.update failed?"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send({ message: "Updated user data.", removedLinks: removedLinks > 0 });
|
res
|
||||||
|
.status(200)
|
||||||
|
.send({ message: "Updated user data.", removedLinks: removedLinks > 0 });
|
||||||
logger.debug("Updated user data", {
|
logger.debug("Updated user data", {
|
||||||
delLinks: removedLinks,
|
delLinks: removedLinks,
|
||||||
delPls: delNum,
|
delPls: delNum,
|
||||||
addPls: addPls.length,
|
addPls: addPls.length,
|
||||||
updatedPls: updateNum
|
updatedPls: updateNum,
|
||||||
});
|
});
|
||||||
return;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("updateUser", { error });
|
logger.error("updateUser", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch user's stored playlists and links
|
* Fetch user's stored playlists and links
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const fetchUser = async (req, res) => {
|
const fetchUser: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// if (randomBool(0.5)) {
|
// if (randomBool(0.5)) {
|
||||||
// res.status(404).send({ message: "Not Found" });
|
// res.status(404).send({ message: "Not Found" });
|
||||||
// return;
|
// return null;
|
||||||
// }
|
// }
|
||||||
|
if (!req.session.user)
|
||||||
|
throw new ReferenceError("sessionData does not have user object");
|
||||||
const uID = req.session.user.id;
|
const uID = req.session.user.id;
|
||||||
|
|
||||||
const currentPlaylists = await Playlists.findAll({
|
const currentPlaylists = await Playlists.findAll({
|
||||||
attributes: ["playlistID", "playlistName"],
|
attributes: ["playlistID", "playlistName"],
|
||||||
raw: true,
|
raw: true,
|
||||||
where: {
|
where: {
|
||||||
userID: uID
|
userID: uID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -197,31 +231,34 @@ export const fetchUser = async (req, res) => {
|
|||||||
attributes: ["from", "to"],
|
attributes: ["from", "to"],
|
||||||
raw: true,
|
raw: true,
|
||||||
where: {
|
where: {
|
||||||
userID: uID
|
userID: uID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).send({
|
res.status(200).send({
|
||||||
playlists: currentPlaylists,
|
playlists: currentPlaylists,
|
||||||
links: currentLinks
|
links: currentLinks,
|
||||||
});
|
});
|
||||||
logger.debug("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length });
|
logger.debug("Fetched user data", {
|
||||||
return;
|
pls: currentPlaylists.length,
|
||||||
|
links: currentLinks.length,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("fetchUser", { error });
|
logger.error("fetchUser", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create link between playlists!
|
* Create link between playlists!
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
*/
|
||||||
export const createLink = async (req, res) => {
|
const createLink: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// await sleep(1000);
|
// await sleep(1000);
|
||||||
|
if (!req.session.user)
|
||||||
|
throw new ReferenceError("sessionData does not have user object");
|
||||||
const uID = req.session.user.id;
|
const uID = req.session.user.id;
|
||||||
|
|
||||||
let fromPl, toPl;
|
let fromPl, toPl;
|
||||||
@ -231,87 +268,89 @@ export const createLink = async (req, res) => {
|
|||||||
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
||||||
res.status(400).send({ message: "Link is not a playlist" });
|
res.status(400).send({ message: "Link is not a playlist" });
|
||||||
logger.info("non-playlist link provided", { from: fromPl, to: toPl });
|
logger.info("non-playlist link provided", { from: fromPl, to: toPl });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).send({ message: "Could not parse link" });
|
res.status(400).send({ message: "Could not parse link" });
|
||||||
logger.warn("parseSpotifyLink", { error });
|
logger.warn("parseSpotifyLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let playlists = await Playlists.findAll({
|
const playlists = (await Playlists.findAll({
|
||||||
attributes: ["playlistID"],
|
attributes: ["playlistID"],
|
||||||
raw: true,
|
raw: true,
|
||||||
where: { userID: uID }
|
where: { userID: uID },
|
||||||
});
|
})) as unknown as PlaylistModel_Pl[];
|
||||||
playlists = playlists.map(pl => pl.playlistID);
|
const playlistIDs = playlists.map((pl) => pl.playlistID);
|
||||||
|
|
||||||
// if playlists are unknown
|
// if playlists are unknown
|
||||||
if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) {
|
if (![fromPl, toPl].every((pl) => playlistIDs.includes(pl.id))) {
|
||||||
res.status(404).send({ message: "Playlists out of sync." });
|
res.status(404).send({ message: "Playlists out of sync." });
|
||||||
logger.warn("unknown playlists, resync");
|
logger.warn("unknown playlists, resync");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if exists
|
// check if exists
|
||||||
const existingLink = await Links.findOne({
|
const existingLink = await Links.findOne({
|
||||||
where: {
|
where: {
|
||||||
[Op.and]: [
|
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
|
||||||
{ userID: uID },
|
},
|
||||||
{ from: fromPl.id },
|
|
||||||
{ to: toPl.id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (existingLink) {
|
if (existingLink) {
|
||||||
res.status(409).send({ message: "Link already exists!" });
|
res.status(409).send({ message: "Link already exists!" });
|
||||||
logger.info("link already exists");
|
logger.info("link already exists");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allLinks = await Links.findAll({
|
const allLinks = (await Links.findAll({
|
||||||
attributes: ["from", "to"],
|
attributes: ["from", "to"],
|
||||||
raw: true,
|
raw: true,
|
||||||
where: { userID: uID }
|
where: { userID: uID },
|
||||||
});
|
})) as unknown as LinkModel_Edge[];
|
||||||
|
|
||||||
const newGraph = new myGraph(playlists, [...allLinks, { from: fromPl.id, to: toPl.id }]);
|
const newGraph = new myGraph(playlistIDs, [
|
||||||
|
...allLinks,
|
||||||
|
{ from: fromPl.id, to: toPl.id },
|
||||||
|
]);
|
||||||
|
|
||||||
if (newGraph.detectCycle()) {
|
if (newGraph.detectCycle()) {
|
||||||
res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" });
|
res
|
||||||
|
.status(400)
|
||||||
|
.send({ message: "Proposed link cannot cause a cycle in the graph" });
|
||||||
logger.warn("potential cycle detected");
|
logger.warn("potential cycle detected");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLink = await Links.create({
|
const newLink = await Links.create({
|
||||||
userID: uID,
|
userID: uID,
|
||||||
from: fromPl.id,
|
from: fromPl.id,
|
||||||
to: toPl.id
|
to: toPl.id,
|
||||||
});
|
});
|
||||||
if (!newLink) {
|
if (!newLink) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("Could not create link", { error: new Error("Links.create failed?") });
|
logger.error("Could not create link", {
|
||||||
return;
|
error: new Error("Links.create failed?"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).send({ message: "Created link." });
|
res.status(201).send({ message: "Created link." });
|
||||||
logger.debug("Created link");
|
logger.debug("Created link");
|
||||||
return;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("createLink", { error });
|
logger.error("createLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove link between playlists
|
* Remove link between playlists
|
||||||
* @param {typedefs.Req} req
|
*/
|
||||||
* @param {typedefs.Res} res
|
const removeLink: RequestHandler = async (req, res) => {
|
||||||
*/
|
|
||||||
export const removeLink = async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
|
if (!req.session.user)
|
||||||
|
throw new Error("sessionData does not have user object");
|
||||||
const uID = req.session.user.id;
|
const uID = req.session.user.id;
|
||||||
|
|
||||||
let fromPl, toPl;
|
let fromPl, toPl;
|
||||||
@ -321,103 +360,122 @@ export const removeLink = async (req, res) => {
|
|||||||
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
||||||
res.status(400).send({ message: "Link is not a playlist" });
|
res.status(400).send({ message: "Link is not a playlist" });
|
||||||
logger.info("non-playlist link provided", { from: fromPl, to: toPl });
|
logger.info("non-playlist link provided", { from: fromPl, to: toPl });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).send({ message: "Could not parse link" });
|
res.status(400).send({ message: "Could not parse link" });
|
||||||
logger.warn("parseSpotifyLink", { error });
|
logger.warn("parseSpotifyLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if exists
|
// check if exists
|
||||||
const existingLink = await Links.findOne({
|
const existingLink = await Links.findOne({
|
||||||
where: {
|
where: {
|
||||||
[Op.and]: [
|
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
|
||||||
{ userID: uID },
|
},
|
||||||
{ from: fromPl.id },
|
|
||||||
{ to: toPl.id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!existingLink) {
|
if (!existingLink) {
|
||||||
res.status(409).send({ message: "Link does not exist!" });
|
res.status(409).send({ message: "Link does not exist!" });
|
||||||
logger.warn("link does not exist");
|
logger.warn("link does not exist");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const removedLink = await Links.destroy({
|
const removedLink = await Links.destroy({
|
||||||
where: {
|
where: {
|
||||||
[Op.and]: [
|
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
|
||||||
{ userID: uID },
|
},
|
||||||
{ from: fromPl.id },
|
|
||||||
{ to: toPl.id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!removedLink) {
|
if (!removedLink) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("Could not remove link", { error: new Error("Links.destroy failed?") });
|
logger.error("Could not remove link", {
|
||||||
return;
|
error: new Error("Links.destroy failed?"),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send({ message: "Deleted link." });
|
res.status(200).send({ message: "Deleted link." });
|
||||||
logger.debug("Deleted link");
|
logger.debug("Deleted link");
|
||||||
return;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("removeLink", { error });
|
logger.error("removeLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
interface _GetPlaylistTracksArgs extends EndpointHandlerBaseArgs {
|
||||||
*
|
playlistID: string;
|
||||||
* @param {typedefs.Req} req
|
}
|
||||||
* @param {typedefs.Res} res
|
interface _GetPlaylistTracks {
|
||||||
* @param {string} playlistID
|
tracks: {
|
||||||
*/
|
is_local: boolean;
|
||||||
const _getPlaylistTracks = async (req, res, playlistID) => {
|
uri: string;
|
||||||
|
}[];
|
||||||
|
snapshot_id: string;
|
||||||
|
}
|
||||||
|
const _getPlaylistTracks: (
|
||||||
|
opts: _GetPlaylistTracksArgs
|
||||||
|
) => Promise<_GetPlaylistTracks | null> = async ({ req, res, playlistID }) => {
|
||||||
let initialFields = ["tracks(next,items(is_local,track(uri)))"];
|
let initialFields = ["tracks(next,items(is_local,track(uri)))"];
|
||||||
let mainFields = ["next", "items(is_local,track(uri))"];
|
let mainFields = ["next", "items(is_local,track(uri))"];
|
||||||
|
|
||||||
const respData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), playlistID);
|
const respData = await getPlaylistDetailsFirstPage({
|
||||||
if (res.headersSent) return;
|
req,
|
||||||
|
res,
|
||||||
|
initialFields: initialFields.join(),
|
||||||
|
playlistID,
|
||||||
|
});
|
||||||
|
if (!respData) return null;
|
||||||
|
|
||||||
|
const pl: _GetPlaylistTracks = {
|
||||||
|
tracks: [],
|
||||||
|
snapshot_id: respData.snapshot_id,
|
||||||
|
};
|
||||||
|
let nextURL;
|
||||||
|
|
||||||
let pl = {};
|
|
||||||
// varying fields again smh
|
// varying fields again smh
|
||||||
if (respData.tracks.next) {
|
if (respData.tracks.next) {
|
||||||
pl.next = new URL(respData.tracks.next);
|
nextURL = new URL(respData.tracks.next);
|
||||||
pl.next.searchParams.set("fields", mainFields.join());
|
nextURL.searchParams.set("fields", mainFields.join());
|
||||||
pl.next = pl.next.href;
|
nextURL = nextURL.href;
|
||||||
}
|
}
|
||||||
pl.tracks = respData.tracks.items.map((playlist_item) => {
|
pl.tracks = respData.tracks.items.map((playlist_item) => {
|
||||||
return {
|
return {
|
||||||
is_local: playlist_item.is_local,
|
is_local: playlist_item.is_local,
|
||||||
uri: playlist_item.track.uri
|
uri: playlist_item.track.uri,
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// keep getting batches of 50 till exhausted
|
// keep getting batches of 50 till exhausted
|
||||||
while (pl.next) {
|
while (nextURL) {
|
||||||
const nextData = await getPlaylistDetailsNextPage(req, res, pl.next);
|
const nextData = await getPlaylistDetailsNextPage({
|
||||||
if (res.headersSent) return;
|
req,
|
||||||
|
res,
|
||||||
|
nextURL,
|
||||||
|
});
|
||||||
|
if (!nextData) return null;
|
||||||
|
|
||||||
pl.tracks.push(
|
pl.tracks.push(
|
||||||
...nextData.items.map((playlist_item) => {
|
...nextData.items.map((playlist_item) => {
|
||||||
return {
|
return {
|
||||||
is_local: playlist_item.is_local,
|
is_local: playlist_item.is_local,
|
||||||
uri: playlist_item.track.uri
|
uri: playlist_item.track.uri,
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
pl.next = nextData.next;
|
nextURL = nextData.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete pl.next;
|
|
||||||
return pl;
|
return pl;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
|
||||||
|
link: {
|
||||||
|
from: URIObject;
|
||||||
|
to: URIObject;
|
||||||
|
};
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Add tracks to the link-head playlist,
|
* Add tracks to the link-head playlist,
|
||||||
* that are present in the link-tail playlist but not in the link-head playlist,
|
* that are present in the link-tail playlist but not in the link-head playlist,
|
||||||
@ -434,49 +492,63 @@ const _getPlaylistTracks = async (req, res, playlistID) => {
|
|||||||
* after populateMissingInLink, pl_a will have tracks: a, b, c, e, d
|
* after populateMissingInLink, pl_a will have tracks: a, b, c, e, d
|
||||||
*
|
*
|
||||||
* CANNOT populate local files; Spotify API does not support it yet.
|
* CANNOT populate local files; Spotify API does not support it yet.
|
||||||
*
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {{from: typedefs.URIObject, to: typedefs.URIObject}} link
|
|
||||||
* @returns {Promise<{toAddNum: number, localNum: number} | undefined>}
|
|
||||||
*/
|
*/
|
||||||
const _populateSingleLinkCore = async (req, res, link) => {
|
const _populateSingleLinkCore: (
|
||||||
|
opts: _PopulateSingleLinkCoreArgs
|
||||||
|
) => Promise<{ toAddNum: number; localNum: number } | null> = async ({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
link,
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
const fromPl = link.from, toPl = link.to;
|
const fromPl = link.from,
|
||||||
|
toPl = link.to;
|
||||||
|
|
||||||
const fromPlaylist = await _getPlaylistTracks(req, res, fromPl.id);
|
const fromPlaylist = await _getPlaylistTracks({
|
||||||
const toPlaylist = await _getPlaylistTracks(req, res, toPl.id);
|
req,
|
||||||
|
res,
|
||||||
|
playlistID: fromPl.id,
|
||||||
|
});
|
||||||
|
const toPlaylist = await _getPlaylistTracks({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
playlistID: toPl.id,
|
||||||
|
});
|
||||||
|
|
||||||
const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri);
|
if (!fromPlaylist || !toPlaylist) return null;
|
||||||
let toTrackURIs = toPlaylist.tracks.
|
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
|
||||||
filter(track => !track.is_local). // API doesn't support adding local files to playlists yet
|
let toTrackURIs = toPlaylist.tracks
|
||||||
filter(track => !fromTrackURIs.includes(track.uri)). // only ones missing from the 'from' playlist
|
.filter((track) => !track.is_local) // API doesn't support adding local files to playlists yet
|
||||||
map(track => track.uri);
|
.filter((track) => !fromTrackURIs.includes(track.uri)) // only ones missing from the 'from' playlist
|
||||||
|
.map((track) => track.uri);
|
||||||
|
|
||||||
const toAddNum = toTrackURIs.length;
|
const toAddNum = toTrackURIs.length;
|
||||||
const localNum = toPlaylist.tracks.filter(track => track.is_local).length;
|
const localNum = toPlaylist.tracks.filter((track) => track.is_local).length;
|
||||||
|
|
||||||
// append to end in batches of 100
|
// append to end in batches of 100
|
||||||
while (toTrackURIs.length > 0) {
|
while (toTrackURIs.length > 0) {
|
||||||
const nextBatch = toTrackURIs.splice(0, 100);
|
const nextBatch = toTrackURIs.splice(0, 100);
|
||||||
const addData = await addItemsToPlaylist(req, res, nextBatch, fromPl.id);
|
const addData = await addItemsToPlaylist({
|
||||||
if (res.headersSent) return;
|
req,
|
||||||
|
res,
|
||||||
|
nextBatch,
|
||||||
|
playlistID: fromPl.id,
|
||||||
|
});
|
||||||
|
if (!addData) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { toAddNum, localNum };
|
return { toAddNum, localNum };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("_populateSingleLinkCore", { error });
|
logger.error("_populateSingleLinkCore", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
const populateSingleLink: RequestHandler = async (req, res) => {
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
|
||||||
export const populateSingleLink = async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
|
if (!req.session.user)
|
||||||
|
throw new Error("sessionData does not have user object");
|
||||||
const uID = req.session.user.id;
|
const uID = req.session.user.id;
|
||||||
const link = { from: req.body.from, to: req.body.to };
|
const link = { from: req.body.from, to: req.body.to };
|
||||||
let fromPl, toPl;
|
let fromPl, toPl;
|
||||||
@ -487,51 +559,63 @@ export const populateSingleLink = async (req, res) => {
|
|||||||
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
||||||
res.status(400).send({ message: "Link is not a playlist" });
|
res.status(400).send({ message: "Link is not a playlist" });
|
||||||
logger.info("non-playlist link provided", link);
|
logger.info("non-playlist link provided", link);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).send({ message: "Could not parse link" });
|
res.status(400).send({ message: "Could not parse link" });
|
||||||
logger.warn("parseSpotifyLink", { error });
|
logger.warn("parseSpotifyLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if exists
|
// check if exists
|
||||||
const existingLink = await Links.findOne({
|
const existingLink = await Links.findOne({
|
||||||
where: {
|
where: {
|
||||||
[Op.and]: [
|
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
|
||||||
{ userID: uID },
|
},
|
||||||
{ from: fromPl.id },
|
|
||||||
{ to: toPl.id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!existingLink) {
|
if (!existingLink) {
|
||||||
res.status(409).send({ message: "Link does not exist!" });
|
res.status(409).send({ message: "Link does not exist!" });
|
||||||
logger.warn("link does not exist", { link });
|
logger.warn("link does not exist", { link });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await checkPlaylistEditable(req, res, fromPl.id, uID))
|
if (
|
||||||
return;
|
!(await checkPlaylistEditable({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
playlistID: fromPl.id,
|
||||||
|
userID: uID,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
const result = await _populateSingleLinkCore(req, res, { from: fromPl, to: toPl });
|
const result = await _populateSingleLinkCore({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
link: { from: fromPl, to: toPl },
|
||||||
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
const { toAddNum, localNum } = result;
|
const { toAddNum, localNum } = result;
|
||||||
let logMsg;
|
let logMsg;
|
||||||
logMsg = toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add";
|
logMsg =
|
||||||
logMsg += localNum > 0 ? "; could not process " + localNum + " local files" : ".";
|
toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add";
|
||||||
|
logMsg +=
|
||||||
|
localNum > 0 ? "; could not process " + localNum + " local files" : ".";
|
||||||
|
|
||||||
res.status(200).send({ message: logMsg });
|
res.status(200).send({ message: logMsg });
|
||||||
logger.debug(logMsg, { toAddNum, localNum });
|
logger.debug(logMsg, { toAddNum, localNum });
|
||||||
}
|
}
|
||||||
return;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("populateSingleLink", { error });
|
logger.error("populateSingleLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
|
||||||
|
link: { from: URIObject; to: URIObject };
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Remove tracks from the link-tail playlist,
|
* Remove tracks from the link-tail playlist,
|
||||||
* that are present in the link-tail playlist but not in the link-head playlist.
|
* that are present in the link-tail playlist but not in the link-head playlist.
|
||||||
@ -546,53 +630,64 @@ export const populateSingleLink = async (req, res) => {
|
|||||||
*
|
*
|
||||||
* after pruneSingleLink, pl_b will have tracks: b, c
|
* after pruneSingleLink, pl_b will have tracks: b, c
|
||||||
*
|
*
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {{from: typedefs.URIObject, to: typedefs.URIObject}} link
|
|
||||||
* @returns {Promise<{toDelNum: number} | undefined>}
|
|
||||||
*/
|
*/
|
||||||
const _pruneSingleLinkCore = async (req, res, link) => {
|
const _pruneSingleLinkCore: (
|
||||||
|
opts: _PruneSingleLinkCoreArgs
|
||||||
|
) => Promise<{ toDelNum: number } | null> = async ({ req, res, link }) => {
|
||||||
try {
|
try {
|
||||||
const fromPl = link.from, toPl = link.to;
|
const fromPl = link.from,
|
||||||
|
toPl = link.to;
|
||||||
|
|
||||||
const fromPlaylist = await _getPlaylistTracks(req, res, fromPl.id);
|
const fromPlaylist = await _getPlaylistTracks({
|
||||||
const toPlaylist = await _getPlaylistTracks(req, res, toPl.id);
|
req,
|
||||||
|
res,
|
||||||
const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri);
|
playlistID: fromPl.id,
|
||||||
let indexedToTrackURIs = toPlaylist.tracks;
|
});
|
||||||
|
const toPlaylist = await _getPlaylistTracks({
|
||||||
indexedToTrackURIs.forEach((track, index) => {
|
req,
|
||||||
track.position = index;
|
res,
|
||||||
|
playlistID: toPl.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
let indexes = indexedToTrackURIs.filter(track => !fromTrackURIs.includes(track.uri)); // only those missing from the 'from' playlist
|
if (!fromPlaylist || !toPlaylist) return null;
|
||||||
indexes = indexes.map(track => track.position); // get track positions
|
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
|
||||||
|
const indexedToTrackURIs = toPlaylist.tracks.map((track, index) => {
|
||||||
|
return { ...track, position: index };
|
||||||
|
});
|
||||||
|
|
||||||
|
let indexes = indexedToTrackURIs
|
||||||
|
.filter((track) => !fromTrackURIs.includes(track.uri)) // only those missing from the 'from' playlist
|
||||||
|
.map((track) => track.position); // get track positions
|
||||||
|
|
||||||
const toDelNum = indexes.length;
|
const toDelNum = indexes.length;
|
||||||
|
|
||||||
// remove in batches of 100 (from reverse, to preserve positions while modifying)
|
// remove in batches of 100 (from reverse, to preserve positions while modifying)
|
||||||
let currentSnapshot = toPlaylist.snapshot_id;
|
let currentSnapshot = toPlaylist.snapshot_id;
|
||||||
while (indexes.length) {
|
while (indexes.length > 0) {
|
||||||
const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100);
|
const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100);
|
||||||
const delResponse = await removeItemsFromPlaylist(req, res, nextBatch, toPl.id, currentSnapshot);
|
const delResponse = await removePlaylistItems({
|
||||||
if (res.headersSent) return;
|
req,
|
||||||
|
res,
|
||||||
|
nextBatch,
|
||||||
|
playlistID: toPl.id,
|
||||||
|
snapshotID: currentSnapshot,
|
||||||
|
});
|
||||||
|
if (!delResponse) return null;
|
||||||
currentSnapshot = delResponse.snapshot_id;
|
currentSnapshot = delResponse.snapshot_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { toDelNum };
|
return { toDelNum };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("_pruneSingleLinkCore", { error })
|
logger.error("_pruneSingleLinkCore", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
const pruneSingleLink: RequestHandler = async (req, res) => {
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
|
||||||
export const pruneSingleLink = async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
|
if (!req.session.user)
|
||||||
|
throw new Error("sessionData does not have user object");
|
||||||
const uID = req.session.user.id;
|
const uID = req.session.user.id;
|
||||||
const link = { from: req.body.from, to: req.body.to };
|
const link = { from: req.body.from, to: req.body.to };
|
||||||
|
|
||||||
@ -603,43 +698,62 @@ export const pruneSingleLink = async (req, res) => {
|
|||||||
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
|
||||||
res.status(400).send({ message: "Link is not a playlist" });
|
res.status(400).send({ message: "Link is not a playlist" });
|
||||||
logger.info("non-playlist link provided", link);
|
logger.info("non-playlist link provided", link);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
res.status(400).send({ message: error.message });
|
res.status(400).send({ message: error.message });
|
||||||
logger.warn("parseSpotifyLink", { error });
|
logger.warn("parseSpotifyLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if exists
|
// check if exists
|
||||||
const existingLink = await Links.findOne({
|
const existingLink = await Links.findOne({
|
||||||
where: {
|
where: {
|
||||||
[Op.and]: [
|
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
|
||||||
{ userID: uID },
|
},
|
||||||
{ from: fromPl.id },
|
|
||||||
{ to: toPl.id }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!existingLink) {
|
if (!existingLink) {
|
||||||
res.status(409).send({ message: "Link does not exist!" });
|
res.status(409).send({ message: "Link does not exist!" });
|
||||||
logger.warn("link does not exist", { link });
|
logger.warn("link does not exist", { link });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await checkPlaylistEditable(req, res, toPl.id, uID))
|
if (
|
||||||
return;
|
!(await checkPlaylistEditable({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
playlistID: toPl.id,
|
||||||
|
userID: uID,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
const result = await _pruneSingleLinkCore(req, res, { from: fromPl, to: toPl });
|
const result = await _pruneSingleLinkCore({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
link: {
|
||||||
|
from: fromPl,
|
||||||
|
to: toPl,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
const { toDelNum } = result;
|
const { toDelNum } = result;
|
||||||
res.status(200).send({ message: `Removed ${toDelNum} tracks.` });
|
res.status(200).send({ message: `Removed ${toDelNum} tracks.` });
|
||||||
logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum });
|
logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum });
|
||||||
}
|
}
|
||||||
return;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("pruneSingleLink", { error });
|
logger.error("pruneSingleLink", { error });
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
updateUser,
|
||||||
|
fetchUser,
|
||||||
|
createLink,
|
||||||
|
removeLink,
|
||||||
|
populateSingleLink,
|
||||||
|
pruneSingleLink,
|
||||||
|
};
|
||||||
@ -1,155 +0,0 @@
|
|||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
import { getUserPlaylistsFirstPage, getUserPlaylistsNextPage, getPlaylistDetailsFirstPage, getPlaylistDetailsNextPage } from "../api/spotify.js";
|
|
||||||
import { parseSpotifyLink } from "../utils/spotifyURITransformer.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve list of all of user's playlists
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
|
||||||
export const fetchUserPlaylists = async (req, res) => {
|
|
||||||
try {
|
|
||||||
let userPlaylists = {};
|
|
||||||
|
|
||||||
// get first 50
|
|
||||||
const respData = await getUserPlaylistsFirstPage(req, res);
|
|
||||||
if (res.headersSent) return;
|
|
||||||
|
|
||||||
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.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.next = nextData.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve an entire playlist
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
*/
|
|
||||||
export 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 {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// 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.next = nextData.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
55
controllers/playlists.ts
Normal file
55
controllers/playlists.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
getCurrentUsersPlaylistsFirstPage,
|
||||||
|
getCurrentUsersPlaylistsNextPage,
|
||||||
|
} from "../api/spotify.ts";
|
||||||
|
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import type {
|
||||||
|
Pagination,
|
||||||
|
SimplifiedPlaylistObject,
|
||||||
|
} from "spotify_manager/index.d.ts";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's playlists
|
||||||
|
*/
|
||||||
|
const fetchUserPlaylists: RequestHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// get first 50
|
||||||
|
const respData = await getCurrentUsersPlaylistsFirstPage({ req, res });
|
||||||
|
if (!respData) return null;
|
||||||
|
|
||||||
|
let tmpData = structuredClone(respData);
|
||||||
|
const userPlaylists: Pick<
|
||||||
|
Pagination<SimplifiedPlaylistObject>,
|
||||||
|
"items" | "total"
|
||||||
|
> = {
|
||||||
|
items: [...tmpData.items],
|
||||||
|
total: tmpData.total,
|
||||||
|
};
|
||||||
|
let nextURL = respData.next;
|
||||||
|
// keep getting batches of 50 till exhausted
|
||||||
|
while (nextURL) {
|
||||||
|
const nextData = await getCurrentUsersPlaylistsNextPage({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
nextURL,
|
||||||
|
});
|
||||||
|
if (!nextData) return null;
|
||||||
|
|
||||||
|
userPlaylists.items.push(...nextData.items);
|
||||||
|
nextURL = nextData.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(userPlaylists);
|
||||||
|
logger.debug("Fetched user playlists", { num: userPlaylists.total });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
|
logger.error("fetchUserPlaylists", { error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export { fetchUserPlaylists };
|
||||||
135
index.js
135
index.js
@ -1,135 +0,0 @@
|
|||||||
import _ from "./config/dotenv.js";
|
|
||||||
|
|
||||||
import { promisify } from "util";
|
|
||||||
import express from "express";
|
|
||||||
import session from "express-session";
|
|
||||||
|
|
||||||
import cors from "cors";
|
|
||||||
import cookieParser from "cookie-parser";
|
|
||||||
import helmet from "helmet";
|
|
||||||
|
|
||||||
import { createClient } from 'redis';
|
|
||||||
import { RedisStore } from "connect-redis";
|
|
||||||
|
|
||||||
import { sessionName } from "./constants.js";
|
|
||||||
import { sequelize } from "./models/index.js";
|
|
||||||
|
|
||||||
import { isAuthenticated } from "./middleware/authCheck.js";
|
|
||||||
import { getUserProfile } from "./api/spotify.js";
|
|
||||||
|
|
||||||
import curriedLogger from "./utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// Enable this if you run behind a proxy (e.g. nginx)
|
|
||||||
app.set("trust proxy", process.env.TRUST_PROXY);
|
|
||||||
|
|
||||||
// Configure Redis client and connect
|
|
||||||
const redisClient = createClient({
|
|
||||||
socket: {
|
|
||||||
host: process.env.REDIS_HOST,
|
|
||||||
port: process.env.REDIS_PORT,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
redisClient.connect()
|
|
||||||
.then(() => {
|
|
||||||
logger.info("Connected to Redis store");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error("Redis connection error", { error });
|
|
||||||
cleanupFunc();
|
|
||||||
});
|
|
||||||
|
|
||||||
const redisStore = new RedisStore({ client: redisClient });
|
|
||||||
|
|
||||||
// Configure session middleware
|
|
||||||
app.use(session({
|
|
||||||
name: sessionName,
|
|
||||||
store: redisStore,
|
|
||||||
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
|
|
||||||
}));
|
|
||||||
app.use(helmet({
|
|
||||||
crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" }
|
|
||||||
}));
|
|
||||||
app.disable("x-powered-by");
|
|
||||||
|
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
// Static
|
|
||||||
app.use(express.static(import.meta.dirname + "/static"));
|
|
||||||
|
|
||||||
// Healthcheck
|
|
||||||
app.use("/health", (req, res) => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
import authRoutes from "./routes/auth.js";
|
|
||||||
import playlistRoutes from "./routes/playlists.js";
|
|
||||||
import operationRoutes from "./routes/operations.js";
|
|
||||||
// Routes
|
|
||||||
app.use("/api/auth/", authRoutes);
|
|
||||||
app.use("/api/playlists", isAuthenticated, playlistRoutes);
|
|
||||||
app.use("/api/operations", isAuthenticated, operationRoutes);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = process.env.PORT || 5000;
|
|
||||||
|
|
||||||
const server = app.listen(port, () => {
|
|
||||||
logger.info(`App Listening on port ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cleanupFunc = (signal) => {
|
|
||||||
if (signal)
|
|
||||||
logger.debug(`${signal} signal received, shutting down now...`);
|
|
||||||
|
|
||||||
Promise.allSettled([
|
|
||||||
redisClient.disconnect,
|
|
||||||
sequelize.close(),
|
|
||||||
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));
|
|
||||||
});
|
|
||||||
164
index.ts
Normal file
164
index.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import _ from "./config/dotenv.ts";
|
||||||
|
|
||||||
|
import { promisify } from "util";
|
||||||
|
import express from "express";
|
||||||
|
import session from "express-session";
|
||||||
|
|
||||||
|
import cors from "cors";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import helmet from "helmet";
|
||||||
|
|
||||||
|
import { createClient } from "redis";
|
||||||
|
import { RedisStore } from "connect-redis";
|
||||||
|
|
||||||
|
import { sessionName } from "./constants.ts";
|
||||||
|
import seqConn from "./models/index.ts";
|
||||||
|
|
||||||
|
import { isAuthenticated } from "./middleware/authCheck.ts";
|
||||||
|
import { getCurrentUsersProfile } from "./api/spotify.ts";
|
||||||
|
|
||||||
|
import curriedLogger from "./utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// check env vars
|
||||||
|
if (
|
||||||
|
isNaN(Number(process.env["TRUST_PROXY"])) ||
|
||||||
|
![0, 1].includes(Number(process.env["TRUST_PROXY"]))
|
||||||
|
) {
|
||||||
|
throw new TypeError("TRUST_PROXY must be 0 or 1");
|
||||||
|
}
|
||||||
|
if (isNaN(Number(process.env["REDIS_PORT"]))) {
|
||||||
|
throw new TypeError("REDIS_PORT must be a number");
|
||||||
|
}
|
||||||
|
if (!process.env["SESSION_SECRET"]) {
|
||||||
|
throw new TypeError("SESSION_SECRET cannot be undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable this if you run behind a proxy (e.g. nginx)
|
||||||
|
app.set("trust proxy", process.env["TRUST_PROXY"]);
|
||||||
|
|
||||||
|
// Configure Redis client and connect
|
||||||
|
const redisClient = createClient({
|
||||||
|
socket: {
|
||||||
|
host: process.env["REDIS_HOST"],
|
||||||
|
port: Number(process.env["REDIS_PORT"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient
|
||||||
|
.connect()
|
||||||
|
.then(() => {
|
||||||
|
logger.info("Connected to Redis store");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error("Redis connection error", { error });
|
||||||
|
cleanupFunc();
|
||||||
|
});
|
||||||
|
|
||||||
|
const redisStore = new RedisStore({ client: redisClient });
|
||||||
|
|
||||||
|
// Configure session middleware
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
name: sessionName,
|
||||||
|
store: redisStore,
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginOpenerPolicy: {
|
||||||
|
policy:
|
||||||
|
process.env["NODE_ENV"] === "development"
|
||||||
|
? "unsafe-none"
|
||||||
|
: "same-origin",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Static
|
||||||
|
app.use(express.static(import.meta.dirname + "/static"));
|
||||||
|
|
||||||
|
// Healthcheck
|
||||||
|
app.use("/health", (_req, res) => {
|
||||||
|
res.status(200).send({ message: "OK" });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
app.use("/auth-health", isAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const respData = await getCurrentUsersProfile({ req, res });
|
||||||
|
if (!respData) return null;
|
||||||
|
res.status(200).send({ message: "OK" });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
|
logger.error("authHealthCheck", { error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
import authRoutes from "./routes/auth.ts";
|
||||||
|
import playlistRoutes from "./routes/playlists.ts";
|
||||||
|
import operationRoutes from "./routes/operations.ts";
|
||||||
|
// Routes
|
||||||
|
app.use("/api/auth/", authRoutes);
|
||||||
|
app.use("/api/playlists", isAuthenticated, playlistRoutes);
|
||||||
|
app.use("/api/operations", isAuthenticated, operationRoutes);
|
||||||
|
|
||||||
|
// 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 null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = process.env["PORT"] || 5000;
|
||||||
|
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
logger.info(`App Listening on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanupFunc = (signal?: string) => {
|
||||||
|
if (signal) logger.debug(`${signal} signal received, shutting down now...`);
|
||||||
|
|
||||||
|
Promise.allSettled([
|
||||||
|
redisClient.disconnect,
|
||||||
|
seqConn.close(),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { sessionName } from "../constants.js";
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* middleware to check if access token is present
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {typedefs.Next} next
|
|
||||||
*/
|
|
||||||
export 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((error) => {
|
|
||||||
if (Object.keys(error).length) {
|
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
|
||||||
logger.error("session.destroy", { error });
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
res.clearCookie(sessionName);
|
|
||||||
res.status(401).send({ message: "Unauthorized" });
|
|
||||||
logger.debug("Session invalid, destroyed.", { sessionID: delSession.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
31
middleware/authCheck.ts
Normal file
31
middleware/authCheck.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { AxiosRequestHeaders } from "axios";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
import { sessionName } from "../constants.ts";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
export const isAuthenticated: RequestHandler = (req, res, next) => {
|
||||||
|
if (req.session.accessToken) {
|
||||||
|
req.session.authHeaders = {
|
||||||
|
Authorization: `Bearer ${req.session.accessToken}`,
|
||||||
|
} as AxiosRequestHeaders;
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
const delSession = req.session.destroy((error) => {
|
||||||
|
if (Object.keys(error).length) {
|
||||||
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
|
logger.error("session.destroy", { error });
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
res.clearCookie(sessionName);
|
||||||
|
res.status(401).send({ message: "Unauthorized" });
|
||||||
|
logger.debug("Session invalid, destroyed.", {
|
||||||
|
sessionID: delSession.id,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/** @type {import("sequelize-cli").Migration} */
|
import { type Migration } from "sequelize-cli";
|
||||||
export default {
|
export default {
|
||||||
up: async function (queryInterface, Sequelize) {
|
up: async function (queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable("playlists", {
|
await queryInterface.createTable("playlists", {
|
||||||
@ -7,28 +7,28 @@ export default {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
autoIncrement: true,
|
autoIncrement: true,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
type: Sequelize.INTEGER
|
type: Sequelize.INTEGER,
|
||||||
},
|
},
|
||||||
playlistID: {
|
playlistID: {
|
||||||
type: Sequelize.STRING
|
type: Sequelize.STRING,
|
||||||
},
|
},
|
||||||
playlistName: {
|
playlistName: {
|
||||||
type: Sequelize.STRING
|
type: Sequelize.STRING,
|
||||||
},
|
},
|
||||||
userID: {
|
userID: {
|
||||||
type: Sequelize.STRING
|
type: Sequelize.STRING,
|
||||||
},
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
type: Sequelize.DATE
|
type: Sequelize.DATE,
|
||||||
},
|
},
|
||||||
updatedAt: {
|
updatedAt: {
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
type: Sequelize.DATE
|
type: Sequelize.DATE,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
down: async function (queryInterface, Sequelize) {
|
down: async function (queryInterface, _Sequelize) {
|
||||||
await queryInterface.dropTable("playlists");
|
await queryInterface.dropTable("playlists");
|
||||||
}
|
},
|
||||||
};
|
} as Migration;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
/** @type {import("sequelize-cli").Migration} */
|
import { type Migration } from "sequelize-cli";
|
||||||
export default {
|
export default {
|
||||||
up: async function (queryInterface, Sequelize) {
|
up: async function (queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable("links", {
|
await queryInterface.createTable("links", {
|
||||||
@ -7,28 +7,28 @@ export default {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
autoIncrement: true,
|
autoIncrement: true,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
type: Sequelize.INTEGER
|
type: Sequelize.INTEGER,
|
||||||
},
|
},
|
||||||
userID: {
|
userID: {
|
||||||
type: Sequelize.STRING
|
type: Sequelize.STRING,
|
||||||
},
|
},
|
||||||
from: {
|
from: {
|
||||||
type: Sequelize.STRING
|
type: Sequelize.STRING,
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
type: Sequelize.STRING
|
type: Sequelize.STRING,
|
||||||
},
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
type: Sequelize.DATE
|
type: Sequelize.DATE,
|
||||||
},
|
},
|
||||||
updatedAt: {
|
updatedAt: {
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
type: Sequelize.DATE
|
type: Sequelize.DATE,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
down: async function (queryInterface, Sequelize) {
|
down: async function (queryInterface, _Sequelize) {
|
||||||
await queryInterface.dropTable("links");
|
await queryInterface.dropTable("links");
|
||||||
}
|
},
|
||||||
};
|
} as Migration;
|
||||||
@ -1,63 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
import { readdirSync } from "fs";
|
|
||||||
import { basename as _basename } from "path";
|
|
||||||
const basename = _basename(import.meta.filename);
|
|
||||||
|
|
||||||
import Sequelize from "sequelize";
|
|
||||||
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
import seqConfig from "../config/sequelize.js"
|
|
||||||
const env = process.env.NODE_ENV || "development";
|
|
||||||
const config = seqConfig[env];
|
|
||||||
const db = {};
|
|
||||||
|
|
||||||
let sequelize;
|
|
||||||
if (config.use_env_variable) {
|
|
||||||
sequelize = new Sequelize(process.env[config.use_env_variable], config);
|
|
||||||
} else {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Read model definitions from folder
|
|
||||||
const modelFiles = readdirSync(import.meta.dirname)
|
|
||||||
.filter(
|
|
||||||
(file) => file.indexOf('.') !== 0
|
|
||||||
&& file !== basename
|
|
||||||
&& file.slice(-3) === '.js',
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(modelFiles.map(async file => {
|
|
||||||
const model = await import(`./${file}`);
|
|
||||||
if (!model.default) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const namedModel = model.default(sequelize, Sequelize.DataTypes);
|
|
||||||
db[namedModel.name] = namedModel;
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Setup defined associations
|
|
||||||
Object.keys(db).forEach(modelName => {
|
|
||||||
if (db[modelName].associate) {
|
|
||||||
db[modelName].associate(db);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// clean ts up
|
|
||||||
db.sequelize = sequelize;
|
|
||||||
db.Sequelize = Sequelize;
|
|
||||||
export { sequelize as sequelize };
|
|
||||||
export { Sequelize as Sequelize };
|
|
||||||
export default db;
|
|
||||||
35
models/index.ts
Normal file
35
models/index.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
import { Sequelize } from "sequelize-typescript";
|
||||||
|
|
||||||
|
import seqConfig from "../config/sequelize.ts";
|
||||||
|
|
||||||
|
import links from "./links.ts";
|
||||||
|
import playlists from "./playlists.ts";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
if (!process.env["NODE_ENV"])
|
||||||
|
throw new TypeError("Node environment not defined");
|
||||||
|
if (!process.env["DB_URI"])
|
||||||
|
throw new TypeError("Database connection URI not defined");
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
const config = seqConfig[process.env["NODE_ENV"]];
|
||||||
|
const seqConn: Sequelize = new Sequelize(process.env["DB_URI"], config);
|
||||||
|
|
||||||
|
// Check connection
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await seqConn.authenticate();
|
||||||
|
logger.info("Sequelize auth success");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Sequelize auth error", { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Load models
|
||||||
|
seqConn.addModels([links, playlists]);
|
||||||
|
|
||||||
|
export default seqConn;
|
||||||
@ -1,23 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
import { Model } from "sequelize";
|
|
||||||
export default (sequelize, DataTypes) => {
|
|
||||||
class links extends Model {
|
|
||||||
/**
|
|
||||||
* Helper method for defining associations.
|
|
||||||
* This method is not a part of Sequelize lifecycle.
|
|
||||||
* The `models/index` file will call this method automatically.
|
|
||||||
*/
|
|
||||||
static associate(models) {
|
|
||||||
// define association here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
links.init({
|
|
||||||
userID: DataTypes.STRING,
|
|
||||||
from: DataTypes.STRING,
|
|
||||||
to: DataTypes.STRING
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: "links",
|
|
||||||
});
|
|
||||||
return links;
|
|
||||||
};
|
|
||||||
23
models/links.ts
Normal file
23
models/links.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"use strict";
|
||||||
|
import {
|
||||||
|
AllowNull,
|
||||||
|
Column,
|
||||||
|
DataType,
|
||||||
|
Model,
|
||||||
|
Table,
|
||||||
|
} from "sequelize-typescript";
|
||||||
|
@Table
|
||||||
|
class links extends Model<Partial<links>> {
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
declare userID: string;
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
declare from: string;
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
declare to: string;
|
||||||
|
}
|
||||||
|
export default links;
|
||||||
@ -1,23 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
import { Model } from "sequelize";
|
|
||||||
export default (sequelize, DataTypes) => {
|
|
||||||
class playlists extends Model {
|
|
||||||
/**
|
|
||||||
* Helper method for defining associations.
|
|
||||||
* This method is not a part of Sequelize lifecycle.
|
|
||||||
* The `models/index` file will call this method automatically.
|
|
||||||
*/
|
|
||||||
static associate(models) {
|
|
||||||
// define association here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
playlists.init({
|
|
||||||
playlistID: DataTypes.STRING,
|
|
||||||
playlistName: DataTypes.STRING,
|
|
||||||
userID: DataTypes.STRING
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: "playlists",
|
|
||||||
});
|
|
||||||
return playlists;
|
|
||||||
};
|
|
||||||
24
models/playlists.ts
Normal file
24
models/playlists.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
import {
|
||||||
|
AllowNull,
|
||||||
|
Column,
|
||||||
|
DataType,
|
||||||
|
Model,
|
||||||
|
Table,
|
||||||
|
} from "sequelize-typescript";
|
||||||
|
@Table
|
||||||
|
class playlists extends Model<Partial<playlists>> {
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
declare playlistID: string;
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
declare playlistName: string;
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
declare userID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default playlists;
|
||||||
700
package-lock.json
generated
700
package-lock.json
generated
@ -9,19 +9,20 @@
|
|||||||
"version": "0",
|
"version": "0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.8.2",
|
||||||
"axios-rate-limit": "^1.4.0",
|
|
||||||
"connect-redis": "^8.0.1",
|
"connect-redis": "^8.0.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv-flow": "^4.1.0",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
"sequelize": "^6.37.5",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"sequelize": "^6.37.6",
|
||||||
|
"sequelize-typescript": "^2.1.6",
|
||||||
"serializr": "^3.0.3",
|
"serializr": "^3.0.3",
|
||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
@ -30,11 +31,14 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/sequelize": "^4.28.20",
|
||||||
|
"@types/validator": "^13.12.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"nodemon": "^3.1.9",
|
"nodemon": "^3.1.9",
|
||||||
"sequelize-cli": "^6.6.2",
|
"sequelize-cli": "^6.6.2",
|
||||||
"typescript": "^5.7.3"
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@colors/colors": {
|
"node_modules/@colors/colors": {
|
||||||
@ -55,6 +59,431 @@
|
|||||||
"kuler": "^2.0.0"
|
"kuler": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@ -141,6 +570,13 @@
|
|||||||
"@redis/client": "^1.0.0"
|
"@redis/client": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bluebird": {
|
||||||
|
"version": "3.5.42",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.42.tgz",
|
||||||
|
"integrity": "sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.5",
|
"version": "1.19.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||||
@ -160,6 +596,16 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/continuation-local-storage": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/continuation-local-storage/-/continuation-local-storage-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-Q7dPOymVpRG5Zpz90/o26+OAqOG2Sw+FED7uQmTrJNCF/JAPTylclZofMxZKd6W7g1BDPmT9/C/jX0ZcSNTQwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie-parser": {
|
"node_modules/@types/cookie-parser": {
|
||||||
"version": "1.4.8",
|
"version": "1.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
|
||||||
@ -191,6 +637,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
|
||||||
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
|
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^4.17.33",
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
@ -225,6 +672,13 @@
|
|||||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||||
|
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@ -237,9 +691,10 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.9",
|
"version": "22.13.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||||
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
|
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.20.0"
|
||||||
}
|
}
|
||||||
@ -266,6 +721,19 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/sequelize": {
|
||||||
|
"version": "4.28.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sequelize/-/sequelize-4.28.20.tgz",
|
||||||
|
"integrity": "sha512-XaGOKRhdizC87hDgQ0u3btxzbejlF+t6Hhvkek1HyphqCI4y7zVBIVAGmuc4cWJqGpxusZ1RiBToHHnNK/Edlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/bluebird": "*",
|
||||||
|
"@types/continuation-local-storage": "*",
|
||||||
|
"@types/lodash": "*",
|
||||||
|
"@types/validator": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/serve-static": {
|
"node_modules/@types/serve-static": {
|
||||||
"version": "1.15.7",
|
"version": "1.15.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
||||||
@ -285,7 +753,8 @@
|
|||||||
"node_modules/@types/validator": {
|
"node_modules/@types/validator": {
|
||||||
"version": "13.12.2",
|
"version": "13.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz",
|
||||||
"integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA=="
|
"integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -370,31 +839,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
|
||||||
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
|
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios-rate-limit": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-uM5PbmSUdSle1I+59Av/wpLuNRobfatIR+FyylSoHcVHT20ohjflNnLMEHZQr7N2QVG/Wlt8jekIPhWwoKtpXQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"axios": ">=0.18.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"axios": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@ -441,7 +899,6 @@
|
|||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@ -707,8 +1164,7 @@
|
|||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/config-chain": {
|
"node_modules/config-chain": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
@ -792,6 +1248,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.1"
|
"cross-spawn": "^7.0.1"
|
||||||
},
|
},
|
||||||
@ -869,6 +1326,7 @@
|
|||||||
"version": "16.4.7",
|
"version": "16.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -876,17 +1334,6 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv-flow": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv-flow/-/dotenv-flow-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-0cwP9jpQBQfyHwvE0cRhraZMkdV45TQedA8AAUZMsFzvmLcQyc1HPv+oX0OOYwLFjIlvgVepQ+WuQHbqDaHJZg==",
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^16.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dottie": {
|
"node_modules/dottie": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
|
||||||
@ -1070,6 +1517,47 @@
|
|||||||
"es6-symbol": "^3.1.1"
|
"es6-symbol": "^3.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
|
||||||
|
"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.1",
|
||||||
|
"@esbuild/android-arm": "0.25.1",
|
||||||
|
"@esbuild/android-arm64": "0.25.1",
|
||||||
|
"@esbuild/android-x64": "0.25.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.1",
|
||||||
|
"@esbuild/darwin-x64": "0.25.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.1",
|
||||||
|
"@esbuild/linux-arm": "0.25.1",
|
||||||
|
"@esbuild/linux-arm64": "0.25.1",
|
||||||
|
"@esbuild/linux-ia32": "0.25.1",
|
||||||
|
"@esbuild/linux-loong64": "0.25.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.1",
|
||||||
|
"@esbuild/linux-s390x": "0.25.1",
|
||||||
|
"@esbuild/linux-x64": "0.25.1",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.1",
|
||||||
|
"@esbuild/sunos-x64": "0.25.1",
|
||||||
|
"@esbuild/win32-arm64": "0.25.1",
|
||||||
|
"@esbuild/win32-ia32": "0.25.1",
|
||||||
|
"@esbuild/win32-x64": "0.25.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@ -1333,6 +1821,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs.realpath": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@ -1407,6 +1901,19 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
|
||||||
|
"integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@ -1573,6 +2080,17 @@
|
|||||||
"node >= 0.4.0"
|
"node >= 0.4.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/inflight": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||||
|
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@ -1874,7 +2392,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
},
|
},
|
||||||
@ -2042,6 +2559,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/one-time": {
|
"node_modules/one-time": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||||
@ -2064,6 +2590,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@ -2339,6 +2874,12 @@
|
|||||||
"@redis/time-series": "1.1.0"
|
"@redis/time-series": "1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reflect-metadata": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@ -2368,6 +2909,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/retry-as-promised": {
|
"node_modules/retry-as-promised": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||||
@ -2462,6 +3013,7 @@
|
|||||||
"url": "https://opencollective.com/sequelize"
|
"url": "https://opencollective.com/sequelize"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/debug": "^4.1.8",
|
"@types/debug": "^4.1.8",
|
||||||
"@types/validator": "^13.7.17",
|
"@types/validator": "^13.7.17",
|
||||||
@ -2543,6 +3095,45 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sequelize-typescript": {
|
||||||
|
"version": "2.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-2.1.6.tgz",
|
||||||
|
"integrity": "sha512-Vc2N++3en346RsbGjL3h7tgAl2Y7V+2liYTAOZ8XL0KTw3ahFHsyAUzOwct51n+g70I1TOUDgs06Oh6+XGcFkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "7.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/validator": "*",
|
||||||
|
"reflect-metadata": "*",
|
||||||
|
"sequelize": ">=6.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize-typescript/node_modules/glob": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
|
||||||
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^3.0.4",
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"path-is-absolute": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sequelize/node_modules/debug": {
|
"node_modules/sequelize/node_modules/debug": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||||
@ -2921,6 +3512,26 @@
|
|||||||
"node": ">= 14.0.0"
|
"node": ">= 14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
|
||||||
|
"integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.25.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type": {
|
"node_modules/type": {
|
||||||
"version": "2.7.3",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||||
@ -2944,6 +3555,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -3206,6 +3818,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
24
package.json
24
package.json
@ -2,13 +2,13 @@
|
|||||||
"name": "spotify-manager",
|
"name": "spotify-manager",
|
||||||
"version": "0",
|
"version": "0",
|
||||||
"description": "Personal Spotify playlist manager",
|
"description": "Personal Spotify playlist manager",
|
||||||
"exports": "./index.js",
|
"exports": "./index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_ENV=development nodemon --delay 2 --exitcrash index.js",
|
"dev": "cross-env NODE_ENV=development tsx watch index.ts",
|
||||||
"test_setup": "npm i && cross-env NODE_ENV=test npx sequelize-cli db:migrate",
|
"test_setup": "npm i && cross-env NODE_ENV=test npx sequelize-cli db:migrate",
|
||||||
"test": "cross-env NODE_ENV=test node index.js",
|
"test": "NODE_ENV=test tsx index.ts",
|
||||||
"prod": "NODE_ENV=production node index.js"
|
"prod": "NODE_ENV=production tsx index.ts"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -21,19 +21,20 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/20kaushik02/spotify-manager#readme",
|
"homepage": "https://github.com/20kaushik02/spotify-manager#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.8.2",
|
||||||
"axios-rate-limit": "^1.4.0",
|
|
||||||
"connect-redis": "^8.0.1",
|
"connect-redis": "^8.0.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv-flow": "^4.1.0",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
"sequelize": "^6.37.5",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"sequelize": "^6.37.6",
|
||||||
|
"sequelize-typescript": "^2.1.6",
|
||||||
"serializr": "^3.0.3",
|
"serializr": "^3.0.3",
|
||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
@ -42,10 +43,13 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/sequelize": "^4.28.20",
|
||||||
|
"@types/validator": "^13.12.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"nodemon": "^3.1.9",
|
"nodemon": "^3.1.9",
|
||||||
"sequelize-cli": "^6.6.2",
|
"sequelize-cli": "^6.6.2",
|
||||||
"typescript": "^5.7.3"
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
import { login, callback, refresh, logout } from "../controllers/auth.js";
|
|
||||||
import { isAuthenticated } from "../middleware/authCheck.js";
|
|
||||||
import { validate } from "../validators/index.js";
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/login",
|
|
||||||
login
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/callback",
|
|
||||||
callback
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/refresh",
|
|
||||||
isAuthenticated,
|
|
||||||
refresh
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/logout",
|
|
||||||
logout
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
16
routes/auth.ts
Normal file
16
routes/auth.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
const authRouter: Router = Router();
|
||||||
|
|
||||||
|
import { login, callback, refresh, logout } from "../controllers/auth.ts";
|
||||||
|
import { isAuthenticated } from "../middleware/authCheck.ts";
|
||||||
|
import { validate } from "../validators/index.ts";
|
||||||
|
|
||||||
|
authRouter.get("/login", login);
|
||||||
|
|
||||||
|
authRouter.get("/callback", callback);
|
||||||
|
|
||||||
|
authRouter.get("/refresh", isAuthenticated, refresh);
|
||||||
|
|
||||||
|
authRouter.get("/logout", logout);
|
||||||
|
|
||||||
|
export default authRouter;
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
import { updateUser, fetchUser, createLink, removeLink, populateSingleLink, pruneSingleLink } from "../controllers/operations.js";
|
|
||||||
import { createLinkValidator, removeLinkValidator, populateSingleLinkValidator, pruneSingleLinkValidator } from "../validators/operations.js";
|
|
||||||
|
|
||||||
import { validate } from "../validators/index.js";
|
|
||||||
|
|
||||||
router.put(
|
|
||||||
"/update",
|
|
||||||
updateUser
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/fetch",
|
|
||||||
fetchUser
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/link",
|
|
||||||
createLinkValidator,
|
|
||||||
validate,
|
|
||||||
createLink
|
|
||||||
);
|
|
||||||
|
|
||||||
router.delete(
|
|
||||||
"/link",
|
|
||||||
removeLinkValidator,
|
|
||||||
validate,
|
|
||||||
removeLink
|
|
||||||
);
|
|
||||||
|
|
||||||
router.put(
|
|
||||||
"/populate/link",
|
|
||||||
populateSingleLinkValidator,
|
|
||||||
validate,
|
|
||||||
populateSingleLink
|
|
||||||
);
|
|
||||||
|
|
||||||
router.put(
|
|
||||||
"/prune/link",
|
|
||||||
pruneSingleLinkValidator,
|
|
||||||
validate,
|
|
||||||
pruneSingleLink
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
43
routes/operations.ts
Normal file
43
routes/operations.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
const opRouter: Router = Router();
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateUser,
|
||||||
|
fetchUser,
|
||||||
|
createLink,
|
||||||
|
removeLink,
|
||||||
|
populateSingleLink,
|
||||||
|
pruneSingleLink,
|
||||||
|
} from "../controllers/operations.ts";
|
||||||
|
import {
|
||||||
|
createLinkValidator,
|
||||||
|
removeLinkValidator,
|
||||||
|
populateSingleLinkValidator,
|
||||||
|
pruneSingleLinkValidator,
|
||||||
|
} from "../validators/operations.ts";
|
||||||
|
|
||||||
|
import { validate } from "../validators/index.ts";
|
||||||
|
|
||||||
|
opRouter.put("/update", updateUser);
|
||||||
|
|
||||||
|
opRouter.get("/fetch", fetchUser);
|
||||||
|
|
||||||
|
opRouter.post("/link", createLinkValidator, validate, createLink);
|
||||||
|
|
||||||
|
opRouter.delete("/link", removeLinkValidator, validate, removeLink);
|
||||||
|
|
||||||
|
opRouter.put(
|
||||||
|
"/populate/link",
|
||||||
|
populateSingleLinkValidator,
|
||||||
|
validate,
|
||||||
|
populateSingleLink
|
||||||
|
);
|
||||||
|
|
||||||
|
opRouter.put(
|
||||||
|
"/prune/link",
|
||||||
|
pruneSingleLinkValidator,
|
||||||
|
validate,
|
||||||
|
pruneSingleLink
|
||||||
|
);
|
||||||
|
|
||||||
|
export default opRouter;
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
import { fetchUserPlaylists, fetchPlaylistDetails } from "../controllers/playlists.js";
|
|
||||||
import { getPlaylistDetailsValidator } from "../validators/playlists.js";
|
|
||||||
|
|
||||||
import { validate } from "../validators/index.js";
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/me",
|
|
||||||
fetchUserPlaylists
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/details",
|
|
||||||
getPlaylistDetailsValidator,
|
|
||||||
validate,
|
|
||||||
fetchPlaylistDetails
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
10
routes/playlists.ts
Normal file
10
routes/playlists.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
import { fetchUserPlaylists } from "../controllers/playlists.ts";
|
||||||
|
|
||||||
|
import { validate } from "../validators/index.ts";
|
||||||
|
|
||||||
|
router.get("/me", fetchUserPlaylists);
|
||||||
|
|
||||||
|
export default router;
|
||||||
113
tsconfig.json
Normal file
113
tsconfig.json
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"exclude": [
|
||||||
|
"./boilerplates"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
/* Projects */
|
||||||
|
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||||
|
"composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
|
"lib": [
|
||||||
|
"ESNext"
|
||||||
|
], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "libReplacement": true, /* Enable lib replacement. */
|
||||||
|
"experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||||
|
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
"useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
"moduleDetection": "force", /* Control what method is used to detect module-format JS files. */
|
||||||
|
/* Modules */
|
||||||
|
"module": "NodeNext", /* Specify what module code is generated. */
|
||||||
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
|
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types",
|
||||||
|
"./types",
|
||||||
|
], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
|
"allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||||
|
"rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||||
|
"resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||||
|
"resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||||
|
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||||
|
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||||
|
"resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
|
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||||
|
/* Emit */
|
||||||
|
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
"declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
"outDir": "./tsout", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
/* Interop Constraints */
|
||||||
|
"isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
"verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||||
|
"isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||||
|
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||||
|
"strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||||
|
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
"strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
|
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||||
|
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
"useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||||
|
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||||
|
"exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
"noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||||
|
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
"noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||||
|
"allowUnusedLabels": false, /* Disable error reporting for unused labels. */
|
||||||
|
"allowUnreachableCode": false, /* Disable error reporting for unreachable code. */
|
||||||
|
/* Completeness */
|
||||||
|
"skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
}
|
||||||
|
}
|
||||||
67
typedefs.js
67
typedefs.js
@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* @typedef {import("module")} Module
|
|
||||||
*
|
|
||||||
* @typedef {import("express").Request} Req
|
|
||||||
* @typedef {import("express").Response} Res
|
|
||||||
* @typedef {import("express").NextFunction} Next
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* type: string,
|
|
||||||
* is_local: boolean,
|
|
||||||
* id: string,
|
|
||||||
* artist?: string,
|
|
||||||
* album?: string,
|
|
||||||
* title?: string,
|
|
||||||
* duration?: number
|
|
||||||
* }} URIObject
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* username: string,
|
|
||||||
* uri: string
|
|
||||||
* }} User
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* name: string,
|
|
||||||
* uri: string,
|
|
||||||
* }} SimplifiedPlaylist
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* name: string
|
|
||||||
* }} Album
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* name: string
|
|
||||||
* }} Artist
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* uri: string,
|
|
||||||
* name: string,
|
|
||||||
* artists: Artist[]
|
|
||||||
* album: Album,
|
|
||||||
* is_local: boolean,
|
|
||||||
* }} Track
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* added_at: string,
|
|
||||||
* track: Track,
|
|
||||||
* }} PlaylistTrack
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* url: string,
|
|
||||||
* height: number,
|
|
||||||
* width: number
|
|
||||||
* }} ImageObject
|
|
||||||
*
|
|
||||||
* @typedef {{
|
|
||||||
* uri: string,
|
|
||||||
* name: string,
|
|
||||||
* description: string,
|
|
||||||
* collaborative: boolean,
|
|
||||||
* public: boolean,
|
|
||||||
* owner: User,
|
|
||||||
* images: ImageObject[],
|
|
||||||
* tracks: PlaylistTrack[],
|
|
||||||
* }} PlaylistDetails
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default {};
|
|
||||||
14
types/express-session.d.ts
vendored
Normal file
14
types/express-session.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { AxiosRequestHeaders } from "axios";
|
||||||
|
import type { User } from "spotify_manager/index.d.ts";
|
||||||
|
|
||||||
|
declare module "express-session" {
|
||||||
|
// added properties
|
||||||
|
interface SessionData {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
authHeaders: AxiosRequestHeaders;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
59
types/spotify_manager/common.types.ts
Normal file
59
types/spotify_manager/common.types.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// COMMON
|
||||||
|
export type AlbumType = "album" | "single" | "compilation";
|
||||||
|
/**
|
||||||
|
* The markets in which the album is available: {@link https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2|ISO 3166-1 alpha-2 country codes}
|
||||||
|
*
|
||||||
|
* NOTE: an album is considered available in a market when at least 1 of its tracks is available in that market.
|
||||||
|
*/
|
||||||
|
export type AvailableMarkets = string[];
|
||||||
|
export type CopyrightObject = {
|
||||||
|
text: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
export type ExternalURLs = {
|
||||||
|
spotify: string;
|
||||||
|
};
|
||||||
|
export type ExternalIDs = {
|
||||||
|
isrc: string;
|
||||||
|
ean: string;
|
||||||
|
upc: string;
|
||||||
|
};
|
||||||
|
export type Followers = {
|
||||||
|
href: string | null;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
export type ImageObject = {
|
||||||
|
/** valid for 24 hours from retrieval */ url: string;
|
||||||
|
/** in pixels */ height: number | null;
|
||||||
|
/** in pixels */ width: number | null;
|
||||||
|
};
|
||||||
|
export type LinkedFrom = {
|
||||||
|
external_urls?: ExternalURLs;
|
||||||
|
href?: string;
|
||||||
|
id?: string;
|
||||||
|
type?: "track";
|
||||||
|
uri?: string;
|
||||||
|
};
|
||||||
|
export type Pagination<T> = {
|
||||||
|
href: string;
|
||||||
|
limit: number;
|
||||||
|
next: string | null;
|
||||||
|
offset: number;
|
||||||
|
previous: string | null;
|
||||||
|
total: number;
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
|
export type PaginationByCursor<T> = {
|
||||||
|
href?: string;
|
||||||
|
limit?: number;
|
||||||
|
next?: string;
|
||||||
|
cursors?: {
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
};
|
||||||
|
total?: number;
|
||||||
|
items?: T[];
|
||||||
|
};
|
||||||
|
export type Restrictions = {
|
||||||
|
reason: "market" | "product" | "explicit";
|
||||||
|
};
|
||||||
28
types/spotify_manager/custom.types.ts
Normal file
28
types/spotify_manager/custom.types.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// APPLICATION
|
||||||
|
export type URIObject = {
|
||||||
|
type: string;
|
||||||
|
is_local: boolean;
|
||||||
|
id: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
title?: string;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
username: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlaylistModel_Pl {
|
||||||
|
playlistID: string;
|
||||||
|
playlistName: string;
|
||||||
|
}
|
||||||
|
export interface PlaylistModel extends PlaylistModel_Pl {
|
||||||
|
userID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkModel_Edge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
134
types/spotify_manager/endpoints.types.ts
Normal file
134
types/spotify_manager/endpoints.types.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import type {
|
||||||
|
ImageObject,
|
||||||
|
Pagination,
|
||||||
|
PaginationByCursor,
|
||||||
|
} from "./common.types.ts";
|
||||||
|
import type {
|
||||||
|
AlbumObject,
|
||||||
|
ArtistObject,
|
||||||
|
ArtistsAlbumObject,
|
||||||
|
EpisodeObject,
|
||||||
|
PlaylistObject,
|
||||||
|
PlaylistTrackObject,
|
||||||
|
SavedAlbumObject,
|
||||||
|
SavedEpisodeObject,
|
||||||
|
SavedShowObject,
|
||||||
|
SavedTrackObject,
|
||||||
|
ShowObject,
|
||||||
|
SimplifiedAlbumObject,
|
||||||
|
SimplifiedEpisodeObject,
|
||||||
|
SimplifiedPlaylistObject,
|
||||||
|
SimplifiedShowObject,
|
||||||
|
SimplifiedTrackObject,
|
||||||
|
SimplifiedUserObject,
|
||||||
|
TrackObject,
|
||||||
|
UserObject,
|
||||||
|
} from "./objects.types.ts";
|
||||||
|
|
||||||
|
// GET method
|
||||||
|
// Albums
|
||||||
|
export type GetAlbum = AlbumObject;
|
||||||
|
export type GetSeveralAlbums = { albums: AlbumObject[] };
|
||||||
|
export type GetAlbumTracks = Pagination<SimplifiedTrackObject>;
|
||||||
|
export type GetUsersSavedAlbums = Pagination<SavedAlbumObject>;
|
||||||
|
export type CheckUsersSavedAlbums = boolean[];
|
||||||
|
export type GetNewReleases = { albums: Pagination<SimplifiedAlbumObject> };
|
||||||
|
|
||||||
|
// Artists
|
||||||
|
export type GetArtist = ArtistObject;
|
||||||
|
export type GetSeveralArtists = { artists: ArtistObject[] };
|
||||||
|
export type GetArtistsAlbums = Pagination<ArtistsAlbumObject>;
|
||||||
|
export type GetArtistsTopTracks = { tracks: TrackObject[] };
|
||||||
|
|
||||||
|
// Episodes
|
||||||
|
export type GetEpisode = EpisodeObject;
|
||||||
|
export type GetSeveralEpisodes = { episodes: EpisodeObject[] };
|
||||||
|
export type GetUsersSavedEpisodes = Pagination<SavedEpisodeObject>;
|
||||||
|
|
||||||
|
// Shows
|
||||||
|
export type GetShow = ShowObject;
|
||||||
|
export type GetSeveralShows = { shows: SimplifiedShowObject[] };
|
||||||
|
export type GetShowEpisodes = Pagination<SimplifiedEpisodeObject>;
|
||||||
|
export type GetUsersSavedShows = Pagination<SavedShowObject>;
|
||||||
|
|
||||||
|
// Playlists
|
||||||
|
export type GetPlaylist = PlaylistObject;
|
||||||
|
export type GetPlaylistItems = Pagination<PlaylistTrackObject>;
|
||||||
|
export type GetCurrentUsersPlaylists = Pagination<SimplifiedPlaylistObject>;
|
||||||
|
export type GetUsersPlaylists = GetCurrentUsersPlaylists;
|
||||||
|
export type GetPlaylistCoverImage = ImageObject[];
|
||||||
|
|
||||||
|
// Tracks
|
||||||
|
export type GetTrack = TrackObject;
|
||||||
|
export type GetSeveralTracks = { tracks: TrackObject[] };
|
||||||
|
export type GetUsersSavedTracks = Pagination<SavedTrackObject>;
|
||||||
|
export type CheckUsersSavedTracks = boolean[];
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export type GetCurrentUsersProfile = UserObject;
|
||||||
|
export type GetUsersTopItems =
|
||||||
|
| Pagination<ArtistObject>
|
||||||
|
| Pagination<TrackObject>;
|
||||||
|
export type GetUsersProfile = SimplifiedUserObject;
|
||||||
|
export type GetFollowedArtists = { artists: PaginationByCursor<ArtistObject> };
|
||||||
|
export type CheckIfUserFollowsArtistsOrNot = boolean[];
|
||||||
|
export type CheckIfCurrentUserFollowsPlaylist = boolean[];
|
||||||
|
|
||||||
|
// POST method
|
||||||
|
// Albums
|
||||||
|
// Artists
|
||||||
|
// Episodes
|
||||||
|
// Shows
|
||||||
|
|
||||||
|
// Playlists
|
||||||
|
export type AddItemsToPlaylist = { snapshot_id: string };
|
||||||
|
export type CreatePlaylist = PlaylistObject;
|
||||||
|
|
||||||
|
// Tracks
|
||||||
|
// Users
|
||||||
|
|
||||||
|
// PUT method
|
||||||
|
// Albums
|
||||||
|
export type SaveAlbumsForCurrentUser = {};
|
||||||
|
// Artists
|
||||||
|
// Episodes
|
||||||
|
// Shows
|
||||||
|
|
||||||
|
// Playlists
|
||||||
|
export type ChangePlaylistDetails = {};
|
||||||
|
export type UpdatePlaylistItems = { snapshot_id: string };
|
||||||
|
export type AddCustomPlaylistCoverImage = {};
|
||||||
|
|
||||||
|
// Tracks
|
||||||
|
export type SaveTracksForCurrentUser = {};
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export type FollowPlaylist = {};
|
||||||
|
export type FollowArtistsOrUsers = {};
|
||||||
|
|
||||||
|
// DELETE method
|
||||||
|
// Albums
|
||||||
|
export type RemoveUsersSavedAlbums = {};
|
||||||
|
|
||||||
|
// Artists
|
||||||
|
// Episodes
|
||||||
|
// Shows
|
||||||
|
|
||||||
|
// Playlists
|
||||||
|
export type RemovePlaylistItems = { snapshot_id: string };
|
||||||
|
|
||||||
|
// Tracks
|
||||||
|
export type RemoveUsersSavedTracks = {};
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export type UnfollowPlaylist = {};
|
||||||
|
export type UnfollowArtistsOrUsers = {};
|
||||||
|
|
||||||
|
// <insert other method> method
|
||||||
|
// Albums
|
||||||
|
// Artists
|
||||||
|
// Episodes
|
||||||
|
// Shows
|
||||||
|
// Playlists
|
||||||
|
// Tracks
|
||||||
|
// Users
|
||||||
5
types/spotify_manager/index.d.ts
vendored
Normal file
5
types/spotify_manager/index.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from "./common.types.ts";
|
||||||
|
export * from "./custom.types.ts";
|
||||||
|
export * from "./endpoints.types.ts";
|
||||||
|
export * from "./objects.types.ts";
|
||||||
|
export * from "./shorthands.types.ts";
|
||||||
189
types/spotify_manager/objects.types.ts
Normal file
189
types/spotify_manager/objects.types.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import type {
|
||||||
|
AlbumType,
|
||||||
|
AvailableMarkets,
|
||||||
|
CopyrightObject,
|
||||||
|
ExternalIDs,
|
||||||
|
ExternalURLs,
|
||||||
|
Followers,
|
||||||
|
ImageObject,
|
||||||
|
LinkedFrom,
|
||||||
|
Pagination,
|
||||||
|
Restrictions,
|
||||||
|
} from "./common.types.ts";
|
||||||
|
|
||||||
|
// OBJECTS
|
||||||
|
export interface SimplifiedArtistObject {
|
||||||
|
external_urls: ExternalURLs;
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "artist";
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
export interface ArtistObject extends SimplifiedArtistObject {
|
||||||
|
followers: Followers;
|
||||||
|
genres: string[];
|
||||||
|
images: ImageObject[];
|
||||||
|
popularity: number;
|
||||||
|
}
|
||||||
|
export interface SimplifiedAlbumObject {
|
||||||
|
album_type: AlbumType;
|
||||||
|
artists: SimplifiedArtistObject[];
|
||||||
|
available_markets: AvailableMarkets;
|
||||||
|
external_urls: ExternalURLs;
|
||||||
|
// genres: string[]; // deprecated
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
images: ImageObject[];
|
||||||
|
name: string;
|
||||||
|
release_date: string;
|
||||||
|
release_date_precision: "year" | "month" | "day";
|
||||||
|
restrictions?: Restrictions;
|
||||||
|
total_tracks: number;
|
||||||
|
type: "album";
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
export interface ArtistsAlbumObject extends SimplifiedAlbumObject {
|
||||||
|
album_group: "album" | "single" | "compilation" | "appears_on";
|
||||||
|
}
|
||||||
|
export interface AlbumObject extends SimplifiedAlbumObject {
|
||||||
|
copyrights: CopyrightObject[];
|
||||||
|
external_ids: ExternalIDs;
|
||||||
|
label: string;
|
||||||
|
popularity: number;
|
||||||
|
tracks: Pagination<SimplifiedTrackObject>;
|
||||||
|
}
|
||||||
|
export type SavedAlbumObject = {
|
||||||
|
added_at: string;
|
||||||
|
album: AlbumObject;
|
||||||
|
};
|
||||||
|
export interface SimplifiedEpisodeObject {
|
||||||
|
description: string;
|
||||||
|
html_description: string;
|
||||||
|
duration_ms: number;
|
||||||
|
explicit: boolean;
|
||||||
|
external_urls: ExternalURLs;
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
images: ImageObject[];
|
||||||
|
is_externally_hosted: boolean;
|
||||||
|
is_playable: boolean;
|
||||||
|
languages: string[];
|
||||||
|
name: string;
|
||||||
|
release_date: string;
|
||||||
|
release_date_precision: string;
|
||||||
|
type: "episode";
|
||||||
|
uri: string;
|
||||||
|
restrictions?: Restrictions;
|
||||||
|
}
|
||||||
|
export interface EpisodeObject extends SimplifiedEpisodeObject {
|
||||||
|
show: ShowObject;
|
||||||
|
}
|
||||||
|
export type SavedEpisodeObject = {
|
||||||
|
added_at: string;
|
||||||
|
episode: EpisodeObject;
|
||||||
|
};
|
||||||
|
export interface SimplifiedShowObject {
|
||||||
|
available_markets?: AvailableMarkets;
|
||||||
|
copyrights?: CopyrightObject[];
|
||||||
|
description?: string;
|
||||||
|
html_description?: string;
|
||||||
|
explicit?: boolean;
|
||||||
|
external_urls?: ExternalURLs;
|
||||||
|
href?: string;
|
||||||
|
id?: string;
|
||||||
|
images?: ImageObject[];
|
||||||
|
is_externally_hosted?: boolean;
|
||||||
|
languages?: string[];
|
||||||
|
media_type?: string;
|
||||||
|
name?: string;
|
||||||
|
publisher?: string;
|
||||||
|
type: "show";
|
||||||
|
uri?: string;
|
||||||
|
total_episodes?: number;
|
||||||
|
}
|
||||||
|
export interface ShowObject extends SimplifiedShowObject {
|
||||||
|
episodes?: Pagination<SimplifiedEpisodeObject>;
|
||||||
|
}
|
||||||
|
export type SavedShowObject = {
|
||||||
|
added_at?: string;
|
||||||
|
show?: SimplifiedShowObject;
|
||||||
|
};
|
||||||
|
export interface SimplifiedTrackObject {
|
||||||
|
artists: SimplifiedArtistObject[];
|
||||||
|
available_markets: AvailableMarkets;
|
||||||
|
disc_number: number;
|
||||||
|
duration_ms: number;
|
||||||
|
explicit: boolean;
|
||||||
|
external_urls: ExternalURLs;
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
is_playable?: boolean;
|
||||||
|
linked_from?: LinkedFrom;
|
||||||
|
restrictions?: Restrictions;
|
||||||
|
name: string;
|
||||||
|
// preview_url?: string; // deprecated
|
||||||
|
track_number: number;
|
||||||
|
type: "track";
|
||||||
|
uri: string;
|
||||||
|
is_local: boolean;
|
||||||
|
}
|
||||||
|
export interface TrackObject extends SimplifiedTrackObject {
|
||||||
|
album: SimplifiedAlbumObject;
|
||||||
|
external_ids: ExternalIDs;
|
||||||
|
popularity: number;
|
||||||
|
}
|
||||||
|
export type SavedTrackObject = {
|
||||||
|
added_at: string;
|
||||||
|
track: TrackObject;
|
||||||
|
};
|
||||||
|
export interface SimplifiedUserObject {
|
||||||
|
display_name: string | null;
|
||||||
|
external_urls: ExternalURLs;
|
||||||
|
followers: Followers;
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
images: ImageObject[];
|
||||||
|
type: "user";
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserObject extends SimplifiedUserObject {
|
||||||
|
country?: string;
|
||||||
|
email?: string;
|
||||||
|
explicit_content?: {
|
||||||
|
filter_enabled: boolean;
|
||||||
|
filter_locked: boolean;
|
||||||
|
};
|
||||||
|
product?: string;
|
||||||
|
}
|
||||||
|
export type PlaylistTrackObject = {
|
||||||
|
added_at: string | null;
|
||||||
|
added_by: SimplifiedUserObject | null;
|
||||||
|
is_local: boolean;
|
||||||
|
track: TrackObject | EpisodeObject;
|
||||||
|
};
|
||||||
|
interface BasePlaylistObject {
|
||||||
|
collaborative: boolean;
|
||||||
|
description: string | null;
|
||||||
|
external_urls: ExternalURLs;
|
||||||
|
href: string;
|
||||||
|
id: string;
|
||||||
|
images: ImageObject[];
|
||||||
|
name: string;
|
||||||
|
owner: SimplifiedUserObject;
|
||||||
|
public: boolean | null;
|
||||||
|
snapshot_id: string;
|
||||||
|
type: "playlist";
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
export interface SimplifiedPlaylistObject extends BasePlaylistObject {
|
||||||
|
tracks: {
|
||||||
|
href: string;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface PlaylistObject extends BasePlaylistObject {
|
||||||
|
tracks: Pagination<PlaylistTrackObject>;
|
||||||
|
followers: Followers;
|
||||||
|
}
|
||||||
10
types/spotify_manager/shorthands.types.ts
Normal file
10
types/spotify_manager/shorthands.types.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export type Req = Request;
|
||||||
|
export type Res = Response;
|
||||||
|
export type Next = NextFunction;
|
||||||
|
|
||||||
|
export interface EndpointHandlerBaseArgs {
|
||||||
|
req: Req;
|
||||||
|
res: Res;
|
||||||
|
}
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
export const randomBool = (chance_of_failure = 0.25) => Math.random() < chance_of_failure;
|
|
||||||
7
utils/flake.ts
Normal file
7
utils/flake.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const sleep = (ms: number): Promise<unknown> =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const randomBool = (chance_of_failure = 0.25): boolean =>
|
||||||
|
Math.random() < chance_of_failure;
|
||||||
|
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
@ -1,10 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Generates a random string containing numbers and letters
|
* Generates a random string containing numbers and letters
|
||||||
* @param {number} length The length of the string
|
|
||||||
* @return {string} The generated string
|
|
||||||
*/
|
*/
|
||||||
export default (length) => {
|
export const generateRandString = (length: number): string => {
|
||||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
const possible =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
@ -1,7 +1,5 @@
|
|||||||
import curriedLogger from "./logger.js";
|
export type GNode = string;
|
||||||
const logger = curriedLogger(import.meta);
|
export type GEdge = { from: string; to: string };
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directed graph, may or may not be connected.
|
* Directed graph, may or may not be connected.
|
||||||
@ -21,46 +19,38 @@ import * as typedefs from "../typedefs.js";
|
|||||||
* let g = new myGraph(nodes, edges);
|
* let g = new myGraph(nodes, edges);
|
||||||
* console.log(g.detectCycle()); // true
|
* console.log(g.detectCycle()); // true
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class myGraph {
|
export class myGraph {
|
||||||
|
nodes: GNode[];
|
||||||
|
edges: GEdge[];
|
||||||
/**
|
/**
|
||||||
* @param {string[]} nodes Graph nodes IDs
|
* @param nodes Graph nodes IDs
|
||||||
* @param {{ from: string, to: string }[]} edges Graph edges b/w nodes
|
* @param edges Graph edges b/w nodes
|
||||||
*/
|
*/
|
||||||
constructor(nodes, edges) {
|
constructor(nodes: GNode[], edges: GEdge[]) {
|
||||||
this.nodes = [...nodes];
|
this.nodes = structuredClone(nodes);
|
||||||
this.edges = structuredClone(edges);
|
this.edges = structuredClone(edges);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getDirectHeads(node: GNode): GNode[] {
|
||||||
* @param {string} node
|
return this.edges
|
||||||
* @returns {string[]}
|
.filter((edge) => edge.to == node)
|
||||||
*/
|
.map((edge) => edge.from);
|
||||||
getDirectHeads(node) {
|
|
||||||
return this.edges.filter(edge => edge.to == node).map(edge => edge.from);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getDirectHeadEdges(node: GNode): GEdge[] {
|
||||||
* @param {string} node
|
return this.edges.filter((edge) => edge.to == node);
|
||||||
* @returns {{ from: string, to: string }[]}
|
|
||||||
*/
|
|
||||||
getDirectHeadEdges(node) {
|
|
||||||
return this.edges.filter(edge => edge.to == node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** BFS */
|
||||||
* BFS
|
getAllHeads(node: GNode): GNode[] {
|
||||||
* @param {string} node
|
const headSet = new Set<GNode>();
|
||||||
* @returns {string[]}
|
const toVisit = new Set<GNode>(); // queue
|
||||||
*/
|
|
||||||
getAllHeads(node) {
|
|
||||||
const headSet = new Set();
|
|
||||||
const toVisit = new Set(); // queue
|
|
||||||
toVisit.add(node);
|
toVisit.add(node);
|
||||||
while (toVisit.size > 0) {
|
while (toVisit.size > 0) {
|
||||||
const nextNode = toVisit.values().next().value;
|
const nextNode = toVisit.values().next().value!;
|
||||||
const nextHeads = this.getDirectHeads(nextNode);
|
const nextHeads = this.getDirectHeads(nextNode);
|
||||||
nextHeads.forEach(head => {
|
nextHeads.forEach((head) => {
|
||||||
headSet.add(head);
|
headSet.add(head);
|
||||||
toVisit.add(head);
|
toVisit.add(head);
|
||||||
});
|
});
|
||||||
@ -69,35 +59,29 @@ export class myGraph {
|
|||||||
return [...headSet];
|
return [...headSet];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getDirectTails(node: GNode): GNode[] {
|
||||||
* @param {string} node
|
return this.edges
|
||||||
* @returns {string[]}
|
.filter((edge) => edge.from == node)
|
||||||
*/
|
.map((edge) => edge.to);
|
||||||
getDirectTails(node) {
|
|
||||||
return this.edges.filter(edge => edge.from == node).map(edge => edge.to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} node
|
* @param {string} node
|
||||||
* @returns {{ from: string, to: string }[]}
|
* @returns {{ from: string, to: string }[]}
|
||||||
*/
|
*/
|
||||||
getDirectTailEdges(node) {
|
getDirectTailEdges(node: GNode): GEdge[] {
|
||||||
return this.edges.filter(edge => edge.from == node);
|
return this.edges.filter((edge) => edge.from == node);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** BFS */
|
||||||
* BFS
|
getAllTails(node: GNode): GNode[] {
|
||||||
* @param {string} node
|
const tailSet = new Set<GNode>();
|
||||||
* @returns {string[]}
|
const toVisit = new Set<GNode>(); // queue
|
||||||
*/
|
|
||||||
getAllTails(node) {
|
|
||||||
const tailSet = new Set();
|
|
||||||
const toVisit = new Set(); // queue
|
|
||||||
toVisit.add(node);
|
toVisit.add(node);
|
||||||
while (toVisit.size > 0) {
|
while (toVisit.size > 0) {
|
||||||
const nextNode = toVisit.values().next().value;
|
const nextNode = toVisit.values().next().value!;
|
||||||
const nextTails = this.getDirectTails(nextNode);
|
const nextTails = this.getDirectTails(nextNode);
|
||||||
nextTails.forEach(tail => {
|
nextTails.forEach((tail) => {
|
||||||
tailSet.add(tail);
|
tailSet.add(tail);
|
||||||
toVisit.add(tail);
|
toVisit.add(tail);
|
||||||
});
|
});
|
||||||
@ -106,14 +90,11 @@ export class myGraph {
|
|||||||
return [...tailSet];
|
return [...tailSet];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Kahn's topological sort */
|
||||||
* Kahn's topological sort
|
topoSort(): GNode[] {
|
||||||
* @returns {string[]}
|
let inDegree: Record<string, number> = {};
|
||||||
*/
|
let zeroInDegreeQueue: GNode[] = [];
|
||||||
topoSort() {
|
let topologicalOrder: GNode[] = [];
|
||||||
let inDegree = {};
|
|
||||||
let zeroInDegreeQueue = [];
|
|
||||||
let topologicalOrder = [];
|
|
||||||
|
|
||||||
// Initialize inDegree of all nodes to 0
|
// Initialize inDegree of all nodes to 0
|
||||||
for (let node of this.nodes) {
|
for (let node of this.nodes) {
|
||||||
@ -122,7 +103,7 @@ export class myGraph {
|
|||||||
|
|
||||||
// Calculate inDegree of each node
|
// Calculate inDegree of each node
|
||||||
for (let edge of this.edges) {
|
for (let edge of this.edges) {
|
||||||
inDegree[edge.to]++;
|
inDegree[edge.to]!++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect nodes with 0 inDegree
|
// Collect nodes with 0 inDegree
|
||||||
@ -135,10 +116,10 @@ export class myGraph {
|
|||||||
// process nodes with 0 inDegree
|
// process nodes with 0 inDegree
|
||||||
while (zeroInDegreeQueue.length > 0) {
|
while (zeroInDegreeQueue.length > 0) {
|
||||||
let node = zeroInDegreeQueue.shift();
|
let node = zeroInDegreeQueue.shift();
|
||||||
topologicalOrder.push(node);
|
topologicalOrder.push(node!);
|
||||||
|
|
||||||
for (let tail of this.getDirectTails(node)) {
|
for (let tail of this.getDirectTails(node!)) {
|
||||||
inDegree[tail]--;
|
inDegree[tail]!--;
|
||||||
if (inDegree[tail] === 0) {
|
if (inDegree[tail] === 0) {
|
||||||
zeroInDegreeQueue.push(tail);
|
zeroInDegreeQueue.push(tail);
|
||||||
}
|
}
|
||||||
@ -147,11 +128,8 @@ export class myGraph {
|
|||||||
return topologicalOrder;
|
return topologicalOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Check if the graph contains a cycle */
|
||||||
* Check if the graph contains a cycle
|
detectCycle(): boolean {
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
detectCycle() {
|
|
||||||
// If topological order includes all nodes, no cycle exists
|
// If topological order includes all nodes, no cycle exists
|
||||||
return this.topoSort().length < this.nodes.length;
|
return this.topoSort().length < this.nodes.length;
|
||||||
}
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stringifies only values of a JSON object, including nested ones
|
|
||||||
*
|
|
||||||
* @param {any} obj JSON object
|
|
||||||
* @param {string} delimiter Delimiter of final string
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export 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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return values.join(delimiter);
|
|
||||||
}
|
|
||||||
16
utils/jsonTransformer.ts
Normal file
16
utils/jsonTransformer.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/** Stringifies only values of a JSON object, including nested ones */
|
||||||
|
export const getNestedValuesString = (
|
||||||
|
obj: any,
|
||||||
|
delimiter: string = ", "
|
||||||
|
): string => {
|
||||||
|
let values: string[] = [];
|
||||||
|
for (const key in obj) {
|
||||||
|
if (typeof obj[key] !== "object") {
|
||||||
|
values.push(obj[key]);
|
||||||
|
} else {
|
||||||
|
values = values.concat(getNestedValuesString(obj[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.join(delimiter);
|
||||||
|
};
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import path from "path";
|
|
||||||
|
|
||||||
import { createLogger, transports, config, format } from "winston";
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
const { combine, label, timestamp, printf, errors } = format;
|
|
||||||
|
|
||||||
const getLabel = (callingModule) => {
|
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ?? ""}`;
|
|
||||||
}
|
|
||||||
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a curried function, and call it with the module in use to get logs with filename
|
|
||||||
* @param {typedefs.Module} callingModule The module from which the logger is called (ESM - import.meta)
|
|
||||||
*/
|
|
||||||
export 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: import.meta.dirname + "/../logs/debug.log",
|
|
||||||
level: "debug",
|
|
||||||
maxsize: 10485760,
|
|
||||||
}),
|
|
||||||
new transports.File({
|
|
||||||
filename: import.meta.dirname + "/../logs/error.log",
|
|
||||||
level: "error",
|
|
||||||
maxsize: 1048576,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
});
|
|
||||||
winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error }));
|
|
||||||
return winstonLogger;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default curriedLogger;
|
|
||||||
76
utils/logger.ts
Normal file
76
utils/logger.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { createLogger, transports, config, format, type Logger } from "winston";
|
||||||
|
|
||||||
|
const { combine, label, timestamp, printf, errors } = format;
|
||||||
|
|
||||||
|
const getLabel = (callingModuleName: string) => {
|
||||||
|
if (!callingModuleName) return "repl";
|
||||||
|
const parts = callingModuleName.split(path.sep);
|
||||||
|
return path.join(
|
||||||
|
parts[parts.length - 2] ?? "",
|
||||||
|
parts[parts.length - 1] ?? ""
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedErrorKeys = ["name", "code", "message", "stack"];
|
||||||
|
|
||||||
|
const metaFormat = (meta: Record<string, unknown>) => {
|
||||||
|
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"]) {
|
||||||
|
const sanitizedError = Object.fromEntries(
|
||||||
|
Object.entries(meta["error"]).filter(([key]) =>
|
||||||
|
allowedErrorKeys.includes(key)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stack, ...rest } = sanitizedError;
|
||||||
|
return (
|
||||||
|
`${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` +
|
||||||
|
`${stack ?? ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loggerCache = new Map<string, ReturnType<typeof createLogger>>();
|
||||||
|
|
||||||
|
const curriedLogger = (callingModuleName: string): Logger => {
|
||||||
|
if (loggerCache.has(callingModuleName)) {
|
||||||
|
return loggerCache.get(callingModuleName)!;
|
||||||
|
}
|
||||||
|
const winstonLogger = createLogger({
|
||||||
|
levels: config.npm.levels,
|
||||||
|
format: combine(
|
||||||
|
errors({ stack: true }),
|
||||||
|
label({ label: getLabel(callingModuleName) }),
|
||||||
|
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
||||||
|
logFormat
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new transports.Console({ level: "info" }),
|
||||||
|
new transports.File({
|
||||||
|
filename: import.meta.dirname + "/../logs/debug.log",
|
||||||
|
level: "debug",
|
||||||
|
maxsize: 10485760,
|
||||||
|
}),
|
||||||
|
new transports.File({
|
||||||
|
filename: import.meta.dirname + "/../logs/error.log",
|
||||||
|
level: "error",
|
||||||
|
maxsize: 1048576,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
winstonLogger.on("error", (error) =>
|
||||||
|
winstonLogger.error("Error inside logger", { error })
|
||||||
|
);
|
||||||
|
loggerCache.set(callingModuleName, winstonLogger);
|
||||||
|
return winstonLogger;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default curriedLogger;
|
||||||
@ -1,49 +1,57 @@
|
|||||||
import * as typedefs from "../typedefs.js";
|
import type { URIObject } from "spotify_manager/index.d.ts";
|
||||||
|
|
||||||
/** @type {RegExp} */
|
const base62Pattern: RegExp = /^[A-Za-z0-9]+$/;
|
||||||
const base62Pattern = /^[A-Za-z0-9]+$/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns type and ID from a Spotify URI
|
* Returns type and ID from a Spotify URI
|
||||||
* @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs}
|
* @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs}
|
||||||
* @param {string} uri Spotify URI - can be of an album, track, playlist, user, episode, etc.
|
* @param uri Spotify URI - can be of an album, track, playlist, user, episode, etc.
|
||||||
* @returns {typedefs.URIObject}
|
|
||||||
* @throws {TypeError} If the input is not a valid Spotify URI
|
* @throws {TypeError} If the input is not a valid Spotify URI
|
||||||
*/
|
*/
|
||||||
export const parseSpotifyURI = (uri) => {
|
const parseSpotifyURI = (uri: string): URIObject => {
|
||||||
const parts = uri.split(":");
|
const parts = uri.split(":");
|
||||||
|
|
||||||
if (parts[0] !== "spotify") {
|
if (parts[0] !== "spotify") {
|
||||||
throw new TypeError(`${uri} is not a valid Spotify URI`);
|
throw new TypeError(`${uri} is not a valid Spotify URI`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let type = parts[1];
|
let type = parts[1] ?? "";
|
||||||
|
|
||||||
|
// Local file format: spotify:local:<artist>:<album>:<title>:<duration>
|
||||||
if (type === "local") {
|
if (type === "local") {
|
||||||
// Local file format: spotify:local:<artist>:<album>:<title>:<duration>
|
|
||||||
let idParts = parts.slice(2);
|
let idParts = parts.slice(2);
|
||||||
if (idParts.length < 4) {
|
if (idParts.length !== 4) {
|
||||||
throw new TypeError(`${uri} is not a valid local file URI`);
|
throw new TypeError(`${uri} is not a valid local file URI`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL decode artist, album, and title
|
// URL decode artist, album, and title
|
||||||
const artist = decodeURIComponent(idParts[0] || "");
|
// NOTE: why do i have to do non-null assertion here...
|
||||||
const album = decodeURIComponent(idParts[1] || "");
|
const artist = decodeURIComponent(idParts[0] ?? "");
|
||||||
const title = decodeURIComponent(idParts[2]);
|
const album = decodeURIComponent(idParts[1] ?? "");
|
||||||
const duration = parseInt(idParts[3], 10);
|
const title = decodeURIComponent(idParts[2] ?? "");
|
||||||
|
let duration = parseInt(idParts[3] ?? "", 10);
|
||||||
|
|
||||||
if (isNaN(duration)) {
|
let uriObj: URIObject = {
|
||||||
throw new TypeError(`${uri} has an invalid duration`);
|
type: "track",
|
||||||
|
is_local: true,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
title,
|
||||||
|
id: "",
|
||||||
|
};
|
||||||
|
if (!isNaN(duration)) {
|
||||||
|
uriObj.duration = duration;
|
||||||
}
|
}
|
||||||
|
// throw new TypeError(`${uri} has an invalid duration`);
|
||||||
|
|
||||||
return { type: "track", is_local: true, artist, album, title, duration };
|
return uriObj;
|
||||||
} else {
|
} else {
|
||||||
// Not a local file
|
// Not a local file
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
throw new TypeError(`${uri} is not a valid Spotify URI`);
|
throw new TypeError(`${uri} is not a valid Spotify URI`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = parts[2];
|
const id = parts[2] ?? "";
|
||||||
|
|
||||||
if (!base62Pattern.test(id)) {
|
if (!base62Pattern.test(id)) {
|
||||||
throw new TypeError(`${uri} has an invalid ID`);
|
throw new TypeError(`${uri} has an invalid ID`);
|
||||||
@ -51,16 +59,16 @@ export const parseSpotifyURI = (uri) => {
|
|||||||
|
|
||||||
return { type, is_local: false, id };
|
return { type, is_local: false, id };
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns type and ID from a Spotify link
|
* Returns type and ID from a Spotify link
|
||||||
* @param {string} link Spotify URL - can be of an album, track, playlist, user, episode, etc.
|
* @param {string} link Spotify URL - can be of an album, track, playlist, user, episode, etc.
|
||||||
* @returns {typedefs.URIObject}
|
|
||||||
* @throws {TypeError} If the input is not a valid Spotify link
|
* @throws {TypeError} If the input is not a valid Spotify link
|
||||||
*/
|
*/
|
||||||
export const parseSpotifyLink = (link) => {
|
const parseSpotifyLink = (link: string): URIObject => {
|
||||||
const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
|
const localPattern =
|
||||||
|
/^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
|
||||||
const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/;
|
const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/;
|
||||||
|
|
||||||
if (localPattern.test(link)) {
|
if (localPattern.test(link)) {
|
||||||
@ -71,16 +79,24 @@ export const parseSpotifyLink = (link) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URL decode artist, album, and title
|
// URL decode artist, album, and title
|
||||||
const artist = decodeURIComponent(matches[1] || "");
|
const artist = decodeURIComponent(matches[1] ?? "");
|
||||||
const album = decodeURIComponent(matches[2] || "");
|
const album = decodeURIComponent(matches[2] ?? "");
|
||||||
const title = decodeURIComponent(matches[3]);
|
const title = decodeURIComponent(matches[3] ?? "");
|
||||||
const duration = parseInt(matches[4], 10);
|
const duration = parseInt(matches[4] ?? "", 10);
|
||||||
|
|
||||||
if (isNaN(duration)) {
|
if (isNaN(duration)) {
|
||||||
throw new TypeError(`${link} has an invalid duration`);
|
throw new TypeError(`${link} has an invalid duration`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: "track", is_local: true, artist, album, title, duration };
|
return {
|
||||||
|
type: "track",
|
||||||
|
is_local: true,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
title,
|
||||||
|
duration,
|
||||||
|
id: "",
|
||||||
|
};
|
||||||
} else if (standardPattern.test(link)) {
|
} else if (standardPattern.test(link)) {
|
||||||
// Not a local file
|
// Not a local file
|
||||||
const matches = link.match(standardPattern);
|
const matches = link.match(standardPattern);
|
||||||
@ -88,8 +104,8 @@ export const parseSpotifyLink = (link) => {
|
|||||||
throw new TypeError(`${link} is not a valid Spotify link`);
|
throw new TypeError(`${link} is not a valid Spotify link`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = matches[1];
|
const type = matches[1] ?? "";
|
||||||
const id = matches[2];
|
const id = matches[2] ?? "";
|
||||||
|
|
||||||
if (!base62Pattern.test(id)) {
|
if (!base62Pattern.test(id)) {
|
||||||
throw new TypeError(`${link} has an invalid ID`);
|
throw new TypeError(`${link} has an invalid ID`);
|
||||||
@ -99,14 +115,10 @@ export const parseSpotifyLink = (link) => {
|
|||||||
} else {
|
} else {
|
||||||
throw new TypeError(`${link} is not a valid Spotify link`);
|
throw new TypeError(`${link} is not a valid Spotify link`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/** Builds URI string from a URIObject */
|
||||||
* Builds URI string from a URIObject
|
const buildSpotifyURI = (uriObj: URIObject): string => {
|
||||||
* @param {typedefs.URIObject} uriObj
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export const buildSpotifyURI = (uriObj) => {
|
|
||||||
if (uriObj.is_local) {
|
if (uriObj.is_local) {
|
||||||
const artist = encodeURIComponent(uriObj.artist ?? "");
|
const artist = encodeURIComponent(uriObj.artist ?? "");
|
||||||
const album = encodeURIComponent(uriObj.album ?? "");
|
const album = encodeURIComponent(uriObj.album ?? "");
|
||||||
@ -115,14 +127,10 @@ export const buildSpotifyURI = (uriObj) => {
|
|||||||
return `spotify:local:${artist}:${album}:${title}:${duration}`;
|
return `spotify:local:${artist}:${album}:${title}:${duration}`;
|
||||||
}
|
}
|
||||||
return `spotify:${uriObj.type}:${uriObj.id}`;
|
return `spotify:${uriObj.type}:${uriObj.id}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/** Builds link from a URIObject */
|
||||||
* Builds link from a URIObject
|
const buildSpotifyLink = (uriObj: URIObject): string => {
|
||||||
* @param {typedefs.URIObject} uriObj
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export const buildSpotifyLink = (uriObj) => {
|
|
||||||
if (uriObj.is_local) {
|
if (uriObj.is_local) {
|
||||||
const artist = encodeURIComponent(uriObj.artist ?? "");
|
const artist = encodeURIComponent(uriObj.artist ?? "");
|
||||||
const album = encodeURIComponent(uriObj.album ?? "");
|
const album = encodeURIComponent(uriObj.album ?? "");
|
||||||
@ -130,5 +138,7 @@ export const buildSpotifyLink = (uriObj) => {
|
|||||||
const duration = uriObj.duration ? uriObj.duration.toString() : "";
|
const duration = uriObj.duration ? uriObj.duration.toString() : "";
|
||||||
return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`;
|
return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`;
|
||||||
}
|
}
|
||||||
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`
|
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { parseSpotifyLink, parseSpotifyURI, buildSpotifyLink, buildSpotifyURI };
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { validationResult } from "express-validator";
|
|
||||||
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
import { getNestedValuesString } from "../utils/jsonTransformer.js";
|
|
||||||
import curriedLogger from "../utils/logger.js";
|
|
||||||
const logger = curriedLogger(import.meta);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator
|
|
||||||
*
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {typedefs.Next} next
|
|
||||||
*/
|
|
||||||
export const validate = (req, res, 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(400).json({
|
|
||||||
message: getNestedValuesString(extractedErrors),
|
|
||||||
errors: extractedErrors
|
|
||||||
});
|
|
||||||
logger.warn("invalid request", { extractedErrors });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
38
validators/index.ts
Normal file
38
validators/index.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { validationResult } from "express-validator";
|
||||||
|
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
import { getNestedValuesString } from "../utils/jsonTransformer.ts";
|
||||||
|
|
||||||
|
import curriedLogger from "../utils/logger.ts";
|
||||||
|
const logger = curriedLogger(import.meta.filename);
|
||||||
|
|
||||||
|
/** Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator */
|
||||||
|
export const validate: RequestHandler = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (errors.isEmpty()) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedErrors: Record<string, string>[] = [];
|
||||||
|
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).send({
|
||||||
|
message: getNestedValuesString(extractedErrors),
|
||||||
|
errors: extractedErrors,
|
||||||
|
});
|
||||||
|
logger.warn("invalid request", { extractedErrors });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { body, header, param, query } from "express-validator";
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {typedefs.Next} next
|
|
||||||
*/
|
|
||||||
export 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createLinkValidator as removeLinkValidator };
|
|
||||||
export { createLinkValidator as populateSingleLinkValidator };
|
|
||||||
export { createLinkValidator as pruneSingleLinkValidator };
|
|
||||||
25
validators/operations.ts
Normal file
25
validators/operations.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { body } from "express-validator";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
const createLinkValidator: RequestHandler = 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createLinkValidator,
|
||||||
|
createLinkValidator as removeLinkValidator,
|
||||||
|
createLinkValidator as populateSingleLinkValidator,
|
||||||
|
createLinkValidator as pruneSingleLinkValidator,
|
||||||
|
};
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { body, header, param, query } from "express-validator";
|
|
||||||
import * as typedefs from "../typedefs.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {typedefs.Req} req
|
|
||||||
* @param {typedefs.Res} res
|
|
||||||
* @param {typedefs.Next} next
|
|
||||||
*/
|
|
||||||
export 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();
|
|
||||||
}
|
|
||||||
14
validators/playlists.ts
Normal file
14
validators/playlists.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { query } from "express-validator";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
const getPlaylistDetailsValidator: RequestHandler = 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getPlaylistDetailsValidator };
|
||||||
Loading…
x
Reference in New Issue
Block a user