perf: add bans.user_id index, Redis cache layer, rate-limit improvements, radio contest/giveaway columns, and tests
- Add DB index on bans.user_id to speed up per-request ban lookups (migration 0008) - Replace in-process rate limiter with Redis-backed implementation with in-memory fallback - Add Redis caching layer for site settings with TTL invalidation (migration 0009) - Add rate limiting to resetPassword to prevent token brute-force attacks - Update all rateLimit callers to await the now-async function - Flesh out RadioContests and RadioGiveaways models with title, description, prize, date, and winner columns - Update radio contest/giveaway pages to display new fields - Add tests for rate limiter (4 tests) and password-reset actions (3 tests) - Add REDIS_URL environment variable (optional, falls back to in-memory)
This commit is contained in:
1 parent
43c0ba6614
commit
5c638cd6bc
25 files changed
+449
-63
No files matched your search
@@ -40,6 +40,6 @@ export async function requireStaff(): Promise<StaffUser> {
|
||||
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000).ok) redirect("/admin?error=ratelimit");
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) redirect("/admin?error=ratelimit");
|
||||
return staff;
|
||||
}
|
||||
+2
-1
@@ -43,6 +43,7 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
||||
pages: { signIn: "/login" },
|
||||
providers: [
|
||||
@@ -58,7 +59,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
if (!username || !password) return null;
|
||||
|
||||
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
||||
if (!rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000).ok) return null;
|
||||
if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: null,
|
||||
}));
|
||||
|
||||
import { rateLimit } from "./rate-limit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("rateLimit (in-memory fallback)", () => {
|
||||
it("allows the first request", async () => {
|
||||
const res = await rateLimit("test:1", 3, 60_000);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.retryAfter).toBe(0);
|
||||
});
|
||||
|
||||
it("allows up to the limit within a window", async () => {
|
||||
const key = `test:2:${Date.now()}`;
|
||||
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
|
||||
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
|
||||
const res = await rateLimit(key, 2, 60_000);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resets after the window expires", async () => {
|
||||
const key = `test:3:${Date.now()}`;
|
||||
await rateLimit(key, 1, 50);
|
||||
const res1 = await rateLimit(key, 1, 50);
|
||||
expect(res1.ok).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const res2 = await rateLimit(key, 1, 50);
|
||||
expect(res2.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("uses separate keys independently", async () => {
|
||||
const a = await rateLimit("key-a", 1, 60_000);
|
||||
const b = await rateLimit("key-b", 1, 60_000);
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
const a2 = await rateLimit("key-a", 1, 60_000);
|
||||
expect(a2.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
+29
-10
@@ -1,12 +1,11 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
/**
|
||||
* Tiny in-process fixed-window rate limiter for abuse-prone server actions
|
||||
* (register, password reset, login). It's per-node (not shared across
|
||||
* instances) — fine for a single-server retro hotel; swap for Redis if you
|
||||
* ever scale out. Keys are typically `${action}:${ip}`.
|
||||
* Fixed-window rate limiter with optional Redis backend. Falls back to in-process
|
||||
* Map when Redis is unavailable or unconfigured — fine for single-server deployments.
|
||||
*
|
||||
* Periodic cleanup runs every 5 minutes to keep the map bounded.
|
||||
* Periodic cleanup runs every 5 minutes to keep the in-process map bounded.
|
||||
*/
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
@@ -17,19 +16,18 @@ export interface RateLimitResult {
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
let lastCleanup = Date.now();
|
||||
const CLEANUP_INTERVAL_MS = 300_000; // 5 min
|
||||
const CLEANUP_INTERVAL_MS = 300_000;
|
||||
const MAX_BUCKETS = 10_000;
|
||||
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
if (buckets.size <= MAX_BUCKETS) {
|
||||
// Quick eviction of completely expired entries
|
||||
for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k);
|
||||
} else {
|
||||
// Aggressive: clear all expired, then delete oldest 20% if still too large
|
||||
for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k);
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
@@ -39,8 +37,29 @@ function cleanup(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
export async function rateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number,
|
||||
): Promise<RateLimitResult> {
|
||||
const now = Date.now();
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const windowKey = `ratelimit:${key}`;
|
||||
const windowSec = Math.ceil(windowMs / 1000);
|
||||
const current = await redis.incr(windowKey);
|
||||
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
||||
const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
||||
if (current > limit) {
|
||||
return { ok: false, retryAfter: Math.ceil(ttl / 1000) };
|
||||
}
|
||||
return { ok: true, retryAfter: 0 };
|
||||
} catch {
|
||||
// Redis unavailable — fall through to in-memory
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
const bucket = buckets.get(key);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis | null };
|
||||
|
||||
function createRedis(): Redis | null {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
if (times > 3) return null;
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
client.on("error", () => {});
|
||||
return client;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const redis: Redis | null =
|
||||
globalForRedis.redis !== undefined
|
||||
? globalForRedis.redis
|
||||
: (globalForRedis.redis = createRedis());
|
||||
|
||||
export async function withRedis<T>(
|
||||
fallback: () => Promise<T>,
|
||||
redisFn: (client: Redis) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (redis) {
|
||||
try {
|
||||
return await redisFn(redis);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
return fallback();
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
// Fallback values used when a setting row is missing OR the DB is unreachable,
|
||||
// so the app still renders (e.g. local dev without a DATABASE_URL).
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
hotel_name: "Atom",
|
||||
habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
@@ -9,28 +8,51 @@ const DEFAULTS: Record<string, string> = {
|
||||
nitro_client_url: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* DB-driven CMS config, mirroring AtomCMS's `setting()` / habbo-next's
|
||||
* `siteSettings`. Loads the whole website_settings table into a key→value map,
|
||||
* cached in-process, with graceful fallback to DEFAULTS. Booleans are stored as
|
||||
* the strings '1' / '0'.
|
||||
*/
|
||||
const CACHE_TTL_MS = 300_000;
|
||||
const REDIS_CACHE_KEY = "site_settings";
|
||||
|
||||
class SiteSettings {
|
||||
private cache: Map<string, string> | null = null;
|
||||
|
||||
private async loadFromDb(): Promise<Map<string, string>> {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
return new Map(rows.map((r) => [r.key, r.value]));
|
||||
} catch {
|
||||
return new Map(Object.entries(DEFAULTS));
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
if (this.cache === null) {
|
||||
if (redis) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
this.cache = new Map(rows.map((r) => [r.key, r.value]));
|
||||
const cached = await redis.get(REDIS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached) as Record<string, string>;
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — serve defaults without caching so we retry later.
|
||||
return new Map(Object.entries(DEFAULTS));
|
||||
// Redis unavailable — fall through
|
||||
}
|
||||
}
|
||||
return this.cache;
|
||||
|
||||
if (this.cache !== null) return this.cache;
|
||||
|
||||
const map = await this.loadFromDb();
|
||||
this.cache = map;
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const obj = Object.fromEntries(map.entries());
|
||||
await redis.setex(REDIS_CACHE_KEY, Math.ceil(CACHE_TTL_MS / 1000), JSON.stringify(obj));
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
||||
@@ -47,9 +69,11 @@ class SiteSettings {
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
|
||||
/** Invalidate the in-process cache after a settings write. */
|
||||
reload(): void {
|
||||
this.cache = null;
|
||||
if (redis) {
|
||||
redis.del(REDIS_CACHE_KEY).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user