diff --git a/TODO.md b/TODO.md index 5f95fb6..a85d531 100644 --- a/TODO.md +++ b/TODO.md @@ -36,7 +36,7 @@ - [x] Setup tests with database + add coverage - [ ] Setup HTTP Requests logging in development (not needed in `test` mode) - [ ] Setup Health checks - - [ ] Setup Rate limiting + - [x] Setup Rate limiting - [ ] Share VineJS validators between `website` and `api` - [ ] Implement Wikipedia Game Solver (`website`) - [x] Init Next.js project diff --git a/apps/api/.env.example b/apps/api/.env.example index c5fd9cf..6759377 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -10,3 +10,5 @@ DATABASE_PASSWORD=password DATABASE_NAME=wikipedia DATABASE_HOST=127.0.0.1 DATABASE_PORT=5432 + +LIMITER_STORE=database diff --git a/apps/api/package.json b/apps/api/package.json index 77e0d26..29d4bde 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@adonisjs/core": "catalog:", "@adonisjs/cors": "catalog:", "@adonisjs/lucid": "catalog:", + "@adonisjs/limiter": "catalog:", "@repo/utils": "workspace:*", "@repo/wikipedia-game-solver": "workspace:*", "@vinejs/vine": "catalog:", diff --git a/apps/api/src/adonisrc.ts b/apps/api/src/adonisrc.ts index 7405b10..ac56974 100644 --- a/apps/api/src/adonisrc.ts +++ b/apps/api/src/adonisrc.ts @@ -34,6 +34,9 @@ export default defineConfig({ async () => { return await import("@adonisjs/auth/auth_provider") }, + async () => { + return await import("@adonisjs/limiter/limiter_provider") + }, ], preloads: [ async () => { diff --git a/apps/api/src/app/routes/wikipedia/pages/get.ts b/apps/api/src/app/routes/wikipedia/pages/get.ts index e5130ab..53f83c3 100644 --- a/apps/api/src/app/routes/wikipedia/pages/get.ts +++ b/apps/api/src/app/routes/wikipedia/pages/get.ts @@ -1,4 +1,5 @@ import Page from "#app/models/page.js" +import { throttle } from "#start/limiter.js" import type { HttpContext } from "@adonisjs/core/http" import router from "@adonisjs/core/services/router" import { sanitizePageTitle } from "@repo/wikipedia-game-solver/wikipedia-utils" @@ -33,4 +34,4 @@ class Controller { } } -router.get("/wikipedia/pages", [Controller]) +router.get("/wikipedia/pages", [Controller]).use(throttle) diff --git a/apps/api/src/bin/test.ts b/apps/api/src/bin/test.ts index fbfc361..a8166d4 100755 --- a/apps/api/src/bin/test.ts +++ b/apps/api/src/bin/test.ts @@ -8,6 +8,7 @@ process.env["NODE_ENV"] = "test" process.env["PORT"] = "3333" +process.env["LIMITER_STORE"] = "memory" import { Ignitor, prettyPrintError } from "@adonisjs/core" import { configure, processCLIArgs, run } from "@japa/runner" diff --git a/apps/api/src/config/limiter.ts b/apps/api/src/config/limiter.ts new file mode 100644 index 0000000..529d5fb --- /dev/null +++ b/apps/api/src/config/limiter.ts @@ -0,0 +1,26 @@ +import env from "#start/env.js" +import { defineConfig, stores } from "@adonisjs/limiter" + +const limiterConfig = defineConfig({ + default: env.get("LIMITER_STORE"), + stores: { + /** + * Database store to save rate limiting data inside a database. + */ + database: stores.database({ + tableName: "rate_limits", + clearExpiredByTimeout: true, + }), + + /** + * Memory store could be used during testing. + */ + memory: stores.memory({}), + }, +}) + +export default limiterConfig + +declare module "@adonisjs/limiter/types" { + export interface LimitersList extends InferLimiters {} +} diff --git a/apps/api/src/database/migrations/1723465492674_create_rate_limits_table.ts b/apps/api/src/database/migrations/1723465492674_create_rate_limits_table.ts new file mode 100644 index 0000000..98d327a --- /dev/null +++ b/apps/api/src/database/migrations/1723465492674_create_rate_limits_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from "@adonisjs/lucid/schema" + +export default class CreateRateLimitsTable extends BaseSchema { + protected tableName = "rate_limits" + + public override async up(): Promise { + void this.schema.createTable(this.tableName, (table) => { + table.string("key", 255).notNullable().primary() + table.integer("points", 9).notNullable().defaultTo(0) + table.bigint("expire").unsigned() + }) + } + + public override async down(): Promise { + void this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/src/start/env.ts b/apps/api/src/start/env.ts index 8534ab4..e0b70a6 100644 --- a/apps/api/src/start/env.ts +++ b/apps/api/src/start/env.ts @@ -19,11 +19,16 @@ export default await Env.create(new URL("../..", import.meta.url), { ] as const), /** - * Variables for configuring database connection + * Variables for configuring database connection. */ DATABASE_HOST: Env.schema.string({ format: "host" }), DATABASE_PORT: Env.schema.number(), DATABASE_USER: Env.schema.string(), DATABASE_PASSWORD: Env.schema.string(), DATABASE_NAME: Env.schema.string(), + + /** + * Variables for configuring the limiter package. + */ + LIMITER_STORE: Env.schema.enum(["database", "memory"] as const), }) diff --git a/apps/api/src/start/limiter.ts b/apps/api/src/start/limiter.ts new file mode 100644 index 0000000..7e84be9 --- /dev/null +++ b/apps/api/src/start/limiter.ts @@ -0,0 +1,15 @@ +/** + * Define HTTP limiters + * + * The "limiter.define" method creates an HTTP middleware to apply rate limits on a route or a group of routes. Feel free to define as many throttle middleware as needed. + */ + +import app from "@adonisjs/core/services/app" +import limiter from "@adonisjs/limiter/services/main" + +export const throttle = limiter.define("global", () => { + if (app.inTest) { + return limiter.noLimit() + } + return limiter.allowRequests(120).every("1 minute") +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97c015d..da44e27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@adonisjs/cors': specifier: 2.2.1 version: 2.2.1 + '@adonisjs/limiter': + specifier: 2.3.2 + version: 2.3.2 '@adonisjs/lucid': specifier: 21.2.0 version: 21.2.0 @@ -275,6 +278,9 @@ importers: '@adonisjs/cors': specifier: 'catalog:' version: 2.2.1(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0)) + '@adonisjs/limiter': + specifier: 'catalog:' + version: 2.3.2(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0))(@adonisjs/lucid@21.2.0(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0))(better-sqlite3@11.1.2)(luxon@3.5.0)(pg@8.12.0)) '@adonisjs/lucid': specifier: 'catalog:' version: 21.2.0(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0))(better-sqlite3@11.1.2)(luxon@3.5.0)(pg@8.12.0) @@ -399,7 +405,7 @@ importers: version: 14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: 'catalog:' - version: 3.17.2(next@14.2.5(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 3.17.2(next@14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) next-themes: specifier: 'catalog:' version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -448,7 +454,7 @@ importers: version: 8.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(typescript@5.5.4) '@storybook/test': specifier: 'catalog:' - version: 8.2.8(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.2.0)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(terser@5.31.5)) + version: 8.2.8(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.2.0)) '@storybook/test-runner': specifier: 'catalog:' version: 0.19.1(@swc/helpers@0.5.5)(@types/node@22.2.0)(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2))) @@ -514,7 +520,7 @@ importers: version: 14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: 'catalog:' - version: 3.17.2(next@14.2.5(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 3.17.2(next@14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: 'catalog:' version: 18.3.1 @@ -629,7 +635,7 @@ importers: version: 14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: 'catalog:' - version: 3.17.2(next@14.2.5(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 3.17.2(next@14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: 'catalog:' version: 18.3.1 @@ -730,7 +736,7 @@ importers: version: 14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: 'catalog:' - version: 3.17.2(next@14.2.5(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 3.17.2(next@14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) next-themes: specifier: 'catalog:' version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -833,7 +839,7 @@ importers: version: 14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: 'catalog:' - version: 3.17.2(next@14.2.5(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 3.17.2(next@14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: 'catalog:' version: 18.3.1 @@ -858,7 +864,7 @@ importers: version: 8.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(typescript@5.5.4) '@storybook/test': specifier: 'catalog:' - version: 8.2.8(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.2.0)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(terser@5.31.5)) + version: 8.2.8(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.2.0)) '@total-typescript/ts-reset': specifier: 'catalog:' version: 0.5.1 @@ -1017,6 +1023,18 @@ packages: '@adonisjs/fold': ^10.0.1 '@adonisjs/logger': ^6.0.1 + '@adonisjs/limiter@2.3.2': + resolution: {integrity: sha512-6DAzJl9c7XGPEakge35Dr7+yqqrpWkifQDKm3cwl/+WaxBPvxWbJBTjzu+P9NRCiIofqfH89QFeXWpJN1+dl0w==} + peerDependencies: + '@adonisjs/core': ^6.12.1 + '@adonisjs/lucid': ^20.1.0 || ^21.0.0 + '@adonisjs/redis': ^8.0.1 || ^9.0.0 + peerDependenciesMeta: + '@adonisjs/lucid': + optional: true + '@adonisjs/redis': + optional: true + '@adonisjs/logger@6.0.3': resolution: {integrity: sha512-CKxIpWBEX/e6duRE6qq8GJ90NQC8q26Q0aSuj+bUO6X4mgcgawxhciJTfpxmJNj9KEUmNAeHOn0hSpTITdk8Lg==} engines: {node: '>=18.16.0'} @@ -7887,6 +7905,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@5.0.3: + resolution: {integrity: sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==} + raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} @@ -9631,6 +9652,13 @@ snapshots: vary: 1.1.2 youch: 3.3.3 + '@adonisjs/limiter@2.3.2(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0))(@adonisjs/lucid@21.2.0(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0))(better-sqlite3@11.1.2)(luxon@3.5.0)(pg@8.12.0))': + dependencies: + '@adonisjs/core': 6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0) + rate-limiter-flexible: 5.0.3 + optionalDependencies: + '@adonisjs/lucid': 21.2.0(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(typescript@5.5.4))(@vinejs/vine@2.1.0))(better-sqlite3@11.1.2)(luxon@3.5.0)(pg@8.12.0) + '@adonisjs/logger@6.0.3': dependencies: '@poppinss/utils': 6.7.3 @@ -12260,30 +12288,12 @@ snapshots: - supports-color - ts-node - '@storybook/test@8.2.8(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.2.0)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(terser@5.31.5))': - dependencies: - '@storybook/csf': 0.1.11 - '@storybook/instrumenter': 8.2.8(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2))) - '@testing-library/dom': 10.1.0 - '@testing-library/jest-dom': 6.4.5(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(vitest@2.0.5(@types/node@22.2.0)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(terser@5.31.5)) - '@testing-library/user-event': 14.5.2(@testing-library/dom@10.1.0) - '@vitest/expect': 1.6.0 - '@vitest/spy': 1.6.0 - storybook: 8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)) - util: 0.12.5 - transitivePeerDependencies: - - '@jest/globals' - - '@types/bun' - - '@types/jest' - - jest - - vitest - '@storybook/test@8.2.8(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.2.0))': dependencies: '@storybook/csf': 0.1.11 '@storybook/instrumenter': 8.2.8(storybook@8.2.8(@babel/preset-env@7.25.3(@babel/core@7.25.2))) '@testing-library/dom': 10.1.0 - '@testing-library/jest-dom': 6.4.5(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(vitest@2.0.5(@types/node@22.2.0)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(terser@5.31.5)) + '@testing-library/jest-dom': 6.4.5(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(vitest@2.0.5(@types/node@22.2.0)) '@testing-library/user-event': 14.5.2(@testing-library/dom@10.1.0) '@vitest/expect': 1.6.0 '@vitest/spy': 1.6.0 @@ -12405,7 +12415,7 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.5(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(vitest@2.0.5(@types/node@22.2.0)(@vitest/browser@2.0.5)(@vitest/ui@2.0.5)(terser@5.31.5))': + '@testing-library/jest-dom@6.4.5(@jest/globals@29.7.0)(jest@29.7.0(@types/node@22.2.0))(vitest@2.0.5(@types/node@22.2.0))': dependencies: '@adobe/css-tools': 4.4.0 '@babel/runtime': 7.25.0 @@ -16896,7 +16906,7 @@ snapshots: nerf-dart@1.0.0: {} - next-intl@3.17.2(next@14.2.5(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + next-intl@3.17.2(next@14.2.5(@babel/core@7.25.2)(@playwright/test@1.46.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@formatjs/intl-localematcher': 0.2.32 negotiator: 0.6.3 @@ -17734,6 +17744,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@5.0.3: {} + raw-body@2.5.2: dependencies: bytes: 3.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 054b8f5..e6a0efe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,7 @@ catalog: "@adonisjs/core": "6.12.1" "@adonisjs/cors": "2.2.1" "@adonisjs/lucid": "21.2.0" + "@adonisjs/limiter": "2.3.2" "pg": "8.12.0" "better-sqlite3": "11.1.2" "@adonisjs/assembler": "7.7.0"