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",
"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 { 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<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>;
@ -16,20 +19,22 @@ export type NewRatelimiter = Insertable<RatelimiterTable>;
export type RatelimiterUpdate = Updateable<RatelimiterTable>;
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(),
});

View file

@ -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(

View file

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

View file

@ -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(

View file

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

View file

@ -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<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
* @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}`,
});
}
}

View file

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