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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user