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:
openhands committed 2026-07-08 12:49:24 +02:00
1 parent 43c0ba6614
commit 5c638cd6bc
25 files changed
+449 -63

No files matched your search

+1 -1
View File
@@ -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 });
}
+25 -5
View File
@@ -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>
+11 -5
View File
@@ -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>
);
+26 -5
View File
@@ -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 -5
View File
@@ -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>
);