diff --git a/backend/api/controllers/user.ts b/backend/api/controllers/user.ts index bd0bc1889..4b8a43a15 100644 --- a/backend/api/controllers/user.ts +++ b/backend/api/controllers/user.ts @@ -302,3 +302,41 @@ export async function getPersonalBests( )) ?? null; return new MonkeyResponse("Personal bests retrieved", data); } + +export async function getFavoriteQuotes( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const quotes = await UserDAL.getFavoriteQuotes(uid); + + return new MonkeyResponse("Favorite quotes retrieved", quotes); +} + +export async function addFavoriteQuote( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const { language, quoteId } = req.body; + + await UserDAL.addFavoriteQuote( + uid, + language, + quoteId, + req.ctx.configuration.favoriteQuotes.maxFavorites + ); + + return new MonkeyResponse("Quote added to favorites"); +} + +export async function removeFavoriteQuote( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const { quoteId, language } = req.body; + await UserDAL.removeFavoriteQuote(uid, language, quoteId); + + return new MonkeyResponse("Quote removed from favorites"); +} diff --git a/backend/api/routes/quotes.ts b/backend/api/routes/quotes.ts index c6262ae9d..2dc764a2d 100644 --- a/backend/api/routes/quotes.ts +++ b/backend/api/routes/quotes.ts @@ -10,7 +10,7 @@ import { validateRequest, } from "../../middlewares/api-utils"; -const quotesRouter = Router(); +const router = Router(); const checkIfUserIsQuoteMod = checkUserPermissions({ criteria: (user) => { @@ -18,7 +18,7 @@ const checkIfUserIsQuoteMod = checkUserPermissions({ }, }); -quotesRouter.get( +router.get( "/", RateLimit.newQuotesGet, authenticateRequest(), @@ -26,7 +26,7 @@ quotesRouter.get( asyncHandler(QuoteController.getQuotes) ); -quotesRouter.post( +router.post( "/", validateConfiguration({ criteria: (configuration) => { @@ -49,7 +49,7 @@ quotesRouter.post( asyncHandler(QuoteController.addQuote) ); -quotesRouter.post( +router.post( "/approve", RateLimit.newQuotesAction, authenticateRequest(), @@ -65,7 +65,7 @@ quotesRouter.post( asyncHandler(QuoteController.approveQuote) ); -quotesRouter.post( +router.post( "/reject", RateLimit.newQuotesAction, authenticateRequest(), @@ -78,7 +78,7 @@ quotesRouter.post( asyncHandler(QuoteController.refuseQuote) ); -quotesRouter.get( +router.get( "/rating", RateLimit.quoteRatingsGet, authenticateRequest(), @@ -91,7 +91,7 @@ quotesRouter.get( asyncHandler(QuoteController.getRating) ); -quotesRouter.post( +router.post( "/rating", RateLimit.quoteRatingsSubmit, authenticateRequest(), @@ -105,7 +105,7 @@ quotesRouter.post( asyncHandler(QuoteController.submitRating) ); -quotesRouter.post( +router.post( "/report", validateConfiguration({ criteria: (configuration) => { @@ -140,4 +140,4 @@ quotesRouter.post( asyncHandler(QuoteController.reportQuote) ); -export default quotesRouter; +export default router; diff --git a/backend/api/routes/users.ts b/backend/api/routes/users.ts index 27218b267..494c29d83 100644 --- a/backend/api/routes/users.ts +++ b/backend/api/routes/users.ts @@ -72,6 +72,9 @@ const usernameValidation = joi "Username invalid. Name cannot contain special characters or contain more than 14 characters. Can include _ . and -", }); +const languageSchema = joi.string().min(1).required(); +const quoteIdSchema = joi.string().min(1).max(5).regex(/\d+/).required(); + router.get( "/", RateLimit.userGet, @@ -304,4 +307,37 @@ router.get( asyncHandler(UserController.getPersonalBests) ); +router.get( + "/favoriteQuotes", + RateLimit.quoteFavoriteGet, + authenticateRequest(), + asyncHandler(UserController.getFavoriteQuotes) +); + +router.post( + "/favoriteQuotes", + RateLimit.quoteFavoritePost, + authenticateRequest(), + validateRequest({ + body: { + language: languageSchema, + quoteId: quoteIdSchema, + }, + }), + asyncHandler(UserController.addFavoriteQuote) +); + +router.delete( + "/favoriteQuotes", + RateLimit.quoteFavoriteDelete, + authenticateRequest(), + validateRequest({ + body: { + language: languageSchema, + quoteId: quoteIdSchema, + }, + }), + asyncHandler(UserController.removeFavoriteQuote) +); + export default router; diff --git a/backend/constants/base-configuration.ts b/backend/constants/base-configuration.ts index fd286715f..1acab24f8 100644 --- a/backend/constants/base-configuration.ts +++ b/backend/constants/base-configuration.ts @@ -29,6 +29,9 @@ const BASE_CONFIGURATION: MonkeyTypes.Configuration = { useRedisForBotTasks: { enabled: false, }, + favoriteQuotes: { + maxFavorites: 100, + }, }; export default BASE_CONFIGURATION; diff --git a/backend/dao/user.ts b/backend/dao/user.ts index a58373053..3497bd7d7 100644 --- a/backend/dao/user.ts +++ b/backend/dao/user.ts @@ -534,3 +534,85 @@ export async function getPersonalBests( return user?.personalBests?.[mode]; } + +export async function getFavoriteQuotes( + uid +): Promise { + const user = await db.collection("users").findOne({ uid }); + + if (!user) { + throw new MonkeyError(404, "User not found", "getFavoriteQuotes"); + } + + return user.favoriteQuotes ?? {}; +} + +export async function addFavoriteQuote( + uid: string, + language: string, + quoteId: string, + maxQuotes: number +): Promise { + const usersCollection = db.collection("users"); + const user = await usersCollection.findOne({ uid }); + + if (!user) { + throw new MonkeyError(404, "User does not exist", "addFavoriteQuote"); + } + + if (user.favoriteQuotes) { + if ( + user.favoriteQuotes[language] && + user.favoriteQuotes[language].includes(quoteId) + ) { + return; + } + + const quotesLength = _.sumBy( + Object.values(user.favoriteQuotes), + (favQuotes) => favQuotes.length + ); + + if (quotesLength >= maxQuotes) { + throw new MonkeyError( + 409, + "Too many favorite quotes", + "addFavoriteQuote" + ); + } + } + + await usersCollection.updateOne( + { uid }, + { + $push: { + [`favoriteQuotes.${language}`]: quoteId, + }, + } + ); +} + +export async function removeFavoriteQuote( + uid: string, + language: string, + quoteId: string +): Promise { + const usersCollection = await db.collection("users"); + const user = await usersCollection.findOne({ uid }); + if (!user) { + throw new MonkeyError(404, "User does not exist", "deleteFavoriteQuote"); + } + + if ( + !user.favoriteQuotes || + !user.favoriteQuotes[language] || + !user.favoriteQuotes[language].includes(quoteId) + ) { + return; + } + + await usersCollection.updateOne( + { uid }, + { $pull: { [`favoriteQuotes.${language}`]: quoteId } } + ); +} diff --git a/backend/middlewares/rate-limit.ts b/backend/middlewares/rate-limit.ts index b9fd0fffc..a0cb5748f 100644 --- a/backend/middlewares/rate-limit.ts +++ b/backend/middlewares/rate-limit.ts @@ -90,6 +90,28 @@ export const quoteReportSubmit = rateLimit({ handler: customHandler, }); +// Quote favorites +export const quoteFavoriteGet = rateLimit({ + windowMs: 30 * 60 * 1000, // 30 min + max: 50 * REQUEST_MULTIPLIER, + keyGenerator: getAddress, + handler: customHandler, +}); + +export const quoteFavoritePost = rateLimit({ + windowMs: 30 * 60 * 1000, // 30 min + max: 50 * REQUEST_MULTIPLIER, + keyGenerator: getAddress, + handler: customHandler, +}); + +export const quoteFavoriteDelete = rateLimit({ + windowMs: 30 * 60 * 1000, // 30 min + max: 50 * REQUEST_MULTIPLIER, + keyGenerator: getAddress, + handler: customHandler, +}); + // Presets Routing export const presetsGet = rateLimit({ windowMs: ONE_HOUR, diff --git a/backend/types/types.d.ts b/backend/types/types.d.ts index 33fc7ca37..3dff04cee 100644 --- a/backend/types/types.d.ts +++ b/backend/types/types.d.ts @@ -29,6 +29,9 @@ declare namespace MonkeyTypes { useRedisForBotTasks: { enabled: boolean; }; + favoriteQuotes: { + maxFavorites: number; + }; } interface DecodedToken { @@ -71,6 +74,7 @@ declare namespace MonkeyTypes { cannotReport?: boolean; banned?: boolean; canManageApeKeys?: boolean; + favoriteQuotes?: Record; } type UserQuoteRatings = Record>;