From 5c638cd6bc4670a717e734cdbb61f29e7d10a7ba Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 8 Jul 2026 12:49:24 +0200
Subject: [PATCH] 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)
---
.env.example | 5 ++
package.json | 1 +
pnpm-lock.yaml | 49 +++++++++++
.../0008_add_bans_user_id_index.sql | 4 +
.../0009_radio_contests_giveaways_columns.sql | 29 +++++++
prisma/schema.prisma | 31 +++++--
src/actions/auth-precheck.ts | 2 +-
src/actions/help-tickets.ts | 2 +-
src/actions/password-reset.test.ts | 86 +++++++++++++++++++
src/actions/password-reset.ts | 7 +-
src/actions/radio-shouts.ts | 2 +-
src/actions/register.ts | 2 +-
src/actions/twofactor.ts | 4 +-
src/app/api/client/sso/route.ts | 2 +-
src/app/radio/contests/[id]/page.tsx | 30 +++++--
src/app/radio/contests/page.tsx | 16 ++--
src/app/radio/giveaways/[id]/page.tsx | 31 +++++--
src/app/radio/giveaways/page.tsx | 17 ++--
src/env.ts | 2 +
src/lib/admin/guard.ts | 2 +-
src/lib/auth.ts | 3 +-
src/lib/rate-limit.test.ts | 47 ++++++++++
src/lib/rate-limit.ts | 39 ++++++---
src/lib/redis.ts | 41 +++++++++
src/lib/services/site-settings.ts | 58 +++++++++----
25 files changed, 449 insertions(+), 63 deletions(-)
create mode 100644 prisma/migrations/0008_add_bans_user_id_index.sql
create mode 100644 prisma/migrations/0009_radio_contests_giveaways_columns.sql
create mode 100644 src/actions/password-reset.test.ts
create mode 100644 src/lib/rate-limit.test.ts
create mode 100644 src/lib/redis.ts
diff --git a/.env.example b/.env.example
index d96bd818..f49f47b6 100644
--- a/.env.example
+++ b/.env.example
@@ -64,3 +64,8 @@ OPENAI_API_KEY=
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
+
+# Optional Redis β enables shared caching for rate limiting and site settings,
+# allowing horizontal scaling across multiple instances. Falls back to in-process
+# Maps when unset.
+REDIS_URL=redis://127.0.0.1:6379
diff --git a/package.json b/package.json
index dfd8d339..69c61974 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"bcryptjs": "^3.0.2",
"croner": "^10.0.1",
"hash-wasm": "^4.12.0",
+ "ioredis": "^5.11.1",
"jszip": "^3.10.1",
"lucide-react": "^1.23.0",
"next": "^16.2.10",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7a94824b..46387047 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
+ ioredis:
+ specifier: ^5.11.1
+ version: 5.11.1
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -920,6 +923,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@ioredis/commands@1.10.0':
+ resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1703,6 +1709,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ cluster-key-slot@1.1.1:
+ resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
+ engines: {node: '>=0.10.0'}
+
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
@@ -2027,6 +2037,10 @@ packages:
intl-messageformat@11.2.9:
resolution: {integrity: sha512-cGzymZerpDhVXRKjKLgXKda9gI29TU2o88L7gwNMHp3WZVxA/0c5tX52udXbW9JklDApolvMXZG6Dhhdz5eirA==}
+ ioredis@5.11.1:
+ resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
+ engines: {node: '>=12.22.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2374,6 +2388,14 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
+ redis-errors@1.2.0:
+ resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
+ engines: {node: '>=4'}
+
+ redis-parser@3.0.0:
+ resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
+ engines: {node: '>=4'}
+
remeda@2.33.4:
resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==}
@@ -2473,6 +2495,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ standard-as-callback@2.1.0:
+ resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
+
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
@@ -3077,6 +3102,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
+ '@ioredis/commands@1.10.0': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -3720,6 +3747,8 @@ snapshots:
client-only@0.0.1: {}
+ cluster-key-slot@1.1.1: {}
+
confbox@0.2.4: {}
core-util-is@1.0.3: {}
@@ -4016,6 +4045,18 @@ snapshots:
'@formatjs/fast-memoize': 3.1.6
'@formatjs/icu-messageformat-parser': 3.5.12
+ ioredis@5.11.1:
+ dependencies:
+ '@ioredis/commands': 1.10.0
+ cluster-key-slot: 1.1.1
+ debug: 4.4.3
+ denque: 2.1.0
+ redis-errors: 1.2.0
+ redis-parser: 3.0.0
+ standard-as-callback: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
is-extglob@2.1.1: {}
is-glob@4.0.3:
@@ -4337,6 +4378,12 @@ snapshots:
readdirp@5.0.0: {}
+ redis-errors@1.2.0: {}
+
+ redis-parser@3.0.0:
+ dependencies:
+ redis-errors: 1.2.0
+
remeda@2.33.4: {}
require-from-string@2.0.2: {}
@@ -4464,6 +4511,8 @@ snapshots:
stackback@0.0.2: {}
+ standard-as-callback@2.1.0: {}
+
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
diff --git a/prisma/migrations/0008_add_bans_user_id_index.sql b/prisma/migrations/0008_add_bans_user_id_index.sql
new file mode 100644
index 00000000..2f3a68f8
--- /dev/null
+++ b/prisma/migrations/0008_add_bans_user_id_index.sql
@@ -0,0 +1,4 @@
+-- 0008_add_bans_user_id_index.sql
+-- Adds an index on bans.user_id to speed up ban lookups by user (used by
+-- the access guard on every page request for logged-in users).
+CREATE INDEX IF NOT EXISTS `bans_user_id_index` ON `bans` (`user_id`);
diff --git a/prisma/migrations/0009_radio_contests_giveaways_columns.sql b/prisma/migrations/0009_radio_contests_giveaways_columns.sql
new file mode 100644
index 00000000..719c36b9
--- /dev/null
+++ b/prisma/migrations/0009_radio_contests_giveaways_columns.sql
@@ -0,0 +1,29 @@
+-- 0009_radio_contests_giveaways_columns.sql
+-- Fills in the empty RadioContests and RadioGiveaways tables with meaningful
+-- columns. These tables were created in migration 0004 with only id/timestamps.
+
+ALTER TABLE `radio_contests`
+ ADD COLUMN `title` VARCHAR(255) NOT NULL AFTER `id`,
+ ADD COLUMN `description` TEXT NULL AFTER `title`,
+ ADD COLUMN `prize` VARCHAR(255) NULL AFTER `description`,
+ ADD COLUMN `start_date` TIMESTAMP(0) NULL AFTER `prize`,
+ ADD COLUMN `end_date` TIMESTAMP(0) NULL AFTER `start_date`,
+ ADD COLUMN `winner_id` BIGINT UNSIGNED NULL AFTER `end_date`,
+ ADD COLUMN `is_active` BOOLEAN NOT NULL DEFAULT true AFTER `winner_id`,
+ ADD COLUMN `created_by` BIGINT UNSIGNED NULL AFTER `is_active`,
+ MODIFY COLUMN `created_at` TIMESTAMP(0) NULL AFTER `created_by`,
+ MODIFY COLUMN `updated_at` TIMESTAMP(0) NULL AFTER `created_at`;
+
+ALTER TABLE `radio_giveaways`
+ ADD COLUMN `title` VARCHAR(255) NOT NULL AFTER `id`,
+ ADD COLUMN `description` TEXT NULL AFTER `title`,
+ ADD COLUMN `prize` VARCHAR(255) NULL AFTER `description`,
+ ADD COLUMN `prize_amount` INT NOT NULL DEFAULT 0 AFTER `prize`,
+ ADD COLUMN `prize_currency` VARCHAR(50) NULL AFTER `prize_amount`,
+ ADD COLUMN `start_date` TIMESTAMP(0) NULL AFTER `prize_currency`,
+ ADD COLUMN `end_date` TIMESTAMP(0) NULL AFTER `start_date`,
+ ADD COLUMN `winner_id` BIGINT UNSIGNED NULL AFTER `end_date`,
+ ADD COLUMN `is_active` BOOLEAN NOT NULL DEFAULT true AFTER `winner_id`,
+ ADD COLUMN `created_by` BIGINT UNSIGNED NULL AFTER `is_active`,
+ MODIFY COLUMN `created_at` TIMESTAMP(0) NULL AFTER `created_by`,
+ MODIFY COLUMN `updated_at` TIMESTAMP(0) NULL AFTER `created_at`;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b0252f91..50368bd8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -92,6 +92,7 @@ model Ban {
type bans_type @default(account)
cfhTopic Int @default(-1) @map("cfh_topic")
+ @@index([userId])
@@map("bans")
}
@@ -2121,17 +2122,35 @@ model RadioHistory {
}
model RadioContests {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- createdAt DateTime? @map("created_at") @db.Timestamp(0)
- updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ title String @db.VarChar(255)
+ description String? @db.Text
+ prize String? @db.VarChar(255)
+ startDate DateTime? @map("start_date") @db.Timestamp(0)
+ endDate DateTime? @map("end_date") @db.Timestamp(0)
+ winnerId BigInt? @map("winner_id") @db.UnsignedBigInt
+ isActive Boolean @default(true) @map("is_active")
+ createdBy BigInt? @map("created_by") @db.UnsignedBigInt
+ createdAt DateTime? @map("created_at") @db.Timestamp(0)
+ updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
@@map("radio_contests")
}
model RadioGiveaways {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- createdAt DateTime? @map("created_at") @db.Timestamp(0)
- updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ title String @db.VarChar(255)
+ description String? @db.Text
+ prize String? @db.VarChar(255)
+ prizeAmount Int @default(0) @map("prize_amount")
+ prizeCurrency String? @map("prize_currency") @db.VarChar(50)
+ startDate DateTime? @map("start_date") @db.Timestamp(0)
+ endDate DateTime? @map("end_date") @db.Timestamp(0)
+ winnerId BigInt? @map("winner_id") @db.UnsignedBigInt
+ isActive Boolean @default(true) @map("is_active")
+ createdBy BigInt? @map("created_by") @db.UnsignedBigInt
+ createdAt DateTime? @map("created_at") @db.Timestamp(0)
+ updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
@@map("radio_giveaways")
}
diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts
index 60718f62..8e0723c1 100644
--- a/src/actions/auth-precheck.ts
+++ b/src/actions/auth-precheck.ts
@@ -19,7 +19,7 @@ export async function precheckLogin(
const p = String(password ?? "");
if (!u || !p) return "invalid";
- if (!rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000).ok) return "invalid";
+ if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok) return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
try {
diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts
index 8a3b820f..20ae8ea1 100644
--- a/src/actions/help-tickets.ts
+++ b/src/actions/help-tickets.ts
@@ -19,7 +19,7 @@ export async function createTicket(formData: FormData): Promise {
if (!Number.isInteger(userId) || userId <= 0) return;
const ip = await clientIp();
- if (!rateLimit(`ticket:${userId}`, 3, 60_000).ok) return;
+ if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
const raw = {
title: String(formData.get("title") ?? "").trim().slice(0, 255),
diff --git a/src/actions/password-reset.test.ts b/src/actions/password-reset.test.ts
new file mode 100644
index 00000000..e1e916fb
--- /dev/null
+++ b/src/actions/password-reset.test.ts
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } = vi.hoisted(
+ () => ({
+ mockFindFirst: vi.fn(),
+ mockUpsert: vi.fn(),
+ mockFindUnique: vi.fn(),
+ mockUpdate: vi.fn(),
+ mockDelete: vi.fn(),
+ mockSendMail: vi.fn(),
+ mockRedirect: vi.fn(),
+ }),
+);
+
+vi.mock("next/navigation", () => ({
+ redirect: (...args: unknown[]) => {
+ mockRedirect(...args);
+ throw new Error("redirect");
+ },
+}));
+
+vi.mock("@/lib/prisma", () => ({
+ prisma: {
+ user: { findFirst: mockFindFirst, update: mockUpdate },
+ passwordReset: { upsert: mockUpsert, findUnique: mockFindUnique, delete: mockDelete },
+ },
+}));
+
+vi.mock("@/lib/services/email", () => ({
+ sendMail: mockSendMail,
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
+ clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
+}));
+
+vi.mock("@/env", () => ({
+ env: { APP_URL: "http://localhost:3000", HOTEL_NAME: "TestHotel" },
+}));
+
+import { requestReset } from "./password-reset";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("requestReset", () => {
+ it("sends a reset email when the user exists", async () => {
+ mockFindFirst.mockResolvedValue({ id: 1 });
+ mockUpsert.mockResolvedValue({});
+
+ const fd = new FormData();
+ fd.set("email", "user@example.com");
+
+ await expect(requestReset(fd)).rejects.toThrow("redirect");
+
+ expect(mockFindFirst).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { mail: "user@example.com" } }),
+ );
+ expect(mockUpsert).toHaveBeenCalled();
+ expect(mockSendMail).toHaveBeenCalledWith(
+ "user@example.com",
+ expect.stringContaining("password reset"),
+ expect.stringContaining("http://localhost:3000/reset"),
+ );
+ });
+
+ it("does not send email when user is not found", async () => {
+ mockFindFirst.mockResolvedValue(null);
+
+ const fd = new FormData();
+ fd.set("email", "unknown@example.com");
+
+ await expect(requestReset(fd)).rejects.toThrow("redirect");
+ expect(mockSendMail).not.toHaveBeenCalled();
+ });
+
+ it("rate limits and does not throw on email without @", async () => {
+ const fd = new FormData();
+ fd.set("email", "not-an-email");
+
+ await expect(requestReset(fd)).rejects.toThrow("redirect");
+ expect(mockFindFirst).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts
index 495b56c7..3caf70c3 100644
--- a/src/actions/password-reset.ts
+++ b/src/actions/password-reset.ts
@@ -18,7 +18,7 @@ export async function requestReset(formData: FormData): Promise {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
- const allowed = rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000).ok;
+ const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok;
// Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
@@ -51,6 +51,11 @@ export async function resetPassword(formData: FormData): Promise {
const token = String(formData.get("token") ?? "").trim();
const password = String(formData.get("password") ?? "");
+ // Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
+ if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
+ redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts β try again later")}`);
+ }
+
let error: string | null = null;
if (password.length < 6) error = "Password must be at least 6 characters";
diff --git a/src/actions/radio-shouts.ts b/src/actions/radio-shouts.ts
index d800f00a..1384360d 100644
--- a/src/actions/radio-shouts.ts
+++ b/src/actions/radio-shouts.ts
@@ -25,7 +25,7 @@ export async function postShout(formData: FormData): Promise {
if (!Number.isInteger(userId) || userId <= 0) return;
const ip = await clientIp();
- if (!rateLimit(`shout:${userId}`, 5, 30_000).ok) return;
+ if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
const raw = {
message: String(formData.get("message") ?? "").trim().slice(0, 255),
diff --git a/src/actions/register.ts b/src/actions/register.ts
index 88773d0d..500ca8ba 100644
--- a/src/actions/register.ts
+++ b/src/actions/register.ts
@@ -47,7 +47,7 @@ export async function register(prevState: string | null, formData: FormData): Pr
const ip = await clientIp();
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
- if (!rateLimit(`register:${ip}`, 5, 10 * 60_000).ok) {
+ if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
return "Too many sign-up attempts. Please wait a few minutes and try again.";
}
diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts
index adec0d74..a9a400dd 100644
--- a/src/actions/twofactor.ts
+++ b/src/actions/twofactor.ts
@@ -78,7 +78,7 @@ export async function confirmTwoFactor(formData: FormData): Promise {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
- if (!rateLimit(`2fa-confirm:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit");
+ if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "").trim();
@@ -93,7 +93,7 @@ export async function disableTwoFactor(formData: FormData): Promise {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
- if (!rateLimit(`2fa-disable:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit");
+ if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "").trim();
diff --git a/src/app/api/client/sso/route.ts b/src/app/api/client/sso/route.ts
index f3778293..50097e1f 100644
--- a/src/app/api/client/sso/route.ts
+++ b/src/app/api/client/sso/route.ts
@@ -16,7 +16,7 @@ export async function GET() {
const userId = Number(session.user.id);
// Throttle SSO ticket generation (5 per 30s per user) β prevent ticket spam.
- if (!rateLimit(`sso:${userId}`, 5, 30_000).ok) {
+ if (!(await rateLimit(`sso:${userId}`, 5, 30_000)).ok) {
return new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 });
}
diff --git a/src/app/radio/contests/[id]/page.tsx b/src/app/radio/contests/[id]/page.tsx
index 9a8d0fae..71ddfe55 100644
--- a/src/app/radio/contests/[id]/page.tsx
+++ b/src/app/radio/contests/[id]/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
+import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -15,9 +16,8 @@ export default async function RadioContestDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
+ const t = await getTranslations("pages.radioContests");
- // Route param is a string; RadioContests.id is a BigInt. Guard against a
- // non-numeric id before constructing the BigInt (would otherwise throw).
let contestId: bigint;
try {
contestId = BigInt(id);
@@ -31,7 +31,7 @@ export default async function RadioContestDetailPage({
if (!contest) notFound();
- const contestIdStr = contest.id.toString();
+ const active = contest.isActive ? "Active" : "Ended";
return (
@@ -39,9 +39,29 @@ export default async function RadioContestDetailPage({
β Back to contests
-
-
+
+
+ {contest.description ? (
+
+ | Description |
+ {contest.description} |
+
+ ) : null}
+ {contest.prize ? (
+
+ | Prize |
+ {contest.prize} |
+
+ ) : null}
+
+ | Period |
+
+ {contest.startDate ? formatDate(contest.startDate) : "β"}
+ {" β "}
+ {contest.endDate ? formatDate(contest.endDate) : "ongoing"}
+ |
+
| Created |
{formatDate(contest.createdAt) || β} |
diff --git a/src/app/radio/contests/page.tsx b/src/app/radio/contests/page.tsx
index d8ee4781..74328f03 100644
--- a/src/app/radio/contests/page.tsx
+++ b/src/app/radio/contests/page.tsx
@@ -12,11 +12,12 @@ function formatDate(d: Date | null | undefined): string {
export default async function RadioContestsPage() {
const t = await getTranslations("pages.radioContests");
- // RadioContests.id is a BigInt β stringify before use in keys/routes.
const contests = await prisma.radioContests
.findMany({
- orderBy: { createdAt: "desc" },
+ where: { isActive: true },
+ orderBy: { startDate: "desc" },
take: 50,
+ select: { id: true, title: true, prize: true, startDate: true, endDate: true },
})
.catch(() => []);
@@ -43,10 +44,15 @@ export default async function RadioContestsPage() {
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
>
- π {t("contestLabel", { id })}
+ π {c.title || t("contestLabel", { id })}
-
- {formatDate(c.createdAt) || t("dateUnknown")}
+ {c.prize ? (
+
+ Prize: {c.prize}
+
+ ) : null}
+
+ {c.startDate ? `${formatDate(c.startDate)} β ${c.endDate ? formatDate(c.endDate) : "ongoing"}` : t("dateUnknown")}
);
diff --git a/src/app/radio/giveaways/[id]/page.tsx b/src/app/radio/giveaways/[id]/page.tsx
index abf3238e..6e6144c3 100644
--- a/src/app/radio/giveaways/[id]/page.tsx
+++ b/src/app/radio/giveaways/[id]/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
+import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -15,9 +16,8 @@ export default async function RadioGiveawayDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
+ const t = await getTranslations("pages.radioGiveaways");
- // Route param is a string; RadioGiveaways.id is a BigInt. Guard against a
- // non-numeric id before constructing the BigInt (would otherwise throw).
let giveawayId: bigint;
try {
giveawayId = BigInt(id);
@@ -31,7 +31,8 @@ export default async function RadioGiveawayDetailPage({
if (!giveaway) notFound();
- const giveawayIdStr = giveaway.id.toString();
+ const active = giveaway.isActive ? "Active" : "Ended";
+ const prizeStr = giveaway.prize || (giveaway.prizeAmount > 0 ? `${giveaway.prizeAmount} ${giveaway.prizeCurrency || "credits"}` : null);
return (
@@ -39,9 +40,29 @@ export default async function RadioGiveawayDetailPage({
β Back to giveaways
-
-
+
+
+ {giveaway.description ? (
+
+ | Description |
+ {giveaway.description} |
+
+ ) : null}
+ {prizeStr ? (
+
+ | Prize |
+ {prizeStr} |
+
+ ) : null}
+
+ | Period |
+
+ {giveaway.startDate ? formatDate(giveaway.startDate) : "β"}
+ {" β "}
+ {giveaway.endDate ? formatDate(giveaway.endDate) : "ongoing"}
+ |
+
| Created |
{formatDate(giveaway.createdAt) || β} |
diff --git a/src/app/radio/giveaways/page.tsx b/src/app/radio/giveaways/page.tsx
index 3faf9ae9..1f006147 100644
--- a/src/app/radio/giveaways/page.tsx
+++ b/src/app/radio/giveaways/page.tsx
@@ -12,11 +12,12 @@ function formatDate(d: Date | null | undefined): string {
export default async function RadioGiveawaysPage() {
const t = await getTranslations("pages.radioGiveaways");
- // RadioGiveaways.id is a BigInt β stringify before use in keys/routes.
const giveaways = await prisma.radioGiveaways
.findMany({
- orderBy: { createdAt: "desc" },
+ where: { isActive: true },
+ orderBy: { startDate: "desc" },
take: 50,
+ select: { id: true, title: true, prize: true, prizeAmount: true, prizeCurrency: true, startDate: true, endDate: true },
})
.catch(() => []);
@@ -35,6 +36,7 @@ export default async function RadioGiveawaysPage() {
{giveaways.map((g) => {
const id = g.id.toString();
+ const prizeStr = g.prize || (g.prizeAmount > 0 ? `${g.prizeAmount} ${g.prizeCurrency || "credits"}` : null);
return (
- π {t("giveawayLabel", { id })}
+ π {g.title || t("giveawayLabel", { id })}
-
- {formatDate(g.createdAt) || t("dateUnknown")}
+ {prizeStr ? (
+
+ Prize: {prizeStr}
+
+ ) : null}
+
+ {g.startDate ? `${formatDate(g.startDate)} β ${g.endDate ? formatDate(g.endDate) : "ongoing"}` : t("dateUnknown")}
);
diff --git a/src/env.ts b/src/env.ts
index a08be36b..7447b34b 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -61,6 +61,8 @@ const schema = z.object({
PAYPAL_CLIENT_ID: z.string().optional(),
PAYPAL_SECRET: z.string().optional(),
PAYPAL_API: z.string().url().optional(),
+ // Optional Redis β enables shared caching for rate limiting and site settings.
+ REDIS_URL: z.string().optional(),
});
type Env = z.infer
;
diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts
index cca39cea..93607dd5 100644
--- a/src/lib/admin/guard.ts
+++ b/src/lib/admin/guard.ts
@@ -40,6 +40,6 @@ export async function requireStaff(): Promise {
export async function requireStaffRateLimited(): Promise {
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;
}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 2a88568b..547d6b92 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -43,6 +43,7 @@ async function verify2faCode(userId: number, code: string): Promise {
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) {
diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts
new file mode 100644
index 00000000..0789fc7e
--- /dev/null
+++ b/src/lib/rate-limit.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
index 047b66f8..3f0b63d6 100644
--- a/src/lib/rate-limit.ts
+++ b/src/lib/rate-limit.ts
@@ -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();
@@ -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 {
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);
diff --git a/src/lib/redis.ts b/src/lib/redis.ts
new file mode 100644
index 00000000..b48308bc
--- /dev/null
+++ b/src/lib/redis.ts
@@ -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(
+ fallback: () => Promise,
+ redisFn: (client: Redis) => Promise,
+): Promise {
+ if (redis) {
+ try {
+ return await redisFn(redis);
+ } catch {
+ return fallback();
+ }
+ }
+ return fallback();
+}
diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts
index eb8cfe0e..c3f701dc 100644
--- a/src/lib/services/site-settings.ts
+++ b/src/lib/services/site-settings.ts
@@ -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 = {
hotel_name: "Atom",
habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage",
@@ -9,28 +8,51 @@ const DEFAULTS: Record = {
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 | null = null;
+ private async loadFromDb(): Promise