diff --git a/23 b/23 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 2cc6ee318..561e28c7f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -5,6 +5,7 @@ PORT=3333 BASE_URL=http://localhost:3333 JWT_SECRET= CLIENT_URL=http://localhost:3000 +IOS_TOKEN= # Database DATABASE_URL="postgresql://postgres:postgres@localhost:5432" diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 1e214dfd3..4ada73309 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -26,8 +26,6 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: plotwist_db POSTGRESQL_REPLICATION_USE_PASSFILE: "no" - volumes: - - postgres_data:/var/lib/postgresql/data ports: - 5432:5432 networks: @@ -60,7 +58,6 @@ services: - plotwist_network volumes: - postgres_data: redis-data: localstack_data: driver: local diff --git a/apps/backend/fly.toml b/apps/backend/fly.toml deleted file mode 100644 index a4e6c42ca..000000000 --- a/apps/backend/fly.toml +++ /dev/null @@ -1,24 +0,0 @@ -# fly.toml app configuration file generated for plotwist-backend on 2024-11-29T23:07:42Z -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app_name = "plotwist-backend" -app = 'plotwist-backend' -primary_region = 'gru' - -[build] - -[http_service] - internal_port = 3000 - force_https = true - auto_stop_machines = false - auto_start_machines = true - min_machines_running = 0 - processes = ['app'] - -[[vm]] - memory = '1gb' - cpu_kind = 'shared' - cpus = 1 - memory_mb = 1024 diff --git a/apps/backend/package.json b/apps/backend/package.json index 7a92a730a..fea1569af 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -32,6 +32,7 @@ "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/redis": "^7.1.0", "@fastify/swagger": "^9.6.1", "@fastify/swagger-ui": "^5.2.4", diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index a016dba76..0637da963 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -34,7 +34,7 @@ function loadServicesEnvs() { function loadDatabaseEnvs() { const schema = z.object({ - DATABASE_URL: z.string().url(), + DATABASE_URL: z.string(), }) return schema.parse(process.env) @@ -44,9 +44,12 @@ function loadAppEnvs() { const schema = z.object({ APP_ENV: z.enum(['dev', 'test', 'production']).optional().default('dev'), CLIENT_URL: z.string(), + IOS_TOKEN: z.string().optional().default(''), PORT: z.coerce.number().default(3333), BASE_URL: z.string().default('http://localhost:3333'), JWT_SECRET: z.string(), + RATE_LIMIT_MAX: z.coerce.number().optional().default(100), + RATE_LIMIT_TIME_WINDOW_MS: z.coerce.number().optional().default(60_000), }) return schema.parse(process.env) diff --git a/apps/backend/src/domain/services/feedback/create-feedback.ts b/apps/backend/src/domain/services/feedback/create-feedback.ts index b090b4987..54079d326 100644 --- a/apps/backend/src/domain/services/feedback/create-feedback.ts +++ b/apps/backend/src/domain/services/feedback/create-feedback.ts @@ -1,7 +1,7 @@ import { insertFeedback } from '@/db/repositories/feedback-repository' import { isForeignKeyViolation } from '@/db/utils/postgres-errors' -import { UserNotFoundError } from '@/domain/errors/user-not-found' import type { InsertFeedbackModel } from '@/domain/entities/feedback' +import { UserNotFoundError } from '@/domain/errors/user-not-found' export async function createFeedbackService(params: InsertFeedbackModel) { try { diff --git a/apps/backend/src/domain/services/user-items/get-user-items-count.ts b/apps/backend/src/domain/services/user-items/get-user-items-count.ts index 417524c41..bfea8e9b9 100644 --- a/apps/backend/src/domain/services/user-items/get-user-items-count.ts +++ b/apps/backend/src/domain/services/user-items/get-user-items-count.ts @@ -4,7 +4,9 @@ type GetUserItemsCountInput = { userId: string } -export async function getUserItemsCountService({ userId }: GetUserItemsCountInput) { +export async function getUserItemsCountService({ + userId, +}: GetUserItemsCountInput) { const count = await selectUserItemsCount(userId) return { diff --git a/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts index e495218e4..811a4ced4 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts @@ -34,7 +34,9 @@ async function getMovieRuntimes( watchedItems: Awaited>, redis: FastifyRedis ) { - const movies = watchedItems.userItems.filter(item => item.mediaType === 'MOVIE') + const movies = watchedItems.userItems.filter( + item => item.mediaType === 'MOVIE' + ) return processInBatches(movies, async item => { const { runtime } = await getTMDBMovieService(redis, { diff --git a/apps/backend/src/http/client-guard.ts b/apps/backend/src/http/client-guard.ts new file mode 100644 index 000000000..e43a8f8d1 --- /dev/null +++ b/apps/backend/src/http/client-guard.ts @@ -0,0 +1,75 @@ +import { timingSafeEqual } from 'node:crypto' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { config } from '@/config' + +const SKIP_PATHS = ['/health', '/complete-stripe-subscription'] +const ALLOWED_ORIGINS = ['https://plotwist.app'] + +function pathMatches(path: string) { + return SKIP_PATHS.some(s => path === s || path.endsWith(s)) +} + +function timingSafeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false + const bufA = Buffer.from(a, 'utf8') + const bufB = Buffer.from(b, 'utf8') + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB) +} + +function allowedOrigin( + origin: string | undefined, + referer: string | undefined +): boolean { + const allowed = [...new Set([config.app.CLIENT_URL, ...ALLOWED_ORIGINS])] + return ( + (typeof origin === 'string' && allowed.includes(origin)) || + (typeof referer === 'string' && allowed.some(o => referer.startsWith(o))) + ) +} + +function forbidden(reply: FastifyReply) { + return reply.status(403).send({ + statusCode: 403, + error: 'Forbidden', + message: 'Request not allowed from this client.', + }) +} + +function validIosToken(request: FastifyRequest, app: FastifyInstance): boolean { + const expected = config.app.IOS_TOKEN + if (!expected) { + app.log.warn('X-IOS-Token received but IOS_TOKEN is not set.') + return false + } + const received = request.headers['x-ios-token'] + return typeof received === 'string' && timingSafeCompare(received, expected) +} + +/** + * Production-only guard: + * - X-IOS-Token present → must match IOS_TOKEN. + * - X-Android-Token present → not implemented (403). + * - Otherwise → allow only if Origin/Referer is in allowed list (e.g. plotwist.app). + */ +export function registerClientGuard(app: FastifyInstance) { + app.addHook('onRequest', async (request, reply) => { + if (config.app.APP_ENV !== 'production') return + const path = request.url.split('?')[0] + if (pathMatches(path)) return + + const iosToken = request.headers['x-ios-token'] + if (typeof iosToken === 'string' && iosToken.length > 0) { + if (!validIosToken(request, app)) return forbidden(reply) + return + } + + const androidToken = request.headers['x-android-token'] + if (typeof androidToken === 'string' && androidToken.length > 0) { + app.log.warn('X-Android-Token received but not implemented yet.') + return forbidden(reply) + } + + if (allowedOrigin(request.headers.origin, request.headers.referer)) return + return forbidden(reply) + }) +} diff --git a/apps/backend/src/http/controllers/user-items-controller.ts b/apps/backend/src/http/controllers/user-items-controller.ts index adbd2679e..1e64e1120 100644 --- a/apps/backend/src/http/controllers/user-items-controller.ts +++ b/apps/backend/src/http/controllers/user-items-controller.ts @@ -15,9 +15,9 @@ import { getAllUserItemsService } from '@/domain/services/user-items/get-all-use import { getUserItemService } from '@/domain/services/user-items/get-user-item' import { getUserItemsService } from '@/domain/services/user-items/get-user-items' import { getUserItemsCountService } from '@/domain/services/user-items/get-user-items-count' +import { reorderUserItemsService } from '@/domain/services/user-items/reorder-user-items' import { upsertUserItemService } from '@/domain/services/user-items/upsert-user-item' import { invalidateUserStatsCache } from '@/domain/services/user-stats/cache-utils' -import { reorderUserItemsService } from '@/domain/services/user-items/reorder-user-items' import { deleteUserItemParamsSchema, getAllUserItemsQuerySchema, @@ -91,18 +91,23 @@ export async function upsertUserItemController( }, }) - // Invalidate user stats cache since item status changed await invalidateUserStatsCache(redis, request.user.id) - // Fetch the user item via Drizzle ORM to ensure consistent format with getUserItem const result = await getUserItemService({ mediaType, tmdbId, userId: request.user.id, }) - // Ensure dates are serialized as ISO strings - const userItem = result.userItem! + const userItem = result.userItem + if (!userItem) { + return reply.status(500).send({ + statusCode: 500, + error: 'Internal Server Error', + message: 'User item could not be retrieved after upsert.', + }) + } + return reply.status(201).send({ userItem: { id: userItem.id, diff --git a/apps/backend/src/http/rate-limit.ts b/apps/backend/src/http/rate-limit.ts new file mode 100644 index 000000000..678d6c91b --- /dev/null +++ b/apps/backend/src/http/rate-limit.ts @@ -0,0 +1,22 @@ +import fastifyRateLimit from '@fastify/rate-limit' +import type { FastifyInstance } from 'fastify' +import { config } from '@/config' + +export function registerRateLimit(app: FastifyInstance) { + app.register(async instance => { + await instance.register(fastifyRateLimit, { + redis: instance.redis, + max: config.app.RATE_LIMIT_MAX, + timeWindow: config.app.RATE_LIMIT_TIME_WINDOW_MS, + skipOnError: true, + errorResponseBuilder: ( + _request: unknown, + context: { after: string } + ) => ({ + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded, retry in ${Math.ceil(Number(context.after) / 1000)} seconds`, + }), + }) + }) +} diff --git a/apps/backend/src/http/routes/healthcheck.ts b/apps/backend/src/http/routes/healthcheck.ts index 03efca73a..752bb74ba 100644 --- a/apps/backend/src/http/routes/healthcheck.ts +++ b/apps/backend/src/http/routes/healthcheck.ts @@ -4,6 +4,7 @@ export const healthCheck = (app: FastifyInstance) => app.route({ method: 'GET', url: '/health', + config: { rateLimit: false }, handler: (_request, reply) => { reply.send({ alive: true }) }, diff --git a/apps/backend/src/http/routes/index.ts b/apps/backend/src/http/routes/index.ts index 74ef93dc5..b76e5bc2a 100644 --- a/apps/backend/src/http/routes/index.ts +++ b/apps/backend/src/http/routes/index.ts @@ -7,6 +7,7 @@ import fastifySwaggerUi from '@fastify/swagger-ui' import type { FastifyInstance } from 'fastify' import { config } from '@/config' +import { registerRateLimit } from '../rate-limit' import { feedbackRoutes } from './feedback' import { followsRoutes } from './follow' import { healthCheck } from './healthcheck' @@ -40,8 +41,9 @@ export function routes(app: FastifyInstance) { app.register(fastifyCors, { origin: getCorsOrigin(), methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Client-Token'], credentials: true, + strictPreflight: false, }) app.register(fastifyJwt, { @@ -54,6 +56,8 @@ export function routes(app: FastifyInstance) { url: config.redis.REDIS_URL, }) + registerRateLimit(app) + app.register(usersRoute) app.register(listsRoute) app.register(loginRoute) @@ -81,36 +85,16 @@ export function routes(app: FastifyInstance) { return } -function getCorsOrigin() { +function getCorsOrigin(): true | string[] { if (config.app.APP_ENV !== 'production') { - return true // Permite todas as origens em dev/test + return true } - // Em produção, permite múltiplas origens const allowedOrigins = [ config.app.CLIENT_URL, 'https://plotwist.app', 'https://www.plotwist.app', ] - // Remove duplicatas caso CLIENT_URL já seja plotwist.app - const uniqueOrigins = [...new Set(allowedOrigins)] - - return ( - origin: string | undefined, - callback: (err: Error | null, allow?: boolean) => void - ) => { - // Apps nativos (iOS/Android) podem não enviar header Origin - // Nesse caso, permitimos a requisição - if (!origin) { - callback(null, true) - return - } - - if (uniqueOrigins.includes(origin)) { - callback(null, true) - } else { - callback(new Error('Not allowed by CORS'), false) - } - } + return [...new Set(allowedOrigins)] } diff --git a/apps/backend/src/http/routes/watch-entries.ts b/apps/backend/src/http/routes/watch-entries.ts index 2046845f1..4e8130cfb 100644 --- a/apps/backend/src/http/routes/watch-entries.ts +++ b/apps/backend/src/http/routes/watch-entries.ts @@ -1,12 +1,12 @@ import type { FastifyInstance } from 'fastify' import type { ZodTypeProvider } from 'fastify-type-provider-zod' -import { verifyJwt } from '../middlewares/verify-jwt' import { createWatchEntryController, deleteWatchEntryController, getWatchEntriesController, updateWatchEntryController, } from '../controllers/watch-entries-controller' +import { verifyJwt } from '../middlewares/verify-jwt' import { createWatchEntryBodySchema, createWatchEntryResponseSchema, diff --git a/apps/backend/src/http/routes/webhook.ts b/apps/backend/src/http/routes/webhook.ts index 8e69dcc11..a7ad56ba7 100644 --- a/apps/backend/src/http/routes/webhook.ts +++ b/apps/backend/src/http/routes/webhook.ts @@ -21,6 +21,7 @@ export async function webhookRoutes(app: FastifyInstance) { app.withTypeProvider().route({ method: 'POST', url: '/complete-stripe-subscription', + config: { rateLimit: false }, schema: { description: 'Webhook route', tags: ['Webhook'], diff --git a/apps/backend/src/http/server.ts b/apps/backend/src/http/server.ts index 77b78cadd..a81130a6d 100644 --- a/apps/backend/src/http/server.ts +++ b/apps/backend/src/http/server.ts @@ -67,10 +67,26 @@ export function startServer() { .send({ message: 'You hit the rate limit! Slow down please!' }) } + if ( + typeof (error as { statusCode?: number }).statusCode === 'number' && + (error as { statusCode: number }).statusCode === 429 + ) { + if (!reply.sent) { + return reply + .code(429) + .send( + (error as { message?: string }).message ?? 'Rate limit exceeded.' + ) + } + return + } + console.error({ error }) return reply.status(500).send({ message: 'Internal server error.' }) }) + // TODO: Uncomment this when we have a client guard + // registerClientGuard(app) routes(app) app diff --git a/apps/ios/Plotwist/Plotwist/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/Plotwist/Plotwist/Assets.xcassets/AccentColor.colorset/Contents.json index eb8789700..0afb3cf0e 100644 --- a/apps/ios/Plotwist/Plotwist/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/apps/ios/Plotwist/Plotwist/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,11 +1,11 @@ { - "colors" : [ + "colors": [ { - "idiom" : "universal" + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/apps/ios/Plotwist/Plotwist/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Plotwist/Plotwist/Assets.xcassets/AppIcon.appiconset/Contents.json index 87d401528..e59070cb9 100644 --- a/apps/ios/Plotwist/Plotwist/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Plotwist/Plotwist/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,38 +1,38 @@ { - "images" : [ + "images": [ { - "filename" : "AppIcon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "AppIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "dark" + "appearance": "luminosity", + "value": "dark" } ], - "filename" : "AppIcon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "AppIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "tinted" + "appearance": "luminosity", + "value": "tinted" } ], - "filename" : "AppIcon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "AppIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/apps/ios/Plotwist/Plotwist/Assets.xcassets/Contents.json b/apps/ios/Plotwist/Plotwist/Assets.xcassets/Contents.json index 73c00596a..74d6a722c 100644 --- a/apps/ios/Plotwist/Plotwist/Assets.xcassets/Contents.json +++ b/apps/ios/Plotwist/Plotwist/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/apps/ios/Plotwist/Plotwist/Assets.xcassets/FilmStrip.imageset/Contents.json b/apps/ios/Plotwist/Plotwist/Assets.xcassets/FilmStrip.imageset/Contents.json index 67f6f1003..182574113 100644 --- a/apps/ios/Plotwist/Plotwist/Assets.xcassets/FilmStrip.imageset/Contents.json +++ b/apps/ios/Plotwist/Plotwist/Assets.xcassets/FilmStrip.imageset/Contents.json @@ -1,25 +1,25 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "idiom" : "universal", - "scale" : "2x" + "idiom": "universal", + "scale": "2x" }, { - "filename" : "film-strip.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "film-strip.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "original" + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" } } diff --git a/apps/ios/Plotwist/Plotwist/Assets.xcassets/tmdb-logo.imageset/Contents.json b/apps/ios/Plotwist/Plotwist/Assets.xcassets/tmdb-logo.imageset/Contents.json index 8fc747dab..bfbca785b 100644 --- a/apps/ios/Plotwist/Plotwist/Assets.xcassets/tmdb-logo.imageset/Contents.json +++ b/apps/ios/Plotwist/Plotwist/Assets.xcassets/tmdb-logo.imageset/Contents.json @@ -1,16 +1,16 @@ { - "images" : [ + "images": [ { - "filename" : "tmdb.svg", - "idiom" : "universal" + "filename": "tmdb.svg", + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "original" + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" } } diff --git a/apps/ios/Plotwist/Plotwist/Services/ReviewService.swift b/apps/ios/Plotwist/Plotwist/Services/ReviewService.swift index 13398eba6..226d3806c 100644 --- a/apps/ios/Plotwist/Plotwist/Services/ReviewService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/ReviewService.swift @@ -258,11 +258,9 @@ class ReviewService { } var request = URLRequest(url: url) - if let token = UserDefaults.standard.string(forKey: "token") { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { @@ -299,7 +297,6 @@ class ReviewService { } var request = URLRequest(url: url) - // Add token if available (optional auth) if let token = UserDefaults.standard.string(forKey: "token") { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818fb..a3e4680c7 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import './.next/dev/types/routes.d.ts' // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/[lang]/[username]/_components/user-activities.tsx b/apps/web/src/app/[lang]/[username]/_components/user-activities.tsx index 2a51e09c9..c8bdfdb5d 100644 --- a/apps/web/src/app/[lang]/[username]/_components/user-activities.tsx +++ b/apps/web/src/app/[lang]/[username]/_components/user-activities.tsx @@ -299,11 +299,13 @@ export function WatchEpisodeActivity({
    - {episodes.map((episode: { seasonNumber: number; episodeNumber: number }) => ( -
  • - • S{episode.seasonNumber}, EP{episode.episodeNumber} -
  • - ))} + {episodes.map( + (episode: { seasonNumber: number; episodeNumber: number }) => ( +
  • + • S{episode.seasonNumber}, EP{episode.episodeNumber} +
  • + ) + )}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b729d45a5..288e4e775 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@fastify/multipart': specifier: ^9.3.0 version: 9.4.0 + '@fastify/rate-limit': + specifier: ^10.3.0 + version: 10.3.0 '@fastify/redis': specifier: ^7.1.0 version: 7.2.0 @@ -2150,6 +2153,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/redis@7.2.0': resolution: {integrity: sha512-Ql8emmkajVBCCz0pRjPFZRFI2069Jrp/iufKb5TJwgBeu7B+oZp+GeZIgf+4WX3EX7Vi/h3KTyGf+5DUWI1oFw==} @@ -10574,6 +10580,12 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + '@fastify/redis@7.2.0': dependencies: fastify-plugin: 5.1.0