ratelimit updated

This commit is contained in:
Marco Pedone 2026-05-06 16:18:25 +02:00
parent 723bc9fc31
commit b0f39f5f11
10 changed files with 111 additions and 91 deletions

View file

@ -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 $$;

View file

@ -22,5 +22,6 @@
], ],
"js/ts.tsdk.path": "node_modules\\typescript\\lib", "js/ts.tsdk.path": "node_modules\\typescript\\lib",
"biome.lsp.trace.server": "verbose", "biome.lsp.trace.server": "verbose",
"tinymist.fontPaths": ["${workspaceFolder}/typst/fonts"] "tinymist.fontPaths": ["${workspaceFolder}/typst/fonts"],
"js/ts.preferences.importModuleSpecifier": "non-relative"
} }

View file

@ -1,13 +1,16 @@
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
import { z } from 'zod'; import { z } from 'zod';
/** Identifier type for public.ratelimiter */
export type RatelimiterKey = string & { __brand: 'public.ratelimiter' };
/** Represents the table public.ratelimiter */ /** Represents the table public.ratelimiter */
export default interface RatelimiterTable { export default interface RatelimiterTable {
key: ColumnType<string, string, string>; key: ColumnType<RatelimiterKey, RatelimiterKey, RatelimiterKey>;
request_timestamp: ColumnType<Date, Date | string, Date | string>; expires_at: ColumnType<Date, Date | string, Date | string>;
request_count: ColumnType<number, number, number>; count: ColumnType<number, number, number>;
} }
export type Ratelimiter = Selectable<RatelimiterTable>; export type Ratelimiter = Selectable<RatelimiterTable>;
@ -16,20 +19,22 @@ export type NewRatelimiter = Insertable<RatelimiterTable>;
export type RatelimiterUpdate = Updateable<RatelimiterTable>; export type RatelimiterUpdate = Updateable<RatelimiterTable>;
export const ratelimiterKey = z.string().transform(value => value as RatelimiterKey);
export const RatelimiterSchema = z.object({ export const RatelimiterSchema = z.object({
key: z.string(), key: ratelimiterKey,
request_timestamp: z.date(), expires_at: z.date(),
request_count: z.number(), count: z.number(),
}); });
export const NewRatelimiterSchema = z.object({ export const NewRatelimiterSchema = z.object({
key: z.string(), key: ratelimiterKey,
request_timestamp: z.union([z.date(), z.string()]).pipe(z.coerce.date()), expires_at: z.union([z.date(), z.string()]).pipe(z.coerce.date()),
request_count: z.number(), count: z.number(),
}); });
export const RatelimiterUpdateSchema = z.object({ export const RatelimiterUpdateSchema = z.object({
key: z.string().optional(), key: ratelimiterKey.optional(),
request_timestamp: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(), expires_at: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(),
request_count: z.number().optional(), count: z.number().optional(),
}); });

View file

