diff --git a/src/server/infrastructure/db/pagination.ts b/src/server/infrastructure/db/pagination.ts new file mode 100644 index 0000000..8817759 --- /dev/null +++ b/src/server/infrastructure/db/pagination.ts @@ -0,0 +1,3 @@ +import { sql } from "drizzle-orm"; + +export const countAll = sql`count(*)::int`; diff --git a/src/server/infrastructure/di/container.ts b/src/server/infrastructure/di/container.ts index 067b9bd..7aed5f5 100644 --- a/src/server/infrastructure/di/container.ts +++ b/src/server/infrastructure/di/container.ts @@ -22,7 +22,7 @@ import { DeleteUserUseCase } from "../../core/use-cases/admin/delete-user"; import { GetAllRolesUseCase } from "../../core/use-cases/admin/get-all-roles"; import { AssignRoleUseCase } from "../../core/use-cases/admin/assign-role"; import { RemoveRoleUseCase } from "../../core/use-cases/admin/remove-role"; -import { InMemoryRateLimiterService } from "../services/in-memory-rate-limiter.service"; +import { RedisRateLimiterService } from "../services/redis-rate-limiter.service"; import { redis } from "../redis"; import { RedisTokenBlacklistService } from "../services/redis-token-blacklist.service"; import { TockTokenService } from "../services/token.service"; @@ -75,7 +75,7 @@ container.register({ removeRoleUseCase: asClass(RemoveRoleUseCase).singleton(), // Services - rateLimiterService: asClass(InMemoryRateLimiterService).singleton(), + rateLimiterService: asClass(RedisRateLimiterService).singleton(), tokenService: asClass(TockTokenService).singleton(), healthCheckService: asClass(DrizzleHealthCheckService).singleton(), }); diff --git a/src/server/infrastructure/persistence/role.pg.repository.ts b/src/server/infrastructure/persistence/role.pg.repository.ts index 23fdf59..f316016 100644 --- a/src/server/infrastructure/persistence/role.pg.repository.ts +++ b/src/server/infrastructure/persistence/role.pg.repository.ts @@ -1,5 +1,6 @@ -import { eq, inArray, sql, and } from "drizzle-orm"; +import { eq, inArray, and } from "drizzle-orm"; import { DB } from "../db"; +import { countAll } from "../db/pagination"; import { withDbError } from "../db/with-db-error"; import { roles, @@ -37,7 +38,7 @@ export class PostgresRoleRepository implements IRoleRepository { const { limit = 20, offset = 0 } = options; const [rows, countResult] = await Promise.all([ this.db.select().from(roles).limit(limit).offset(offset).orderBy(roles.name), - this.db.select({ count: sql`count(*)::int` }).from(roles), + this.db.select({ count: countAll }).from(roles), ]); return { roles: rows.map((r) => new RoleEntity(r.id, r.name, r.description, r.createdAt)), @@ -109,7 +110,7 @@ export class PostgresRoleRepository implements IRoleRepository { async countUsersWithRole(roleId: string): Promise { return withDbError("count users with role", async () => { const result = await this.db - .select({ count: sql`count(*)::int` }) + .select({ count: countAll }) .from(userRoles) .where(eq(userRoles.roleId, roleId)); return result[0]?.count ?? 0; diff --git a/src/server/infrastructure/persistence/user.pg.repository.ts b/src/server/infrastructure/persistence/user.pg.repository.ts index f4e98e7..18e8d0c 100644 --- a/src/server/infrastructure/persistence/user.pg.repository.ts +++ b/src/server/infrastructure/persistence/user.pg.repository.ts @@ -1,5 +1,6 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { DB } from "../db"; +import { countAll } from "../db/pagination"; import { withDbError, isDbError } from "../db/with-db-error"; import { users } from "./schema/user.schema"; import { IUserRepository } from "../../core/repositories/user.repository"; @@ -69,7 +70,7 @@ export class PostgresUserRepository implements IUserRepository { const { limit = 20, offset = 0 } = options; const [rows, countResult] = await Promise.all([ this.db.select().from(users).limit(limit).offset(offset).orderBy(users.createdAt), - this.db.select({ count: sql`count(*)::int` }).from(users), + this.db.select({ count: countAll }).from(users), ]); return { users: rows.map( diff --git a/src/server/infrastructure/services/redis-rate-limiter.service.ts b/src/server/infrastructure/services/redis-rate-limiter.service.ts new file mode 100644 index 0000000..f6fa078 --- /dev/null +++ b/src/server/infrastructure/services/redis-rate-limiter.service.ts @@ -0,0 +1,37 @@ +import { Redis } from "ioredis"; +import { IRateLimiterService } from "../../core/services/rate-limiter.service"; +import { logger } from "../logger"; + +const KEY_PREFIX = "ratelimit:"; + +export class RedisRateLimiterService implements IRateLimiterService { + constructor(private readonly redis: Redis) {} + + async isAllowed( + key: string, + maxAttempts: number, + windowMs: number, + ): Promise { + try { + const redisKey = `${KEY_PREFIX}${key}`; + const count = await this.redis.incr(redisKey); + + if (count === 1) { + await this.redis.pexpire(redisKey, windowMs); + } + + return count <= maxAttempts; + } catch (err) { + logger.error({ err }, "[Redis] Rate limiter check failed, allowing request"); + return true; + } + } + + async reset(key: string): Promise { + try { + await this.redis.del(`${KEY_PREFIX}${key}`); + } catch (err) { + logger.error({ err }, "[Redis] Rate limiter reset failed"); + } + } +}