From 2138c8b410b1688c6e8fc71d0e6a98adad0cad5f Mon Sep 17 00:00:00 2001 From: pheralb Date: Tue, 20 Aug 2024 19:16:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20ratelimit=20with=20@upstash/r?= =?UTF-8?q?atelimit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-routes/src/index.ts | 111 ++++++++++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 17 deletions(-) diff --git a/api-routes/src/index.ts b/api-routes/src/index.ts index 162353f..f4ceccc 100644 --- a/api-routes/src/index.ts +++ b/api-routes/src/index.ts @@ -1,5 +1,11 @@ -import { Hono } from 'hono'; +import { Context, Hono } from 'hono'; +import { env } from 'hono/adapter'; import { cors } from 'hono/cors'; +import { BlankInput, Env } from 'hono/types'; +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis/cloudflare'; + +// 🌿 Import utils: import { addFullUrl } from './utils'; // 📦 Import data from main app: @@ -7,6 +13,13 @@ import { svgsData } from '../../src/data'; import { iSVG } from '../../src/types/svg'; import { tCategory } from '../../src/types/categories'; +declare module 'hono' { + interface ContextVariableMap { + ratelimit: Ratelimit; + } +} + +// ✨ Return the full route for each SVG: const fullRouteSvgsData = svgsData.map((svg) => { return { ...svg, @@ -15,29 +28,85 @@ const fullRouteSvgsData = svgsData.map((svg) => { }; }) as iSVG[]; -// ⚙️ Create a new Hono instance: +// ⚙️ Create a new Hono & Cache instance: const app = new Hono(); +const cache = new Map(); + +class RedisRateLimiter { + static instance: Ratelimit; + static getInstance(c: Context) { + if (!this.instance) { + const { UPSTASH_REDIS_URL, UPSTASH_REDIS_TOKEN } = env<{ + UPSTASH_REDIS_URL: string; + UPSTASH_REDIS_TOKEN: string; + }>(c); + const redisClient = new Redis({ + token: UPSTASH_REDIS_TOKEN, + url: UPSTASH_REDIS_URL + }); + const ratelimit = new Ratelimit({ + redis: redisClient, + limiter: Ratelimit.slidingWindow(5, '5 s'), + ephemeralCache: cache + }); + this.instance = ratelimit; + return this.instance; + } else { + return this.instance; + } + } +} + +app.use(async (c, next) => { + const ratelimit = RedisRateLimiter.getInstance(c); + c.set('ratelimit', ratelimit); + await next(); +}); + app.use('/api/*', cors()); // 🌱 GET: "/" - Returns all the SVGs data: -app.get('/', (c) => { +app.get('/', async (c) => { + const limit = c.req.query('limit'); + const search = c.req.query('search'); + const ratelimit = c.get('ratelimit'); + const ip = c.req.raw.headers.get('CF-Connecting-IP'); + const { success } = await ratelimit.limit(ip ?? 'anonymous'); + + if (!success) { + return c.json({ error: 'Too many request' }, 429); + } + + if (limit) { + const limitNumber = parseInt(limit); + if (limitNumber) { + return c.json(fullRouteSvgsData.slice(0, limitNumber)); + } + } + + if (search) { + const searchResults = fullRouteSvgsData.filter((svg) => + svg.title.toLowerCase().includes(search.toLowerCase()) + ); + if (searchResults.length === 0) { + return c.json({ error: 'not found' }, 404); + } + return c.json(searchResults); + } + return c.json(fullRouteSvgsData); }); -// 🌱 GET: "/:search" - Returns a single SVG data: -app.get('/search/:search', (c) => { - const title = c.req.param('search') as string; - const svg = fullRouteSvgsData.find((svg) => - svg.title.toLowerCase().includes(title.toLowerCase()) - ); - if (!svg) { - return c.json({ error: 'not found' }, 404); - } - return c.json(svg); -}); - // 🌱 GET: "/categories" - Return an array with categories: -app.get('/categories', (c) => { +app.get('/categories', async (c) => { + const ratelimit = c.get('ratelimit'); + const ip = c.req.raw.headers.get('CF-Connecting-IP'); + const { success } = await ratelimit.limit(ip ?? 'anonymous'); + + if (!success) { + return c.json({ error: 'Too many request' }, 429); + } + const categories = fullRouteSvgsData.reduce((acc, svg) => { if (typeof svg.category === 'string') { if (!acc.includes(svg.category)) { @@ -57,9 +126,17 @@ app.get('/categories', (c) => { }); // 🌱 GET: "/category/:category - Return an list of svgs by specific category: -app.get('/category/:category', (c) => { +app.get('/category/:category', async (c) => { const category = c.req.param('category') as string; const targetCategory = category.charAt(0).toUpperCase() + category.slice(1); + const ratelimit = c.get('ratelimit'); + const ip = c.req.raw.headers.get('CF-Connecting-IP'); + const { success } = await ratelimit.limit(ip ?? 'anonymous'); + + if (!success) { + return c.json({ error: 'Too many request' }, 429); + } + const categorySvgs = fullRouteSvgsData.filter((svg) => { if (typeof svg.category === 'string') { return svg.category === targetCategory;