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 ? ( + + + + + ) : null} + {contest.prize ? ( + + + + + ) : null} + + + + 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

- -
Description{contest.description}
Prize{contest.prize}
Period + {contest.startDate ? formatDate(contest.startDate) : "β€”"} + {" β€” "} + {contest.endDate ? formatDate(contest.endDate) : "ongoing"} +
Created {formatDate(contest.createdAt) || β€”}
+ +
+ {giveaway.description ? ( + + + + + ) : null} + {prizeStr ? ( + + + + + ) : null} + + + + 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> { + 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> { - 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; + 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 { @@ -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(() => {}); + } } }
Description{giveaway.description}
Prize{prizeStr}
Period + {giveaway.startDate ? formatDate(giveaway.startDate) : "β€”"} + {" β€” "} + {giveaway.endDate ? formatDate(giveaway.endDate) : "ongoing"} +
Created {formatDate(giveaway.createdAt) || β€”}