From a74ffc453e7d3d76a4efa49fa88b8670a5366f1f Mon Sep 17 00:00:00 2001 From: Kaushik Narayan R Date: Tue, 11 Mar 2025 15:24:45 -0700 Subject: [PATCH] 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 --- .env | 4 +- .env.development | 6 +- .gitignore | 4 + .sequelizerc | 2 +- api/axios.js | 53 -- api/axios.ts | 54 ++ api/spotify.js | 153 ---- api/spotify.ts | 286 +++++++ boilerplates/controller.js | 18 - boilerplates/controller.ts | 16 + boilerplates/route.js | 14 - boilerplates/route.ts | 10 + boilerplates/validator.js | 17 - boilerplates/validator.ts | 13 + config/dotenv.js | 3 - config/dotenv.ts | 18 + config/sequelize.js | 29 - config/sequelize.ts | 25 + constants.js | 17 - constants.ts | 16 + controllers/{auth.js => auth.ts} | 131 ++-- controllers/{operations.js => operations.ts} | 588 +++++++++------ controllers/playlists.js | 155 ---- controllers/playlists.ts | 55 ++ index.js | 135 ---- index.ts | 164 ++++ middleware/authCheck.js | 33 - middleware/authCheck.ts | 31 + ....js => 20240727162141-create-playlists.ts} | 22 +- ...inks.js => 20240730101615-create-links.ts} | 22 +- models/index.js | 63 -- models/index.ts | 35 + models/links.js | 23 - models/links.ts | 23 + models/playlists.js | 23 - models/playlists.ts | 24 + package-lock.json | 700 +++++++++++++++++- package.json | 24 +- routes/auth.js | 29 - routes/auth.ts | 16 + routes/operations.js | 47 -- routes/operations.ts | 43 ++ routes/playlists.js | 21 - routes/playlists.ts | 10 + tsconfig.json | 113 +++ typedefs.js | 67 -- types/express-session.d.ts | 14 + types/spotify_manager/common.types.ts | 59 ++ types/spotify_manager/custom.types.ts | 28 + types/spotify_manager/endpoints.types.ts | 134 ++++ types/spotify_manager/index.d.ts | 5 + types/spotify_manager/objects.types.ts | 189 +++++ types/spotify_manager/shorthands.types.ts | 10 + utils/flake.js | 3 - utils/flake.ts | 7 + ...ateRandString.js => generateRandString.ts} | 7 +- utils/{graph.js => graph.ts} | 114 ++- utils/jsonTransformer.js | 19 - utils/jsonTransformer.ts | 16 + utils/logger.js | 67 -- utils/logger.ts | 76 ++ ...ransformer.js => spotifyUriTransformer.ts} | 98 +-- validators/index.js | 42 -- validators/index.ts | 38 + validators/operations.js | 27 - validators/operations.ts | 25 + validators/playlists.js | 17 - validators/playlists.ts | 14 + 68 files changed, 2795 insertions(+), 1569 deletions(-) delete mode 100644 api/axios.js create mode 100644 api/axios.ts delete mode 100644 api/spotify.js create mode 100644 api/spotify.ts delete mode 100644 boilerplates/controller.js create mode 100644 boilerplates/controller.ts delete mode 100644 boilerplates/route.js create mode 100644 boilerplates/route.ts delete mode 100644 boilerplates/validator.js create mode 100644 boilerplates/validator.ts delete mode 100644 config/dotenv.js create mode 100644 config/dotenv.ts delete mode 100644 config/sequelize.js create mode 100644 config/sequelize.ts delete mode 100644 constants.js create mode 100644 constants.ts rename controllers/{auth.js => auth.ts} (53%) rename controllers/{operations.js => operations.ts} (50%) delete mode 100644 controllers/playlists.js create mode 100644 controllers/playlists.ts delete mode 100644 index.js create mode 100644 index.ts delete mode 100644 middleware/authCheck.js create mode 100644 middleware/authCheck.ts rename migrations/{20240727162141-create-playlists.js => 20240727162141-create-playlists.ts} (60%) rename migrations/{20240730101615-create-links.js => 20240730101615-create-links.ts} (58%) delete mode 100644 models/index.js create mode 100644 models/index.ts delete mode 100644 models/links.js create mode 100644 models/links.ts delete mode 100644 models/playlists.js create mode 100644 models/playlists.ts delete mode 100644 routes/auth.js create mode 100644 routes/auth.ts delete mode 100644 routes/operations.js create mode 100644 routes/operations.ts delete mode 100644 routes/playlists.js create mode 100644 routes/playlists.ts create mode 100644 tsconfig.json delete mode 100644 typedefs.js create mode 100644 types/express-session.d.ts create mode 100644 types/spotify_manager/common.types.ts create mode 100644 types/spotify_manager/custom.types.ts create mode 100644 types/spotify_manager/endpoints.types.ts create mode 100644 types/spotify_manager/index.d.ts create mode 100644 types/spotify_manager/objects.types.ts create mode 100644 types/spotify_manager/shorthands.types.ts delete mode 100644 utils/flake.js create mode 100644 utils/flake.ts rename utils/{generateRandString.js => generateRandString.ts} (51%) rename utils/{graph.js => graph.ts} (50%) delete mode 100644 utils/jsonTransformer.js create mode 100644 utils/jsonTransformer.ts delete mode 100644 utils/logger.js create mode 100644 utils/logger.ts rename utils/{spotifyUriTransformer.js => spotifyUriTransformer.ts} (58%) delete mode 100644 validators/index.js create mode 100644 validators/index.ts delete mode 100644 validators/operations.js create mode 100644 validators/operations.ts delete mode 100644 validators/playlists.js create mode 100644 validators/playlists.ts diff --git a/.env b/.env index 74c3547..eb0ae8a 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ -CLIENT_ID = your_client_id_here -CLIENT_SECRET = your_client_secret_here +CLIENT_ID = your_spotify_client_id_here +CLIENT_SECRET = your_spotify_client_secret_here SESSION_SECRET = 'your_session_secret_string_here' PORT = 9001 TRUST_PROXY = 1 diff --git a/.env.development b/.env.development index 80f766b..68b65a9 100644 --- a/.env.development +++ b/.env.development @@ -1,10 +1,6 @@ BASE_DOMAIN = 127.0.0.1 REDIRECT_URI = http://127.0.0.1:9001/api/auth/callback APP_URI = http://127.0.0.1:3000 -DB_USER = your_database_username -DB_PASSWD = your_database_password -DB_NAME = your_database_name -DB_HOST = 127.0.0.1 -DB_PORT = your_database_port +DB_URI = postgres://your_database_username:your_database_password@127.0.0.1:your_database_port/your_database_name REDIS_HOST = 127.0.0.1 REDIS_PORT = 6379 diff --git a/.gitignore b/.gitignore index 1976619..f9a708d 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,7 @@ dist # SQLite db *.db + +# production +/build +/tsout diff --git a/.sequelizerc b/.sequelizerc index 382d2a5..40d04bf 100644 --- a/.sequelizerc +++ b/.sequelizerc @@ -3,5 +3,5 @@ dotenvFlow.config(); import { resolve } from "path"; export default { - "config": resolve("config", "sequelize.js") + config: resolve("config", "sequelize.ts"), }; diff --git a/api/axios.js b/api/axios.js deleted file mode 100644 index 0c87d97..0000000 --- a/api/axios.js +++ /dev/null @@ -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); - } -); diff --git a/api/axios.ts b/api/axios.ts new file mode 100644 index 0000000..0577b7a --- /dev/null +++ b/api/axios.ts @@ -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 }; diff --git a/api/spotify.js b/api/spotify.js deleted file mode 100644 index 920235a..0000000 --- a/api/spotify.js +++ /dev/null @@ -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; - } -} diff --git a/api/spotify.ts b/api/spotify.ts new file mode 100644 index 0000000..7c1e87a --- /dev/null +++ b/api/spotify.ts @@ -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 ( + req: Req, + res: Res, + method: allowedMethods, + path: string, + config: AxiosRequestConfig = {}, + data: any = null, + inlineData: boolean = false +): Promise | null> => { + let resp: AxiosResponse; + 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 = async ({ req, res }) => { + const response = await singleRequest( + 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 = async ({ req, res }) => { + const response = await singleRequest( + 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 = async ({ + req, + res, + nextURL, +}) => { + const response = await singleRequest( + req, + res, + allowedMethods.Get, + nextURL + ); + return response?.data ?? null; +}; + +interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs { + initialFields: string; + playlistID: string; +} +const getPlaylistDetailsFirstPage: ( + opts: GetPlaylistDetailsFirstPageArgs +) => Promise = async ({ + req, + res, + initialFields, + playlistID, +}) => { + const response = await singleRequest( + req, + res, + allowedMethods.Get, + `/playlists/${playlistID}/`, + { + params: { + fields: initialFields, + }, + } + ); + return response?.data ?? null; +}; + +interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerBaseArgs { + nextURL: string; +} +const getPlaylistDetailsNextPage: ( + opts: GetPlaylistDetailsNextPageArgs +) => Promise = async ({ req, res, nextURL }) => { + const response = await singleRequest( + req, + res, + allowedMethods.Get, + nextURL + ); + return response?.data ?? null; +}; + +interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs { + nextBatch: string[]; + playlistID: string; +} +const addItemsToPlaylist: ( + opts: AddItemsToPlaylistArgs +) => Promise = async ({ + req, + res, + nextBatch, + playlistID, +}) => { + const response = await singleRequest( + 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 = 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, + 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 = 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, +}; diff --git a/boilerplates/controller.js b/boilerplates/controller.js deleted file mode 100644 index 540233c..0000000 --- a/boilerplates/controller.js +++ /dev/null @@ -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; - } -} diff --git a/boilerplates/controller.ts b/boilerplates/controller.ts new file mode 100644 index 0000000..9c81b41 --- /dev/null +++ b/boilerplates/controller.ts @@ -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 }; diff --git a/boilerplates/route.js b/boilerplates/route.js deleted file mode 100644 index 1dbb54f..0000000 --- a/boilerplates/route.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Router } from "express"; -const router = Router(); - -import { validate } from "../validators/index.js"; - -router.get( - -); - -router.post( - -); - -export default router; diff --git a/boilerplates/route.ts b/boilerplates/route.ts new file mode 100644 index 0000000..d481732 --- /dev/null +++ b/boilerplates/route.ts @@ -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; diff --git a/boilerplates/validator.js b/boilerplates/validator.js deleted file mode 100644 index 3537edc..0000000 --- a/boilerplates/validator.js +++ /dev/null @@ -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(); -} diff --git a/boilerplates/validator.ts b/boilerplates/validator.ts new file mode 100644 index 0000000..b42251e --- /dev/null +++ b/boilerplates/validator.ts @@ -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 }; diff --git a/config/dotenv.js b/config/dotenv.js deleted file mode 100644 index 7e9843a..0000000 --- a/config/dotenv.js +++ /dev/null @@ -1,3 +0,0 @@ -// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822 -import DotenvFlow from "dotenv-flow"; -export default DotenvFlow.config(); diff --git a/config/dotenv.ts b/config/dotenv.ts new file mode 100644 index 0000000..de90ac8 --- /dev/null +++ b/config/dotenv.ts @@ -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; diff --git a/config/sequelize.js b/config/sequelize.js deleted file mode 100644 index 87a0ba0..0000000 --- a/config/sequelize.js +++ /dev/null @@ -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; diff --git a/config/sequelize.ts b/config/sequelize.ts new file mode 100644 index 0000000..8deddcc --- /dev/null +++ b/config/sequelize.ts @@ -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; + +// 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; diff --git a/constants.js b/constants.js deleted file mode 100644 index c1940ad..0000000 --- a/constants.js +++ /dev/null @@ -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", -}; diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..2640983 --- /dev/null +++ b/constants.ts @@ -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 }; diff --git a/controllers/auth.js b/controllers/auth.ts similarity index 53% rename from controllers/auth.js rename to controllers/auth.ts index 4420df5..2543364 100644 --- a/controllers/auth.js +++ b/controllers/auth.ts @@ -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 { scopes, stateKey, accountsAPIURL, sessionName } from "../constants.js"; +import { + requiredScopes, + stateKey, + accountsAPIURL, + sessionName, +} from "../constants.ts"; +import type { RequestHandler } from "express"; -import generateRandString from "../utils/generateRandString.js"; -import { getUserProfile } from "../api/spotify.js"; -import curriedLogger from "../utils/logger.js"; -const logger = curriedLogger(import.meta); +import { generateRandString } from "../utils/generateRandString.ts"; + +import curriedLogger from "../utils/logger.ts"; +const logger = curriedLogger(import.meta.filename); /** * 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 { const state = generateRandString(16); res.cookie(stateKey, state); - const scope = Object.values(scopes).join(" "); + const scope = Object.values(requiredScopes).join(" "); + res.redirect( `${accountsAPIURL}/authorize?` + - new URLSearchParams({ - response_type: "code", - client_id: process.env.CLIENT_ID, - scope: scope, - redirect_uri: process.env.REDIRECT_URI, - state: state - }).toString() + new URLSearchParams({ + response_type: "code", + client_id: process.env["CLIENT_ID"], + scope: scope, + redirect_uri: process.env["REDIRECT_URI"], + state: state, + } as Record).toString() ); - return; + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("login", { error }); - return; + return null; } -} +}; /** * 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 { const { code, state, error } = req.query; const storedState = req.cookies ? req.cookies[stateKey] : null; @@ -51,22 +54,22 @@ export const callback = async (req, res) => { if (state === null || state !== storedState) { res.status(409).send({ message: "Invalid state" }); logger.warn("state mismatch"); - return; + return null; } else if (error) { res.status(401).send({ message: "Auth callback error" }); logger.error("callback error", { error }); - return; + return null; } else { // get auth tokens res.clearCookie(stateKey); const authForm = { code: code, - redirect_uri: process.env.REDIRECT_URI, - grant_type: "authorization_code" - } + redirect_uri: process.env["REDIRECT_URI"], + grant_type: "authorization_code", + } as Record; - const authPayload = (new URLSearchParams(authForm)).toString(); + const authPayload = new URLSearchParams(authForm).toString(); 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; } else { 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); - if (res.headersSent) return; + const userData = await getCurrentUsersProfile({ req, res }); + if (!userData) return null; - /** @type {typedefs.User} */ req.session.user = { - username: userData.display_name, + username: userData.display_name ?? "", id: userData.id, }; // 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 }); - return; + return null; } } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("callback", { error }); - return; + return null; } -} +}; /** * 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 { const authForm = { - refresh_token: req.session.refreshToken, + refresh_token: req.session.refreshToken ?? "", 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); if (response.status === 200) { 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" }); - logger.debug(`Access token refreshed${(response.data.refresh_token !== null) ? " and refresh token updated" : ""}.`); - return; + logger.debug( + `Access token refreshed${ + response.data.refresh_token !== null + ? " and refresh token updated" + : "" + }.` + ); + return null; } 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 }); - return; + return null; } } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("refresh", { error }); - return; + return null; } }; /** * Clear session - * @param {typedefs.Req} req - * @param {typedefs.Res} res */ -export const logout = async (req, res) => { +const logout: RequestHandler = async (req, res) => { try { const delSession = req.session.destroy((error) => { if (Object.keys(error).length) { res.status(500).send({ message: "Internal Server Error" }); logger.error("Error while logging out", { error }); - return; } else { res.clearCookie(sessionName); // res.status(200).send({ message: "OK" }); - res.redirect(process.env.APP_URI + "?logout=success"); + res.redirect(process.env["APP_URI"] + "?logout=success"); logger.debug("Logged out.", { sessionID: delSession.id }); - return; } - }) + }); + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("logout", { error }); - return; + return null; } -} +}; + +export { login, callback, refresh, logout }; diff --git a/controllers/operations.js b/controllers/operations.ts similarity index 50% rename from controllers/operations.js rename to controllers/operations.ts index 37b571a..10d1b42 100644 --- a/controllers/operations.js +++ b/controllers/operations.ts @@ -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 models, { sequelize } from "../models/index.js"; -const Playlists = models.playlists; -const Links = models.links; +import { + getCurrentUsersPlaylistsFirstPage, + 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 - * @param {typedefs.Req} req - * @param {typedefs.Res} res */ -export const updateUser = async (req, res) => { +const updateUser: RequestHandler = async (req, res) => { 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; // get first 50 - const respData = await getUserPlaylistsFirstPage(req, res); - if (res.headersSent) return; + const respData = await getCurrentUsersPlaylistsFirstPage({ req, res }); + if (!respData) return null; - currentPlaylists = respData.items.map(playlist => { + currentPlaylists = respData.items.map((playlist) => { return { playlistID: playlist.id, - playlistName: playlist.name - } + playlistName: playlist.name, + }; }); let nextURL = respData.next; // keep getting batches of 50 till exhausted while (nextURL) { - const nextData = await getUserPlaylistsNextPage(req, res, nextURL); - if (res.headersSent) return; + const nextData = await getCurrentUsersPlaylistsNextPage({ + req, + res, + nextURL, + }); + if (!nextData) return null; currentPlaylists.push( - ...nextData.items.map(playlist => { + ...nextData.items.map((playlist) => { return { playlistID: playlist.id, - playlistName: playlist.name - } + playlistName: playlist.name, + }; }) ); @@ -57,17 +78,20 @@ export const updateUser = async (req, res) => { attributes: ["playlistID", "playlistName"], raw: true, where: { - userID: uID + userID: uID, }, }); - const deleted = []; - const added = []; - const renamed = []; + const deleted: PlaylistModel_Pl[] = []; + const added: PlaylistModel_Pl[] = []; + const renamed: { playlistID: string; oldName: string; newName: string }[] = + []; if (oldPlaylists.length) { 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 currentPlaylists.forEach((pl) => { @@ -96,9 +120,12 @@ export const updateUser = async (req, res) => { 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) { // clean up any links dependent on the playlists removedLinks = await Links.destroy({ @@ -109,87 +136,94 @@ export const updateUser = async (req, res) => { [Op.or]: [ { from: { [Op.in]: deletedIDs } }, { to: { [Op.in]: deletedIDs } }, - ] - } - ] - } - }) + ], + }, + ], + }, + }); // only then remove delNum = await Playlists.destroy({ - where: { playlistID: deletedIDs, userID: uID } + where: { playlistID: deletedIDs, userID: uID }, }); if (delNum !== deleted.length) { res.status(500).send({ message: "Internal Server Error" }); - logger.error("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") }); - return; + logger.error("Could not remove all old playlists", { + error: new Error("Playlists.destroy failed?"), + }); + return null; } } if (added.length) { addPls = await Playlists.bulkCreate( - added.map(pl => { return { ...pl, userID: uID } }), + added.map((pl) => { + return { ...pl, userID: uID }; + }), { validate: true } ); if (addPls.length !== added.length) { res.status(500).send({ message: "Internal Server Error" }); - logger.error("Could not add all new playlists", { error: new Error("Playlists.bulkCreate failed?") }); - return; + logger.error("Could not add all new playlists", { + error: new Error("Playlists.bulkCreate failed?"), + }); + return null; } } - const transaction = await sequelize.transaction(); try { - for (const { playlistID, newName } of renamed) { - const updateRes = await Playlists.update( - { playlistName: newName }, - { where: { playlistID, userID: uID } }, - { transaction } - ); - updateNum += Number(updateRes[0]); - } - - await transaction.commit(); + await seqConn.transaction(async (transaction) => { + for (const { playlistID, newName } of renamed) { + const updateRes = await Playlists.update( + { playlistName: newName }, + { where: { playlistID, userID: uID }, transaction } + ); + updateNum += Number(updateRes[0]); + } + }); } catch (error) { - await transaction.rollback(); res.status(500).send({ message: "Internal Server Error" }); - logger.error("Could not update playlist names", { error: new Error("Playlists.update failed?") }); - return; + logger.error("Could not update playlist names", { + 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", { delLinks: removedLinks, delPls: delNum, addPls: addPls.length, - updatedPls: updateNum + updatedPls: updateNum, }); - return; + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("updateUser", { error }); - return; + return null; } -} +}; /** * 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 { // if (randomBool(0.5)) { // 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 currentPlaylists = await Playlists.findAll({ attributes: ["playlistID", "playlistName"], raw: true, where: { - userID: uID + userID: uID, }, }); @@ -197,31 +231,34 @@ export const fetchUser = async (req, res) => { attributes: ["from", "to"], raw: true, where: { - userID: uID + userID: uID, }, }); res.status(200).send({ playlists: currentPlaylists, - links: currentLinks + links: currentLinks, }); - logger.debug("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length }); - return; + logger.debug("Fetched user data", { + pls: currentPlaylists.length, + links: currentLinks.length, + }); + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("fetchUser", { error }); - return; + return null; } -} +}; /** * 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 { // await sleep(1000); + if (!req.session.user) + throw new ReferenceError("sessionData does not have user object"); const uID = req.session.user.id; let fromPl, toPl; @@ -231,87 +268,89 @@ export const createLink = async (req, res) => { if (fromPl.type !== "playlist" || toPl.type !== "playlist") { res.status(400).send({ message: "Link is not a playlist" }); logger.info("non-playlist link provided", { from: fromPl, to: toPl }); - return; + return null; } } catch (error) { res.status(400).send({ message: "Could not parse link" }); logger.warn("parseSpotifyLink", { error }); - return; + return null; } - let playlists = await Playlists.findAll({ + const playlists = (await Playlists.findAll({ attributes: ["playlistID"], raw: true, - where: { userID: uID } - }); - playlists = playlists.map(pl => pl.playlistID); + where: { userID: uID }, + })) as unknown as PlaylistModel_Pl[]; + const playlistIDs = playlists.map((pl) => pl.playlistID); // 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." }); logger.warn("unknown playlists, resync"); - return; + return null; } // check if exists const existingLink = await Links.findOne({ where: { - [Op.and]: [ - { userID: uID }, - { from: fromPl.id }, - { to: toPl.id } - ] - } + [Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }], + }, }); if (existingLink) { res.status(409).send({ message: "Link already exists!" }); logger.info("link already exists"); - return; + return null; } - const allLinks = await Links.findAll({ + const allLinks = (await Links.findAll({ attributes: ["from", "to"], 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()) { - 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"); - return; + return null; } const newLink = await Links.create({ userID: uID, from: fromPl.id, - to: toPl.id + to: toPl.id, }); if (!newLink) { res.status(500).send({ message: "Internal Server Error" }); - logger.error("Could not create link", { error: new Error("Links.create failed?") }); - return; + logger.error("Could not create link", { + error: new Error("Links.create failed?"), + }); + return null; } res.status(201).send({ message: "Created link." }); logger.debug("Created link"); - return; + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("createLink", { error }); - return; + return null; } -} - +}; /** * Remove link between playlists - * @param {typedefs.Req} req - * @param {typedefs.Res} res -*/ -export const removeLink = async (req, res) => { + */ +const removeLink: RequestHandler = async (req, res) => { try { + if (!req.session.user) + throw new Error("sessionData does not have user object"); const uID = req.session.user.id; let fromPl, toPl; @@ -321,103 +360,122 @@ export const removeLink = async (req, res) => { if (fromPl.type !== "playlist" || toPl.type !== "playlist") { res.status(400).send({ message: "Link is not a playlist" }); logger.info("non-playlist link provided", { from: fromPl, to: toPl }); - return; + return null; } } catch (error) { res.status(400).send({ message: "Could not parse link" }); logger.warn("parseSpotifyLink", { error }); - return; + return null; } // check if exists const existingLink = await Links.findOne({ where: { - [Op.and]: [ - { userID: uID }, - { from: fromPl.id }, - { to: toPl.id } - ] - } + [Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }], + }, }); if (!existingLink) { res.status(409).send({ message: "Link does not exist!" }); logger.warn("link does not exist"); - return; + return null; } const removedLink = await Links.destroy({ where: { - [Op.and]: [ - { userID: uID }, - { from: fromPl.id }, - { to: toPl.id } - ] - } + [Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }], + }, }); if (!removedLink) { res.status(500).send({ message: "Internal Server Error" }); - logger.error("Could not remove link", { error: new Error("Links.destroy failed?") }); - return; + logger.error("Could not remove link", { + error: new Error("Links.destroy failed?"), + }); + return null; } res.status(200).send({ message: "Deleted link." }); logger.debug("Deleted link"); - return; + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("removeLink", { error }); - return; + return null; } -} +}; -/** - * - * @param {typedefs.Req} req - * @param {typedefs.Res} res - * @param {string} playlistID - */ -const _getPlaylistTracks = async (req, res, playlistID) => { +interface _GetPlaylistTracksArgs extends EndpointHandlerBaseArgs { + playlistID: string; +} +interface _GetPlaylistTracks { + tracks: { + is_local: boolean; + 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 mainFields = ["next", "items(is_local,track(uri))"]; - const respData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), playlistID); - if (res.headersSent) return; + const respData = await getPlaylistDetailsFirstPage({ + 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 if (respData.tracks.next) { - pl.next = new URL(respData.tracks.next); - pl.next.searchParams.set("fields", mainFields.join()); - pl.next = pl.next.href; + nextURL = new URL(respData.tracks.next); + nextURL.searchParams.set("fields", mainFields.join()); + nextURL = nextURL.href; } pl.tracks = respData.tracks.items.map((playlist_item) => { return { is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } + uri: playlist_item.track.uri, + }; }); // keep getting batches of 50 till exhausted - while (pl.next) { - const nextData = await getPlaylistDetailsNextPage(req, res, pl.next); - if (res.headersSent) return; + while (nextURL) { + const nextData = await getPlaylistDetailsNextPage({ + req, + res, + nextURL, + }); + if (!nextData) return null; pl.tracks.push( ...nextData.items.map((playlist_item) => { return { 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; -} +}; +interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs { + link: { + from: URIObject; + to: URIObject; + }; +} /** * Add tracks to 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 * * 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 { - const fromPl = link.from, toPl = link.to; + const fromPl = link.from, + toPl = link.to; - const fromPlaylist = await _getPlaylistTracks(req, res, fromPl.id); - const toPlaylist = await _getPlaylistTracks(req, res, toPl.id); + const fromPlaylist = await _getPlaylistTracks({ + req, + res, + playlistID: fromPl.id, + }); + const toPlaylist = await _getPlaylistTracks({ + req, + res, + playlistID: toPl.id, + }); - const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri); - let toTrackURIs = toPlaylist.tracks. - filter(track => !track.is_local). // API doesn't support adding local files to playlists yet - filter(track => !fromTrackURIs.includes(track.uri)). // only ones missing from the 'from' playlist - map(track => track.uri); + if (!fromPlaylist || !toPlaylist) return null; + const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri); + let toTrackURIs = toPlaylist.tracks + .filter((track) => !track.is_local) // API doesn't support adding local files to playlists yet + .filter((track) => !fromTrackURIs.includes(track.uri)) // only ones missing from the 'from' playlist + .map((track) => track.uri); 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 while (toTrackURIs.length > 0) { const nextBatch = toTrackURIs.splice(0, 100); - const addData = await addItemsToPlaylist(req, res, nextBatch, fromPl.id); - if (res.headersSent) return; + const addData = await addItemsToPlaylist({ + req, + res, + nextBatch, + playlistID: fromPl.id, + }); + if (!addData) return null; } return { toAddNum, localNum }; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("_populateSingleLinkCore", { error }); - return; + return null; } -} +}; -/** - * @param {typedefs.Req} req - * @param {typedefs.Res} res - */ -export const populateSingleLink = async (req, res) => { +const populateSingleLink: RequestHandler = async (req, res) => { try { + if (!req.session.user) + throw new Error("sessionData does not have user object"); const uID = req.session.user.id; const link = { from: req.body.from, to: req.body.to }; let fromPl, toPl; @@ -487,51 +559,63 @@ export const populateSingleLink = async (req, res) => { if (fromPl.type !== "playlist" || toPl.type !== "playlist") { res.status(400).send({ message: "Link is not a playlist" }); logger.info("non-playlist link provided", link); - return; + return null; } } catch (error) { res.status(400).send({ message: "Could not parse link" }); logger.warn("parseSpotifyLink", { error }); - return; + return null; } // check if exists const existingLink = await Links.findOne({ where: { - [Op.and]: [ - { userID: uID }, - { from: fromPl.id }, - { to: toPl.id } - ] - } + [Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }], + }, }); if (!existingLink) { res.status(409).send({ message: "Link does not exist!" }); logger.warn("link does not exist", { link }); - return; + return null; } - if (!await checkPlaylistEditable(req, res, fromPl.id, uID)) - return; + if ( + !(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) { const { toAddNum, localNum } = result; let logMsg; - logMsg = toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add"; - logMsg += localNum > 0 ? "; could not process " + localNum + " local files" : "."; + logMsg = + toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add"; + logMsg += + localNum > 0 ? "; could not process " + localNum + " local files" : "."; res.status(200).send({ message: logMsg }); logger.debug(logMsg, { toAddNum, localNum }); } - return; + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("populateSingleLink", { error }); - return; + return null; } -} +}; +interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs { + link: { from: URIObject; to: URIObject }; +} /** * Remove tracks from the link-tail 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 * - * @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 { - const fromPl = link.from, toPl = link.to; + const fromPl = link.from, + toPl = link.to; - const fromPlaylist = await _getPlaylistTracks(req, res, fromPl.id); - const toPlaylist = await _getPlaylistTracks(req, res, toPl.id); - - const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri); - let indexedToTrackURIs = toPlaylist.tracks; - - indexedToTrackURIs.forEach((track, index) => { - track.position = index; + const fromPlaylist = await _getPlaylistTracks({ + req, + res, + playlistID: fromPl.id, + }); + const toPlaylist = await _getPlaylistTracks({ + req, + res, + playlistID: toPl.id, }); - let indexes = indexedToTrackURIs.filter(track => !fromTrackURIs.includes(track.uri)); // only those missing from the 'from' playlist - indexes = indexes.map(track => track.position); // get track positions + if (!fromPlaylist || !toPlaylist) return null; + 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; // remove in batches of 100 (from reverse, to preserve positions while modifying) let currentSnapshot = toPlaylist.snapshot_id; - while (indexes.length) { + while (indexes.length > 0) { const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100); - const delResponse = await removeItemsFromPlaylist(req, res, nextBatch, toPl.id, currentSnapshot); - if (res.headersSent) return; + const delResponse = await removePlaylistItems({ + req, + res, + nextBatch, + playlistID: toPl.id, + snapshotID: currentSnapshot, + }); + if (!delResponse) return null; currentSnapshot = delResponse.snapshot_id; } return { toDelNum }; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); - logger.error("_pruneSingleLinkCore", { error }) - return; + logger.error("_pruneSingleLinkCore", { error }); + return null; } -} +}; -/** - * @param {typedefs.Req} req - * @param {typedefs.Res} res -*/ -export const pruneSingleLink = async (req, res) => { +const pruneSingleLink: RequestHandler = async (req, res) => { try { + if (!req.session.user) + throw new Error("sessionData does not have user object"); const uID = req.session.user.id; 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") { res.status(400).send({ message: "Link is not a playlist" }); logger.info("non-playlist link provided", link); - return; + return null; } - } catch (error) { + } catch (error: any) { res.status(400).send({ message: error.message }); logger.warn("parseSpotifyLink", { error }); - return; + return null; } // check if exists const existingLink = await Links.findOne({ where: { - [Op.and]: [ - { userID: uID }, - { from: fromPl.id }, - { to: toPl.id } - ] - } + [Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }], + }, }); if (!existingLink) { res.status(409).send({ message: "Link does not exist!" }); logger.warn("link does not exist", { link }); - return; + return null; } - if (!await checkPlaylistEditable(req, res, toPl.id, uID)) - return; + if ( + !(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) { const { toDelNum } = result; res.status(200).send({ message: `Removed ${toDelNum} tracks.` }); logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum }); } - return; + return null; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("pruneSingleLink", { error }); - return; + return null; } -} +}; + +export { + updateUser, + fetchUser, + createLink, + removeLink, + populateSingleLink, + pruneSingleLink, +}; diff --git a/controllers/playlists.js b/controllers/playlists.js deleted file mode 100644 index 7efd048..0000000 --- a/controllers/playlists.js +++ /dev/null @@ -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; - } -} diff --git a/controllers/playlists.ts b/controllers/playlists.ts new file mode 100644 index 0000000..33c4ac6 --- /dev/null +++ b/controllers/playlists.ts @@ -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, + "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 }; diff --git a/index.js b/index.js deleted file mode 100644 index a7b2dd7..0000000 --- a/index.js +++ /dev/null @@ -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 cat's 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)); -}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..aa391e0 --- /dev/null +++ b/index.ts @@ -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 cat\'s 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)); + } +); diff --git a/middleware/authCheck.js b/middleware/authCheck.js deleted file mode 100644 index 72c49e0..0000000 --- a/middleware/authCheck.js +++ /dev/null @@ -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; - } - }); - } -} diff --git a/middleware/authCheck.ts b/middleware/authCheck.ts new file mode 100644 index 0000000..76a4e90 --- /dev/null +++ b/middleware/authCheck.ts @@ -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; + } + }); + } +}; diff --git a/migrations/20240727162141-create-playlists.js b/migrations/20240727162141-create-playlists.ts similarity index 60% rename from migrations/20240727162141-create-playlists.js rename to migrations/20240727162141-create-playlists.ts index 875f096..4ef6a72 100644 --- a/migrations/20240727162141-create-playlists.js +++ b/migrations/20240727162141-create-playlists.ts @@ -1,5 +1,5 @@ "use strict"; -/** @type {import("sequelize-cli").Migration} */ +import { type Migration } from "sequelize-cli"; export default { up: async function (queryInterface, Sequelize) { await queryInterface.createTable("playlists", { @@ -7,28 +7,28 @@ export default { allowNull: false, autoIncrement: true, primaryKey: true, - type: Sequelize.INTEGER + type: Sequelize.INTEGER, }, playlistID: { - type: Sequelize.STRING + type: Sequelize.STRING, }, playlistName: { - type: Sequelize.STRING + type: Sequelize.STRING, }, userID: { - type: Sequelize.STRING + type: Sequelize.STRING, }, createdAt: { allowNull: false, - type: Sequelize.DATE + type: Sequelize.DATE, }, updatedAt: { allowNull: false, - type: Sequelize.DATE - } + type: Sequelize.DATE, + }, }); }, - down: async function (queryInterface, Sequelize) { + down: async function (queryInterface, _Sequelize) { await queryInterface.dropTable("playlists"); - } -}; + }, +} as Migration; diff --git a/migrations/20240730101615-create-links.js b/migrations/20240730101615-create-links.ts similarity index 58% rename from migrations/20240730101615-create-links.js rename to migrations/20240730101615-create-links.ts index 52aed92..7a39cee 100644 --- a/migrations/20240730101615-create-links.js +++ b/migrations/20240730101615-create-links.ts @@ -1,5 +1,5 @@ "use strict"; -/** @type {import("sequelize-cli").Migration} */ +import { type Migration } from "sequelize-cli"; export default { up: async function (queryInterface, Sequelize) { await queryInterface.createTable("links", { @@ -7,28 +7,28 @@ export default { allowNull: false, autoIncrement: true, primaryKey: true, - type: Sequelize.INTEGER + type: Sequelize.INTEGER, }, userID: { - type: Sequelize.STRING + type: Sequelize.STRING, }, from: { - type: Sequelize.STRING + type: Sequelize.STRING, }, to: { - type: Sequelize.STRING + type: Sequelize.STRING, }, createdAt: { allowNull: false, - type: Sequelize.DATE + type: Sequelize.DATE, }, updatedAt: { allowNull: false, - type: Sequelize.DATE - } + type: Sequelize.DATE, + }, }); }, - down: async function (queryInterface, Sequelize) { + down: async function (queryInterface, _Sequelize) { await queryInterface.dropTable("links"); - } -}; + }, +} as Migration; diff --git a/models/index.js b/models/index.js deleted file mode 100644 index ec21585..0000000 --- a/models/index.js +++ /dev/null @@ -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; diff --git a/models/index.ts b/models/index.ts new file mode 100644 index 0000000..dfd2d32 --- /dev/null +++ b/models/index.ts @@ -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; diff --git a/models/links.js b/models/links.js deleted file mode 100644 index 9e83b9e..0000000 --- a/models/links.js +++ /dev/null @@ -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; -}; diff --git a/models/links.ts b/models/links.ts new file mode 100644 index 0000000..4294abf --- /dev/null +++ b/models/links.ts @@ -0,0 +1,23 @@ +"use strict"; +import { + AllowNull, + Column, + DataType, + Model, + Table, +} from "sequelize-typescript"; +@Table +class links extends Model> { + @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; diff --git a/models/playlists.js b/models/playlists.js deleted file mode 100644 index 57fa253..0000000 --- a/models/playlists.js +++ /dev/null @@ -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; -}; diff --git a/models/playlists.ts b/models/playlists.ts new file mode 100644 index 0000000..b07f93f --- /dev/null +++ b/models/playlists.ts @@ -0,0 +1,24 @@ +"use strict"; +import { + AllowNull, + Column, + DataType, + Model, + Table, +} from "sequelize-typescript"; +@Table +class playlists extends Model> { + @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; diff --git a/package-lock.json b/package-lock.json index ca234a9..fc72e58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,20 @@ "version": "0", "license": "MIT", "dependencies": { - "axios": "^1.7.9", - "axios-rate-limit": "^1.4.0", + "axios": "^1.8.2", "connect-redis": "^8.0.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dotenv-flow": "^4.1.0", + "dotenv": "^16.4.7", "express": "^4.21.2", "express-session": "^1.18.1", "express-validator": "^7.2.0", "helmet": "^8.0.0", "pg": "^8.13.1", "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", "winston": "^3.17.0" }, @@ -30,11 +31,14 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@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", "nodemon": "^3.1.9", "sequelize-cli": "^6.6.2", - "typescript": "^5.7.3" + "tsx": "^4.19.3", + "typescript": "^5.8.2" } }, "node_modules/@colors/colors": { @@ -55,6 +59,431 @@ "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": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -141,6 +570,13 @@ "@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": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -160,6 +596,16 @@ "@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": { "version": "1.4.8", "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", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -225,6 +672,13 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "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": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -237,9 +691,10 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } @@ -266,6 +721,19 @@ "@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": { "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", @@ -285,7 +753,8 @@ "node_modules/@types/validator": { "version": "13.12.2", "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": { "version": "2.0.0", @@ -370,31 +839,20 @@ } }, "node_modules/axios": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz", - "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -441,7 +899,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -707,8 +1164,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/config-chain": { "version": "1.1.13", @@ -792,6 +1248,7 @@ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.1" }, @@ -869,6 +1326,7 @@ "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -876,17 +1334,6 @@ "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": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", @@ -1070,6 +1517,47 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1333,6 +1821,12 @@ "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": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1407,6 +1901,19 @@ "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": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -1573,6 +2080,17 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1874,7 +2392,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2042,6 +2559,15 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", @@ -2064,6 +2590,15 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2339,6 +2874,12 @@ "@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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2368,6 +2909,16 @@ "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": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", @@ -2462,6 +3013,7 @@ "url": "https://opencollective.com/sequelize" } ], + "license": "MIT", "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -2543,6 +3095,45 @@ "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": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2921,6 +3512,26 @@ "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": { "version": "2.7.3", "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", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3206,6 +3818,12 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index f66eba5..f11ccde 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "spotify-manager", "version": "0", "description": "Personal Spotify playlist manager", - "exports": "./index.js", + "exports": "./index.ts", "type": "module", "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": "cross-env NODE_ENV=test node index.js", - "prod": "NODE_ENV=production node index.js" + "test": "NODE_ENV=test tsx index.ts", + "prod": "NODE_ENV=production tsx index.ts" }, "repository": { "type": "git", @@ -21,19 +21,20 @@ }, "homepage": "https://github.com/20kaushik02/spotify-manager#readme", "dependencies": { - "axios": "^1.7.9", - "axios-rate-limit": "^1.4.0", + "axios": "^1.8.2", "connect-redis": "^8.0.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dotenv-flow": "^4.1.0", + "dotenv": "^16.4.7", "express": "^4.21.2", "express-session": "^1.18.1", "express-validator": "^7.2.0", "helmet": "^8.0.0", "pg": "^8.13.1", "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", "winston": "^3.17.0" }, @@ -42,10 +43,13 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@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", "nodemon": "^3.1.9", "sequelize-cli": "^6.6.2", - "typescript": "^5.7.3" + "tsx": "^4.19.3", + "typescript": "^5.8.2" } } diff --git a/routes/auth.js b/routes/auth.js deleted file mode 100644 index db2d4fd..0000000 --- a/routes/auth.js +++ /dev/null @@ -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; diff --git a/routes/auth.ts b/routes/auth.ts new file mode 100644 index 0000000..125e036 --- /dev/null +++ b/routes/auth.ts @@ -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; diff --git a/routes/operations.js b/routes/operations.js deleted file mode 100644 index bd4f95a..0000000 --- a/routes/operations.js +++ /dev/null @@ -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; diff --git a/routes/operations.ts b/routes/operations.ts new file mode 100644 index 0000000..bbd89c9 --- /dev/null +++ b/routes/operations.ts @@ -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; diff --git a/routes/playlists.js b/routes/playlists.js deleted file mode 100644 index 5e19e48..0000000 --- a/routes/playlists.js +++ /dev/null @@ -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; diff --git a/routes/playlists.ts b/routes/playlists.ts new file mode 100644 index 0000000..b02838b --- /dev/null +++ b/routes/playlists.ts @@ -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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c5831e8 --- /dev/null +++ b/tsconfig.json @@ -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 ''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. */ + } +} diff --git a/typedefs.js b/typedefs.js deleted file mode 100644 index 0b4c01f..0000000 --- a/typedefs.js +++ /dev/null @@ -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 {}; diff --git a/types/express-session.d.ts b/types/express-session.d.ts new file mode 100644 index 0000000..a099689 --- /dev/null +++ b/types/express-session.d.ts @@ -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 {}; diff --git a/types/spotify_manager/common.types.ts b/types/spotify_manager/common.types.ts new file mode 100644 index 0000000..2602128 --- /dev/null +++ b/types/spotify_manager/common.types.ts @@ -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 = { + href: string; + limit: number; + next: string | null; + offset: number; + previous: string | null; + total: number; + items: T[]; +}; +export type PaginationByCursor = { + href?: string; + limit?: number; + next?: string; + cursors?: { + after?: string; + before?: string; + }; + total?: number; + items?: T[]; +}; +export type Restrictions = { + reason: "market" | "product" | "explicit"; +}; diff --git a/types/spotify_manager/custom.types.ts b/types/spotify_manager/custom.types.ts new file mode 100644 index 0000000..1d61496 --- /dev/null +++ b/types/spotify_manager/custom.types.ts @@ -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; +} diff --git a/types/spotify_manager/endpoints.types.ts b/types/spotify_manager/endpoints.types.ts new file mode 100644 index 0000000..1a58c0f --- /dev/null +++ b/types/spotify_manager/endpoints.types.ts @@ -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; +export type GetUsersSavedAlbums = Pagination; +export type CheckUsersSavedAlbums = boolean[]; +export type GetNewReleases = { albums: Pagination }; + +// Artists +export type GetArtist = ArtistObject; +export type GetSeveralArtists = { artists: ArtistObject[] }; +export type GetArtistsAlbums = Pagination; +export type GetArtistsTopTracks = { tracks: TrackObject[] }; + +// Episodes +export type GetEpisode = EpisodeObject; +export type GetSeveralEpisodes = { episodes: EpisodeObject[] }; +export type GetUsersSavedEpisodes = Pagination; + +// Shows +export type GetShow = ShowObject; +export type GetSeveralShows = { shows: SimplifiedShowObject[] }; +export type GetShowEpisodes = Pagination; +export type GetUsersSavedShows = Pagination; + +// Playlists +export type GetPlaylist = PlaylistObject; +export type GetPlaylistItems = Pagination; +export type GetCurrentUsersPlaylists = Pagination; +export type GetUsersPlaylists = GetCurrentUsersPlaylists; +export type GetPlaylistCoverImage = ImageObject[]; + +// Tracks +export type GetTrack = TrackObject; +export type GetSeveralTracks = { tracks: TrackObject[] }; +export type GetUsersSavedTracks = Pagination; +export type CheckUsersSavedTracks = boolean[]; + +// Users +export type GetCurrentUsersProfile = UserObject; +export type GetUsersTopItems = + | Pagination + | Pagination; +export type GetUsersProfile = SimplifiedUserObject; +export type GetFollowedArtists = { artists: PaginationByCursor }; +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 = {}; + +// method +// Albums +// Artists +// Episodes +// Shows +// Playlists +// Tracks +// Users diff --git a/types/spotify_manager/index.d.ts b/types/spotify_manager/index.d.ts new file mode 100644 index 0000000..427f4d0 --- /dev/null +++ b/types/spotify_manager/index.d.ts @@ -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"; diff --git a/types/spotify_manager/objects.types.ts b/types/spotify_manager/objects.types.ts new file mode 100644 index 0000000..58b50f2 --- /dev/null +++ b/types/spotify_manager/objects.types.ts @@ -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; +} +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; +} +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; + followers: Followers; +} diff --git a/types/spotify_manager/shorthands.types.ts b/types/spotify_manager/shorthands.types.ts new file mode 100644 index 0000000..b10bf9b --- /dev/null +++ b/types/spotify_manager/shorthands.types.ts @@ -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; +} diff --git a/utils/flake.js b/utils/flake.js deleted file mode 100644 index 15cb218..0000000 --- a/utils/flake.js +++ /dev/null @@ -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; diff --git a/utils/flake.ts b/utils/flake.ts new file mode 100644 index 0000000..86c2d1b --- /dev/null +++ b/utils/flake.ts @@ -0,0 +1,7 @@ +export const sleep = (ms: number): Promise => + 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)); diff --git a/utils/generateRandString.js b/utils/generateRandString.ts similarity index 51% rename from utils/generateRandString.js rename to utils/generateRandString.ts index 8322637..ef139ac 100644 --- a/utils/generateRandString.js +++ b/utils/generateRandString.ts @@ -1,10 +1,9 @@ /** * Generates a random string containing numbers and letters - * @param {number} length The length of the string - * @return {string} The generated string */ -export default (length) => { - const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +export const generateRandString = (length: number): string => { + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let text = ""; for (let i = 0; i < length; i++) { diff --git a/utils/graph.js b/utils/graph.ts similarity index 50% rename from utils/graph.js rename to utils/graph.ts index 9276245..00dfcd0 100644 --- a/utils/graph.js +++ b/utils/graph.ts @@ -1,7 +1,5 @@ -import curriedLogger from "./logger.js"; -const logger = curriedLogger(import.meta); - -import * as typedefs from "../typedefs.js"; +export type GNode = string; +export type GEdge = { from: string; to: string }; /** * Directed graph, may or may not be connected. @@ -21,46 +19,38 @@ import * as typedefs from "../typedefs.js"; * let g = new myGraph(nodes, edges); * console.log(g.detectCycle()); // true * ``` -*/ + */ export class myGraph { + nodes: GNode[]; + edges: GEdge[]; /** - * @param {string[]} nodes Graph nodes IDs - * @param {{ from: string, to: string }[]} edges Graph edges b/w nodes - */ - constructor(nodes, edges) { - this.nodes = [...nodes]; + * @param nodes Graph nodes IDs + * @param edges Graph edges b/w nodes + */ + constructor(nodes: GNode[], edges: GEdge[]) { + this.nodes = structuredClone(nodes); this.edges = structuredClone(edges); } - /** - * @param {string} node - * @returns {string[]} - */ - getDirectHeads(node) { - return this.edges.filter(edge => edge.to == node).map(edge => edge.from); + getDirectHeads(node: GNode): GNode[] { + return this.edges + .filter((edge) => edge.to == node) + .map((edge) => edge.from); } - /** - * @param {string} node - * @returns {{ from: string, to: string }[]} - */ - getDirectHeadEdges(node) { - return this.edges.filter(edge => edge.to == node); + getDirectHeadEdges(node: GNode): GEdge[] { + return this.edges.filter((edge) => edge.to == node); } - /** - * BFS - * @param {string} node - * @returns {string[]} - */ - getAllHeads(node) { - const headSet = new Set(); - const toVisit = new Set(); // queue + /** BFS */ + getAllHeads(node: GNode): GNode[] { + const headSet = new Set(); + const toVisit = new Set(); // queue toVisit.add(node); while (toVisit.size > 0) { - const nextNode = toVisit.values().next().value; + const nextNode = toVisit.values().next().value!; const nextHeads = this.getDirectHeads(nextNode); - nextHeads.forEach(head => { + nextHeads.forEach((head) => { headSet.add(head); toVisit.add(head); }); @@ -69,35 +59,29 @@ export class myGraph { return [...headSet]; } - /** - * @param {string} node - * @returns {string[]} - */ - getDirectTails(node) { - return this.edges.filter(edge => edge.from == node).map(edge => edge.to); + getDirectTails(node: GNode): GNode[] { + return this.edges + .filter((edge) => edge.from == node) + .map((edge) => edge.to); } /** * @param {string} node * @returns {{ from: string, to: string }[]} - */ - getDirectTailEdges(node) { - return this.edges.filter(edge => edge.from == node); + */ + getDirectTailEdges(node: GNode): GEdge[] { + return this.edges.filter((edge) => edge.from == node); } - /** - * BFS - * @param {string} node - * @returns {string[]} - */ - getAllTails(node) { - const tailSet = new Set(); - const toVisit = new Set(); // queue + /** BFS */ + getAllTails(node: GNode): GNode[] { + const tailSet = new Set(); + const toVisit = new Set(); // queue toVisit.add(node); while (toVisit.size > 0) { - const nextNode = toVisit.values().next().value; + const nextNode = toVisit.values().next().value!; const nextTails = this.getDirectTails(nextNode); - nextTails.forEach(tail => { + nextTails.forEach((tail) => { tailSet.add(tail); toVisit.add(tail); }); @@ -106,14 +90,11 @@ export class myGraph { return [...tailSet]; } - /** - * Kahn's topological sort - * @returns {string[]} - */ - topoSort() { - let inDegree = {}; - let zeroInDegreeQueue = []; - let topologicalOrder = []; + /** Kahn's topological sort */ + topoSort(): GNode[] { + let inDegree: Record = {}; + let zeroInDegreeQueue: GNode[] = []; + let topologicalOrder: GNode[] = []; // Initialize inDegree of all nodes to 0 for (let node of this.nodes) { @@ -122,7 +103,7 @@ export class myGraph { // Calculate inDegree of each node for (let edge of this.edges) { - inDegree[edge.to]++; + inDegree[edge.to]!++; } // Collect nodes with 0 inDegree @@ -135,10 +116,10 @@ export class myGraph { // process nodes with 0 inDegree while (zeroInDegreeQueue.length > 0) { let node = zeroInDegreeQueue.shift(); - topologicalOrder.push(node); + topologicalOrder.push(node!); - for (let tail of this.getDirectTails(node)) { - inDegree[tail]--; + for (let tail of this.getDirectTails(node!)) { + inDegree[tail]!--; if (inDegree[tail] === 0) { zeroInDegreeQueue.push(tail); } @@ -147,11 +128,8 @@ export class myGraph { return topologicalOrder; } - /** - * Check if the graph contains a cycle - * @returns {boolean} - */ - detectCycle() { + /** Check if the graph contains a cycle */ + detectCycle(): boolean { // If topological order includes all nodes, no cycle exists return this.topoSort().length < this.nodes.length; } diff --git a/utils/jsonTransformer.js b/utils/jsonTransformer.js deleted file mode 100644 index 54bbda3..0000000 --- a/utils/jsonTransformer.js +++ /dev/null @@ -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); -} diff --git a/utils/jsonTransformer.ts b/utils/jsonTransformer.ts new file mode 100644 index 0000000..2056719 --- /dev/null +++ b/utils/jsonTransformer.ts @@ -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); +}; diff --git a/utils/logger.js b/utils/logger.js deleted file mode 100644 index d96b546..0000000 --- a/utils/logger.js +++ /dev/null @@ -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; diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..6ceba79 --- /dev/null +++ b/utils/logger.ts @@ -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) => { + 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>(); + +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; diff --git a/utils/spotifyUriTransformer.js b/utils/spotifyUriTransformer.ts similarity index 58% rename from utils/spotifyUriTransformer.js rename to utils/spotifyUriTransformer.ts index 7c76bd9..e8c5b80 100644 --- a/utils/spotifyUriTransformer.js +++ b/utils/spotifyUriTransformer.ts @@ -1,49 +1,57 @@ -import * as typedefs from "../typedefs.js"; +import type { URIObject } from "spotify_manager/index.d.ts"; -/** @type {RegExp} */ -const base62Pattern = /^[A-Za-z0-9]+$/; +const base62Pattern: RegExp = /^[A-Za-z0-9]+$/; /** * 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} - * @param {string} uri Spotify URI - can be of an album, track, playlist, user, episode, etc. - * @returns {typedefs.URIObject} + * @param uri Spotify URI - can be of an album, track, playlist, user, episode, etc. * @throws {TypeError} If the input is not a valid Spotify URI */ -export const parseSpotifyURI = (uri) => { +const parseSpotifyURI = (uri: string): URIObject => { const parts = uri.split(":"); if (parts[0] !== "spotify") { throw new TypeError(`${uri} is not a valid Spotify URI`); } - let type = parts[1]; + let type = parts[1] ?? ""; + // Local file format: spotify:local::::<duration> if (type === "local") { - // Local file format: spotify:local:<artist>:<album>:<title>:<duration> let idParts = parts.slice(2); - if (idParts.length < 4) { + if (idParts.length !== 4) { throw new TypeError(`${uri} is not a valid local file URI`); } // URL decode artist, album, and title - const artist = decodeURIComponent(idParts[0] || ""); - const album = decodeURIComponent(idParts[1] || ""); - const title = decodeURIComponent(idParts[2]); - const duration = parseInt(idParts[3], 10); + // NOTE: why do i have to do non-null assertion here... + const artist = decodeURIComponent(idParts[0] ?? ""); + const album = decodeURIComponent(idParts[1] ?? ""); + const title = decodeURIComponent(idParts[2] ?? ""); + let duration = parseInt(idParts[3] ?? "", 10); - if (isNaN(duration)) { - throw new TypeError(`${uri} has an invalid duration`); + let uriObj: URIObject = { + 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 { // Not a local file if (parts.length !== 3) { throw new TypeError(`${uri} is not a valid Spotify URI`); } - const id = parts[2]; + const id = parts[2] ?? ""; if (!base62Pattern.test(id)) { throw new TypeError(`${uri} has an invalid ID`); @@ -51,16 +59,16 @@ export const parseSpotifyURI = (uri) => { return { type, is_local: false, id }; } -} +}; /** * Returns type and ID from a Spotify link * @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 */ -export const parseSpotifyLink = (link) => { - const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/; +const parseSpotifyLink = (link: string): URIObject => { + const localPattern = + /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/; const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/; if (localPattern.test(link)) { @@ -71,16 +79,24 @@ export const parseSpotifyLink = (link) => { } // URL decode artist, album, and title - const artist = decodeURIComponent(matches[1] || ""); - const album = decodeURIComponent(matches[2] || ""); - const title = decodeURIComponent(matches[3]); - const duration = parseInt(matches[4], 10); + const artist = decodeURIComponent(matches[1] ?? ""); + const album = decodeURIComponent(matches[2] ?? ""); + const title = decodeURIComponent(matches[3] ?? ""); + const duration = parseInt(matches[4] ?? "", 10); if (isNaN(duration)) { throw new TypeError(`${link} has an invalid duration`); } - 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)) { // Not a local file const matches = link.match(standardPattern); @@ -88,8 +104,8 @@ export const parseSpotifyLink = (link) => { throw new TypeError(`${link} is not a valid Spotify link`); } - const type = matches[1]; - const id = matches[2]; + const type = matches[1] ?? ""; + const id = matches[2] ?? ""; if (!base62Pattern.test(id)) { throw new TypeError(`${link} has an invalid ID`); @@ -99,14 +115,10 @@ export const parseSpotifyLink = (link) => { } else { throw new TypeError(`${link} is not a valid Spotify link`); } -} +}; -/** - * Builds URI string from a URIObject - * @param {typedefs.URIObject} uriObj - * @returns {string} - */ -export const buildSpotifyURI = (uriObj) => { +/** Builds URI string from a URIObject */ +const buildSpotifyURI = (uriObj: URIObject): string => { if (uriObj.is_local) { const artist = encodeURIComponent(uriObj.artist ?? ""); const album = encodeURIComponent(uriObj.album ?? ""); @@ -115,14 +127,10 @@ export const buildSpotifyURI = (uriObj) => { return `spotify:local:${artist}:${album}:${title}:${duration}`; } return `spotify:${uriObj.type}:${uriObj.id}`; -} +}; -/** - * Builds link from a URIObject - * @param {typedefs.URIObject} uriObj - * @returns {string} - */ -export const buildSpotifyLink = (uriObj) => { +/** Builds link from a URIObject */ +const buildSpotifyLink = (uriObj: URIObject): string => { if (uriObj.is_local) { const artist = encodeURIComponent(uriObj.artist ?? ""); const album = encodeURIComponent(uriObj.album ?? ""); @@ -130,5 +138,7 @@ export const buildSpotifyLink = (uriObj) => { const duration = uriObj.duration ? uriObj.duration.toString() : ""; return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`; } - return `https://open.spotify.com/${uriObj.type}/${uriObj.id}` -} + return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`; +}; + +export { parseSpotifyLink, parseSpotifyURI, buildSpotifyLink, buildSpotifyURI }; diff --git a/validators/index.js b/validators/index.js deleted file mode 100644 index 75f09f6..0000000 --- a/validators/index.js +++ /dev/null @@ -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; -} diff --git a/validators/index.ts b/validators/index.ts new file mode 100644 index 0000000..3748792 --- /dev/null +++ b/validators/index.ts @@ -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; +}; diff --git a/validators/operations.js b/validators/operations.js deleted file mode 100644 index e0b7041..0000000 --- a/validators/operations.js +++ /dev/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 }; diff --git a/validators/operations.ts b/validators/operations.ts new file mode 100644 index 0000000..e0c643d --- /dev/null +++ b/validators/operations.ts @@ -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, +}; diff --git a/validators/playlists.js b/validators/playlists.js deleted file mode 100644 index 07a1100..0000000 --- a/validators/playlists.js +++ /dev/null @@ -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(); -} diff --git a/validators/playlists.ts b/validators/playlists.ts new file mode 100644 index 0000000..35e59d4 --- /dev/null +++ b/validators/playlists.ts @@ -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 };