ratelimit updated
This commit is contained in:
parent
723bc9fc31
commit
b0f39f5f11
10 changed files with 111 additions and 91 deletions
33
apps/db/migrations/53_ratelimit_fix.up.sql
Normal file
33
apps/db/migrations/53_ratelimit_fix.up.sql
Normal 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 $$;
|
||||||
|
|
||||||
3
apps/infoalloggi/.vscode/settings.json
vendored
3
apps/infoalloggi/.vscode/settings.json
vendored
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">[];
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue