From b0f39f5f117e579090daea9f9bfec7e32450831d Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Wed, 6 May 2026 16:18:25 +0200 Subject: [PATCH] ratelimit updated --- apps/db/migrations/53_ratelimit_fix.up.sql | 33 +++++++ apps/infoalloggi/.vscode/settings.json | 3 +- .../src/schemas/public/Ratelimiter.ts | 29 +++--- .../src/server/api/routers/auth.ts | 9 -- .../src/server/api/routers/pagamenti.ts | 8 -- .../src/server/api/routers/prezziario.ts | 9 +- .../src/server/api/routers/test.ts | 5 + .../server/controllers/annunci.controller.ts | 5 - .../src/server/services/ratelimiter.ts | 98 ++++++++++--------- apps/infoalloggi/tsconfig.json | 3 +- 10 files changed, 111 insertions(+), 91 deletions(-) create mode 100644 apps/db/migrations/53_ratelimit_fix.up.sql diff --git a/apps/db/migrations/53_ratelimit_fix.up.sql b/apps/db/migrations/53_ratelimit_fix.up.sql new file mode 100644 index 0000000..6ea23ee --- /dev/null +++ b/apps/db/migrations/53_ratelimit_fix.up.sql @@ -0,0 +1,33 @@ +DO $$ BEGIN IF EXISTS ( + SELECT + FROM information_schema.columns + WHERE table_name = 'ratelimiter' + AND column_name = 'request_timestamp' +) THEN +ALTER TABLE public.ratelimiter + RENAME COLUMN request_timestamp TO expires_at; +END IF; +IF EXISTS ( + SELECT + FROM information_schema.columns + WHERE table_name = 'ratelimiter' + AND column_name = 'request_count' +) THEN +ALTER TABLE public.ratelimiter + RENAME COLUMN request_count TO count; +END IF; +END $$; + + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ratelimiter_pkey' + AND conrelid = 'public.ratelimiter'::regclass + ) THEN + ALTER TABLE public.ratelimiter + ADD CONSTRAINT ratelimiter_pkey PRIMARY KEY (key); + END IF; +END $$; + diff --git a/apps/infoalloggi/.vscode/settings.json b/apps/infoalloggi/.vscode/settings.json index 5324afb..8edb065 100644 --- a/apps/infoalloggi/.vscode/settings.json +++ b/apps/infoalloggi/.vscode/settings.json @@ -22,5 +22,6 @@ ], "js/ts.tsdk.path": "node_modules\\typescript\\lib", "biome.lsp.trace.server": "verbose", - "tinymist.fontPaths": ["${workspaceFolder}/typst/fonts"] + "tinymist.fontPaths": ["${workspaceFolder}/typst/fonts"], + "js/ts.preferences.importModuleSpecifier": "non-relative" } \ No newline at end of file diff --git a/apps/infoalloggi/src/schemas/public/Ratelimiter.ts b/apps/infoalloggi/src/schemas/public/Ratelimiter.ts index 70b1e7b..561ebff 100644 --- a/apps/infoalloggi/src/schemas/public/Ratelimiter.ts +++ b/apps/infoalloggi/src/schemas/public/Ratelimiter.ts @@ -1,13 +1,16 @@ import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import { z } from 'zod'; +/** Identifier type for public.ratelimiter */ +export type RatelimiterKey = string & { __brand: 'public.ratelimiter' }; + /** Represents the table public.ratelimiter */ export default interface RatelimiterTable { - key: ColumnType; + key: ColumnType; - request_timestamp: ColumnType; + expires_at: ColumnType; - request_count: ColumnType; + count: ColumnType; } export type Ratelimiter = Selectable; @@ -16,20 +19,22 @@ export type NewRatelimiter = Insertable; export type RatelimiterUpdate = Updateable; +export const ratelimiterKey = z.string().transform(value => value as RatelimiterKey); + export const RatelimiterSchema = z.object({ - key: z.string(), - request_timestamp: z.date(), - request_count: z.number(), + key: ratelimiterKey, + expires_at: z.date(), + count: z.number(), }); export const NewRatelimiterSchema = z.object({ - key: z.string(), - request_timestamp: z.union([z.date(), z.string()]).pipe(z.coerce.date()), - request_count: z.number(), + key: ratelimiterKey, + expires_at: z.union([z.date(), z.string()]).pipe(z.coerce.date()), + count: z.number(), }); export const RatelimiterUpdateSchema = z.object({ - key: z.string().optional(), - request_timestamp: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(), - request_count: z.number().optional(), + key: ratelimiterKey.optional(), + expires_at: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(), + count: z.number().optional(), }); \ No newline at end of file diff --git a/apps/infoalloggi/src/server/api/routers/auth.ts b/apps/infoalloggi/src/server/api/routers/auth.ts index c6158b1..29ca339 100644 --- a/apps/infoalloggi/src/server/api/routers/auth.ts +++ b/apps/infoalloggi/src/server/api/routers/auth.ts @@ -22,15 +22,6 @@ import { resetPasswordFromTokenHandler, } from "~/server/services/auth.service"; -// Create a new ratelimiter, that allows 10 requests per 10 seconds -/* -const ratelimit = new RateLimiterHandler({ - windowSize: 10, - maxRequests: 10, - analytics: true, -}); -*/ - export const authRouter = createTRPCRouter({ addUserAdmin: adminProcedure .input( diff --git a/apps/infoalloggi/src/server/api/routers/pagamenti.ts b/apps/infoalloggi/src/server/api/routers/pagamenti.ts index 5888419..99ed817 100644 --- a/apps/infoalloggi/src/server/api/routers/pagamenti.ts +++ b/apps/infoalloggi/src/server/api/routers/pagamenti.ts @@ -17,14 +17,6 @@ import { import { db } from "~/server/db"; import { TypstGenerate } from "~/server/services/typst.service"; import { zResult } from "~/utils/result"; -// Create a new ratelimiter, that allows 10 requests per 10 seconds -/* -const ratelimit = new RateLimiterHandler({ - maxRequests: 10, - windowSize: 60, - analytics: false, -}); -*/ export const pagamentiRouter = createTRPCRouter({ getPaymentFromIntent: protectedProcedure diff --git a/apps/infoalloggi/src/server/api/routers/prezziario.ts b/apps/infoalloggi/src/server/api/routers/prezziario.ts index dbeb3e0..042e81f 100644 --- a/apps/infoalloggi/src/server/api/routers/prezziario.ts +++ b/apps/infoalloggi/src/server/api/routers/prezziario.ts @@ -19,14 +19,7 @@ import { getPrezziarioConsulenza, updatePrezziario, } from "~/server/services/prezziario.service"; -// Create a new ratelimiter, that allows 10 requests per 10 seconds -/* -const ratelimit = new RateLimiterHandler({ - maxRequests: 10, - windowSize: 60, - analytics: false, -}); -*/ + export const prezziarioRouter = createTRPCRouter({ addPrezziario: adminProcedure .input( diff --git a/apps/infoalloggi/src/server/api/routers/test.ts b/apps/infoalloggi/src/server/api/routers/test.ts index c6f17fd..e7dde77 100644 --- a/apps/infoalloggi/src/server/api/routers/test.ts +++ b/apps/infoalloggi/src/server/api/routers/test.ts @@ -3,6 +3,7 @@ import { protectedApiProcedure, publicProcedure, } from "~/server/api/trpc"; +import { pgRateLimit } from "~/server/services/ratelimiter"; export const testRouter = createTRPCRouter({ testApi: protectedApiProcedure.query(async () => { @@ -17,6 +18,10 @@ export const testRouter = createTRPCRouter({ // testTypst: publicProcedure.query(async () => { // return await genTesti(); // }), + + testRate: publicProcedure.query(async () => { + return await pgRateLimit("test", 5, 5); + }), }); /* PROTECTED API CALL EXAMPLE diff --git a/apps/infoalloggi/src/server/controllers/annunci.controller.ts b/apps/infoalloggi/src/server/controllers/annunci.controller.ts index 03adfe0..b10084e 100644 --- a/apps/infoalloggi/src/server/controllers/annunci.controller.ts +++ b/apps/infoalloggi/src/server/controllers/annunci.controller.ts @@ -16,11 +16,6 @@ import { withImages, withVideos } from "~/utils/kysely-helper"; import type { Result } from "~/utils/result"; import { revalidate } from "../utils/revalidationHelper"; -// const ratelimit = new RateLimiterHandler({ -// windowSize: 10, -// maxRequests: 10, -// analytics: true, -// }); export type AnnunciWithMedia = Annunci & { tipo: string; images: Pick[]; diff --git a/apps/infoalloggi/src/server/services/ratelimiter.ts b/apps/infoalloggi/src/server/services/ratelimiter.ts index 1684b9a..55c4b4f 100644 --- a/apps/infoalloggi/src/server/services/ratelimiter.ts +++ b/apps/infoalloggi/src/server/services/ratelimiter.ts @@ -1,52 +1,56 @@ -import { getKeydbClient } from "~/utils/keydb"; +import { TRPCError } from "@trpc/server"; +import { add } from "date-fns"; +import type { RatelimiterKey } from "~/schemas/public/Ratelimiter"; +import { db } from "~/server/db"; -const keydb = getKeydbClient(); -interface RateLimiterConfig { - windowSize: number; - maxRequests: number; - analytics: boolean; -} /** - * @description The rate limiter class - * @param windowSize The window size in seconds - * @param maxRequests The maximum number of requests - * @param analytics Whether to log analytics + * pgRateLimit + * @param key Identifier of the rate limiter + * @param windowSizeSec Window size in seconds + * @param maxRequests Max number of requests allowed within the window size + * @returns `{success: boolean}` */ -export class RateLimiterHandler { - private config: RateLimiterConfig; - - constructor(config: RateLimiterConfig) { - this.config = config; - } - /** - * @description Limit the number of requests for a given key - * @param key The key to limit, "event_identifier" is recommended - */ - async limit(key: string): Promise<{ success: boolean }> { - if (!keydb) { - console.warn("KeyDB client is not initialized. Skipping rate limiting."); - return { success: true }; - } - const redisKey = `ratelimiter:${key}`; - const { windowSize, maxRequests, analytics } = this.config; - // Increment the count atomically - const count = await keydb.incr(redisKey); - - if (count === 1) { - // First request, set expiry - await keydb.expire(redisKey, windowSize); - if (analytics) { - console.log(`New Ratelimiter key: ${key} created.`); - } - } - if (count > maxRequests) { - if (analytics) { - console.error( - `Rate limit exceeded for key: ${key}. Current count: ${count}, Max allowed: ${maxRequests}`, - ); - } - return { success: false }; - } - return { success: true }; +export async function pgRateLimit( + key: string, + windowSizeSec: number, + maxRequests: number, +): Promise<{ success: boolean }> { + const now = new Date(); + const expiry = add(now, { seconds: windowSizeSec }); + try { + const result = await db + .insertInto("ratelimiter") + .values({ + key: key as RatelimiterKey, + expires_at: expiry, + count: 1, + }) + .onConflict((oc) => + oc.column("key").doUpdateSet((eb) => ({ + // If current time > expires_at, reset count to 1, otherwise increment + count: eb + .case() + .when("ratelimiter.expires_at", "<", now) + .then(1) + .else(eb("ratelimiter.count", "+", 1)) + .end(), + // If window expired, set new expiry. Otherwise, keep existing one. + expires_at: eb + .case() + .when("ratelimiter.expires_at", "<", now) + .then(expiry) + .else(eb.ref("ratelimiter.expires_at")) + .end(), + })), + ) + .returning("count") + .executeTakeFirst(); + const currentCount = Number(result?.count ?? 0); + return { success: currentCount <= maxRequests }; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to rate limit request. ${(e as Error).message}`, + }); } } diff --git a/apps/infoalloggi/tsconfig.json b/apps/infoalloggi/tsconfig.json index 4cc91ae..54cef03 100644 --- a/apps/infoalloggi/tsconfig.json +++ b/apps/infoalloggi/tsconfig.json @@ -16,9 +16,10 @@ "moduleResolution": "Bundler", "noEmit": true, "noUncheckedIndexedAccess": true, + "rootDir": ".", "paths": { - "@/*": ["./*"], "~/*": ["./src/*"], + "@/*": ["./*"], "emails/*": ["./emails/*"] }, "plugins": [