@ -22,15 +22,6 @@ import {
resetPasswordFromTokenHandler, resetPasswordFromTokenHandler,
} from "~/server/services/auth.service"; } 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({ export const authRouter = createTRPCRouter({
addUserAdmin: adminProcedure addUserAdmin: adminProcedure
.input( .input(

View file

@ -17,14 +17,6 @@ import {
import { db } from "~/server/db"; import { db } from "~/server/db";
import { TypstGenerate } from "~/server/services/typst.service"; import { TypstGenerate } from "~/server/services/typst.service";
import { zResult } from "~/utils/result"; 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({ export const pagamentiRouter = createTRPCRouter({
getPaymentFromIntent: protectedProcedure getPaymentFromIntent: protectedProcedure

View file

@ -19,14 +19,7 @@ import {
getPrezziarioConsulenza, getPrezziarioConsulenza,
updatePrezziario, updatePrezziario,
} from "~/server/services/prezziario.service"; } 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({ export const prezziarioRouter = createTRPCRouter({
addPrezziario: adminProcedure addPrezziario: adminProcedure
.input( .input(

View file

@ -3,6 +3,7 @@ import {
protectedApiProcedure, protectedApiProcedure,
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { pgRateLimit } from "~/server/services/ratelimiter";
export const testRouter = createTRPCRouter({ export const testRouter = createTRPCRouter({
testApi: protectedApiProcedure.query(async () => { testApi: protectedApiProcedure.query(async () => {
@ -17,6 +18,10 @@ export const testRouter = createTRPCRouter({
// testTypst: publicProcedure.query(async () => { // testTypst: publicProcedure.query(async () => {
// return await genTesti(); // return await genTesti();
// }), // }),
testRate: publicProcedure.query(async () => {
return await pgRateLimit("test", 5, 5);
}),
}); });
/* /*
PROTECTED API CALL EXAMPLE PROTECTED API CALL EXAMPLE

View file

@ -16,11 +16,6 @@ import { withImages, withVideos } from "~/utils/kysely-helper";
import type { Result } from "~/utils/result"; import type { Result } from "~/utils/result";
import { revalidate } from "../utils/revalidationHelper"; import { revalidate } from "../utils/revalidationHelper";
// const ratelimit = new RateLimiterHandler({
// windowSize: 10,
// maxRequests: 10,
// analytics: true,
// });
export type AnnunciWithMedia = Annunci & { export type AnnunciWithMedia = Annunci & {
tipo: string; tipo: string;
images: Pick<ImagesRefs, "img" | "thumb">[]; images: Pick<ImagesRefs, "img" | "thumb">[];

View file

@ -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 * pgRateLimit
* @param windowSize The window size in seconds * @param key Identifier of the rate limiter
* @param maxRequests The maximum number of requests * @param windowSizeSec Window size in seconds
* @param analytics Whether to log analytics * @param maxRequests Max number of requests allowed within the window size
* @returns `{success: boolean}`
*/ */
export class RateLimiterHandler { export async function pgRateLimit(
private config: RateLimiterConfig; key: string,
windowSizeSec: number,
constructor(config: RateLimiterConfig) { maxRequests: number,
this.config = config; ): Promise<{ success: boolean }> {
} const now = new Date();
/** const expiry = add(now, { seconds: windowSizeSec });
* @description Limit the number of requests for a given key try {
* @param key The key to limit, "event_identifier" is recommended const result = await db
*/ .insertInto("ratelimiter")
async limit(key: string): Promise<{ success: boolean }> { .values({
if (!keydb) { key: key as RatelimiterKey,
console.warn("KeyDB client is not initialized. Skipping rate limiting."); expires_at: expiry,
return { success: true }; count: 1,
} })
const redisKey = `ratelimiter:${key}`; .onConflict((oc) =>
const { windowSize, maxRequests, analytics } = this.config; oc.column("key").doUpdateSet((eb) => ({
// Increment the count atomically // If current time > expires_at, reset count to 1, otherwise increment
const count = await keydb.incr(redisKey); count: eb
.case()
if (count === 1) { .when("ratelimiter.expires_at", "<", now)
// First request, set expiry .then(1)
await keydb.expire(redisKey, windowSize); .else(eb("ratelimiter.count", "+", 1))
if (analytics) { .end(),
console.log(`New Ratelimiter key: ${key} created.`); // If window expired, set new expiry. Otherwise, keep existing one.
} expires_at: eb
} .case()
if (count > maxRequests) { .when("ratelimiter.expires_at", "<", now)
if (analytics) { .then(expiry)
console.error( .else(eb.ref("ratelimiter.expires_at"))
`Rate limit exceeded for key: ${key}. Current count: ${count}, Max allowed: ${maxRequests}`, .end(),
); })),
} )
return { success: false }; .returning("count")
} .executeTakeFirst();
return { success: true }; 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}`,
});
} }
} }

View file

@ -16,9 +16,10 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"noEmit": true, "noEmit": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"rootDir": ".",
"paths": { "paths": {
"@/*": ["./*"],
"~/*": ["./src/*"], "~/*": ["./src/*"],
"@/*": ["./*"],
"emails/*": ["./emails/*"] "emails/*": ["./emails/*"]
}, },
"plugins": [ "plugins": [