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
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Generated
+49
@@ -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/[email protected]':
|
||||
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -1703,6 +1709,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||
|
||||
@@ -2027,6 +2037,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cGzymZerpDhVXRKjKLgXKda9gI29TU2o88L7gwNMHp3WZVxA/0c5tX52udXbW9JklDApolvMXZG6Dhhdz5eirA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2374,6 +2388,14 @@ packages:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==}
|
||||
|
||||
@@ -2473,6 +2495,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
@@ -3077,6 +3102,8 @@ snapshots:
|
||||
'@img/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@ioredis/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -3720,6 +3747,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4016,6 +4045,18 @@ snapshots:
|
||||
'@formatjs/fast-memoize': 3.1.6
|
||||
'@formatjs/icu-messageformat-parser': 3.5.12
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4337,6 +4378,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4464,6 +4511,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
|
||||
@@ -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`);
|
||||
@@ -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`;
|
||||
+25
-6
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
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),
|
||||
|
||||
@@ -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", "[email protected]");
|
||||
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { mail: "[email protected]" } }),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalled();
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
"[email protected]",
|
||||
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", "[email protected]");
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function postShout(formData: FormData): Promise<void> {
|
||||
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),
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
@@ -39,9 +39,29 @@ export default async function RadioContestDetailPage({
|
||||
<Link href="/radio/contests">← Back to contests</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard icon="🎉" title={`Contest #${contestIdStr}`} subtitle="Radio contest details">
|
||||
<table>
|
||||
<ContentCard icon="🎉" title={contest.title || `Contest #${contest.id}`} subtitle={active}>
|
||||
<table style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
{contest.description ? (
|
||||
<tr>
|
||||
<th style={{ verticalAlign: "top", width: 120 }}>Description</th>
|
||||
<td>{contest.description}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{contest.prize ? (
|
||||
<tr>
|
||||
<th>Prize</th>
|
||||
<td>{contest.prize}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<td>
|
||||
{contest.startDate ? formatDate(contest.startDate) : "—"}
|
||||
{" — "}
|
||||
{contest.endDate ? formatDate(contest.endDate) : "ongoing"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>{formatDate(contest.createdAt) || <span className="muted">—</span>}</td>
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: "1rem" }}>
|
||||
<span aria-hidden>🎉</span> {t("contestLabel", { id })}
|
||||
<span aria-hidden>🎉</span> {c.title || t("contestLabel", { id })}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || t("dateUnknown")}
|
||||
{c.prize ? (
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.9rem" }}>
|
||||
Prize: {c.prize}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{c.startDate ? `${formatDate(c.startDate)} — ${c.endDate ? formatDate(c.endDate) : "ongoing"}` : t("dateUnknown")}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
@@ -39,9 +40,29 @@ export default async function RadioGiveawayDetailPage({
|
||||
<Link href="/radio/giveaways">← Back to giveaways</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard icon="🎁" title={`Giveaway #${giveawayIdStr}`} subtitle="Radio giveaway details">
|
||||
<table>
|
||||
<ContentCard icon="🎁" title={giveaway.title || `Giveaway #${giveaway.id}`} subtitle={active}>
|
||||
<table style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
{giveaway.description ? (
|
||||
<tr>
|
||||
<th style={{ verticalAlign: "top", width: 120 }}>Description</th>
|
||||
<td>{giveaway.description}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{prizeStr ? (
|
||||
<tr>
|
||||
<th>Prize</th>
|
||||
<td>{prizeStr}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<td>
|
||||
{giveaway.startDate ? formatDate(giveaway.startDate) : "—"}
|
||||
{" — "}
|
||||
{giveaway.endDate ? formatDate(giveaway.endDate) : "ongoing"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>{formatDate(giveaway.createdAt) || <span className="muted">—</span>}</td>
|
||||
|
||||
@@ -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() {
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
const prizeStr = g.prize || (g.prizeAmount > 0 ? `${g.prizeAmount} ${g.prizeCurrency || "credits"}` : null);
|
||||
return (
|
||||
<Link
|
||||
key={id}
|
||||
@@ -43,10 +45,15 @@ export default async function RadioGiveawaysPage() {
|
||||
style={{ color: "inherit", textDecoration: "none", display: "grid", gap: "0.3rem" }}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: "1rem" }}>
|
||||
<span aria-hidden>🎁</span> {t("giveawayLabel", { id })}
|
||||
<span aria-hidden>🎁</span> {g.title || t("giveawayLabel", { id })}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || t("dateUnknown")}
|
||||
{prizeStr ? (
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.9rem" }}>
|
||||
Prize: {prizeStr}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{g.startDate ? `${formatDate(g.startDate)} — ${g.endDate ? formatDate(g.endDate) : "ongoing"}` : t("dateUnknown")}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
@@ -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