add redis rate limiter

This commit is contained in:
miloschwartz 2025-06-19 16:57:54 -04:00
parent 377eb2b851
commit 494b54ac32
No known key found for this signature in database
6 changed files with 56 additions and 22 deletions

13
package-lock.json generated
View file

@ -74,6 +74,7 @@
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "^8.16.0", "pg": "^8.16.0",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"rate-limit-redis": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-easy-sort": "^1.6.0", "react-easy-sort": "^1.6.0",
@ -11506,6 +11507,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/rate-limit-redis": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.1.tgz",
"integrity": "sha512-JsUsVmRVI6G/XrlYtfGV1NMCbGS/CVYayHkxD5Ism5FaL8qpFHCXbFkUeIi5WJ/onJOKWCgtB/xtCLa6qSXb4g==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"peerDependencies": {
"express-rate-limit": ">= 6"
}
},
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "2.5.2", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",

View file

@ -92,6 +92,7 @@
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "^8.16.0", "pg": "^8.16.0",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"rate-limit-redis": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-easy-sort": "^1.6.0", "react-easy-sort": "^1.6.0",
@ -107,9 +108,9 @@
"winston": "3.17.0", "winston": "3.17.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.18.2", "ws": "8.18.2",
"yargs": "18.0.0",
"zod": "3.25.56", "zod": "3.25.56",
"zod-validation-error": "3.4.1", "zod-validation-error": "3.4.1"
"yargs": "18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.44.1", "@dotenvx/dotenvx": "1.44.1",

View file

@ -4,7 +4,7 @@ import config from "@server/lib/config";
class RedisManager { class RedisManager {
private static instance: RedisManager; private static instance: RedisManager;
private client: Redis | null = null; public client: Redis | null = null;
private subscriber: Redis | null = null; private subscriber: Redis | null = null;
private publisher: Redis | null = null; private publisher: Redis | null = null;
private isEnabled: boolean = false; private isEnabled: boolean = false;

View file

@ -3,20 +3,27 @@ import createHttpError from "http-errors";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { RedisStore } from "rate-limit-redis";
import redisManager from "@server/db/redis";
import { Command as RedisCommand } from "ioredis";
export function rateLimitMiddleware({ export function rateLimitMiddleware({
windowMin, windowMin,
max, max,
type, type,
skipCondition, skipCondition
}: { }: {
windowMin: number; windowMin: number;
max: number; max: number;
type: "IP_ONLY" | "IP_AND_PATH"; type: "IP_ONLY" | "IP_AND_PATH";
skipCondition?: (req: Request, res: Response) => boolean; skipCondition?: (req: Request, res: Response) => boolean;
}) { }) {
const enableRedis = config.getRawConfig().flags?.enable_redis;
let opts;
if (type === "IP_AND_PATH") { if (type === "IP_AND_PATH") {
return rateLimit({ opts = {
windowMs: windowMin * 60 * 1000, windowMs: windowMin * 60 * 1000,
max, max,
skip: skipCondition, skip: skipCondition,
@ -26,24 +33,37 @@ export function rateLimitMiddleware({
handler: (req: Request, res: Response, next: NextFunction) => { handler: (req: Request, res: Response, next: NextFunction) => {
const message = `Rate limit exceeded. You can make ${max} requests every ${windowMin} minute(s).`; const message = `Rate limit exceeded. You can make ${max} requests every ${windowMin} minute(s).`;
logger.warn( logger.warn(
`Rate limit exceeded for IP ${req.ip} on path ${req.path}`, `Rate limit exceeded for IP ${req.ip} on path ${req.path}`
); );
return next( return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message), createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
); );
}, }
} as any;
} else {
opts = {
windowMs: windowMin * 60 * 1000,
max,
skip: skipCondition,
handler: (req: Request, res: Response, next: NextFunction) => {
const message = `Rate limit exceeded. You can make ${max} requests every ${windowMin} minute(s).`;
logger.warn(`Rate limit exceeded for IP ${req.ip}`);
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
}
};
}
if (enableRedis) {
const client = redisManager.client!;
opts.store = new RedisStore({
sendCommand: async (command: string, ...args: string[]) =>
(await client.call(command, args)) as any
}); });
} }
return rateLimit({
windowMs: windowMin * 60 * 1000, return rateLimit(opts);
max,
skip: skipCondition,
handler: (req: Request, res: Response, next: NextFunction) => {
const message = `Rate limit exceeded. You can make ${max} requests every ${windowMin} minute(s).`;
logger.warn(`Rate limit exceeded for IP ${req.ip}`);
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
},
});
} }
export default rateLimitMiddleware; export default rateLimitMiddleware;

View file

@ -21,13 +21,13 @@ export const startOfflineChecker = (): void => {
offlineCheckerInterval = setInterval(async () => { offlineCheckerInterval = setInterval(async () => {
try { try {
const twoMinutesAgo = new Date(Date.now() - OFFLINE_THRESHOLD_MS); const twoMinutesAgo = new Date(Date.now() - OFFLINE_THRESHOLD_MS);
// Find clients that haven't pinged in the last 2 minutes and mark them as offline // Find clients that haven't pinged in the last 2 minutes and mark them as offline
await db await db
.update(clients) .update(clients)
.set({ online: false }) .set({ online: false })
.where( .where(
eq(clients.online, true) && eq(clients.online, true) &&
(lt(clients.lastPing, twoMinutesAgo.toISOString()) || isNull(clients.lastPing)) (lt(clients.lastPing, twoMinutesAgo.toISOString()) || isNull(clients.lastPing))
); );
@ -90,4 +90,4 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
broadcast: false, broadcast: false,
excludeSender: false excludeSender: false
}; };
}; };

View file

@ -398,7 +398,7 @@ if (redisManager.isRedisEnabled()) {
}); });
logger.info(`WebSocket handler initialized with Redis support - Node ID: ${NODE_ID}`); logger.info(`WebSocket handler initialized with Redis support - Node ID: ${NODE_ID}`);
} else { } else {
logger.info('WebSocket handler initialized in local mode (Redis disabled)'); logger.debug('WebSocket handler initialized in local mode (Redis disabled)');
} }
// Cleanup function for graceful shutdown // Cleanup function for graceful shutdown