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