From 968ca15c27cf79fb2dee622ab06f5237dc5cc3ab Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 21 Jul 2026 21:58:48 +0200 Subject: [PATCH] feat: jwt cache, redis health, help-ticket admin, and write rate limits Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere. Co-authored-by: Cursor --- .env.example | 7 +- .gitea/workflows/deploy.yaml | 5 + src/actions/admin-help-tickets.ts | 136 +++++++++ src/actions/article-reactions.ts | 83 ++--- src/actions/referral.ts | 181 +++++------ src/actions/sessions.ts | 14 +- .../[id]/admin-help-ticket-detail.tsx | 288 ++++++++++++++++++ src/app/admin/help-tickets/[id]/page.tsx | 127 ++++++++ .../admin/help-tickets/help-tickets-table.tsx | 89 ++++++ src/app/admin/help-tickets/page.tsx | 187 ++++++++++++ src/app/api/health/route.ts | 27 +- src/app/api/tickets/[id]/reply/route.ts | 5 + src/app/api/tickets/route.ts | 5 + src/lib/admin-nav.ts | 9 +- src/lib/auth.ts | 147 ++++----- src/lib/auth/jwt-version-cache.test.ts | 51 ++++ src/lib/auth/jwt-version-cache.ts | 73 +++++ src/lib/deploy-workflow-contract.test.ts | 1 + src/lib/redis.ts | 2 +- src/messages/en.json | 37 +++ src/messages/it.json | 37 +++ src/messages/nl.json | 37 +++ src/types/next-auth.d.ts | 1 + 23 files changed, 1344 insertions(+), 205 deletions(-) create mode 100644 src/actions/admin-help-tickets.ts create mode 100644 src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx create mode 100644 src/app/admin/help-tickets/[id]/page.tsx create mode 100644 src/app/admin/help-tickets/help-tickets-table.tsx create mode 100644 src/app/admin/help-tickets/page.tsx create mode 100644 src/lib/auth/jwt-version-cache.test.ts create mode 100644 src/lib/auth/jwt-version-cache.ts diff --git a/.env.example b/.env.example index 6ba51350..0edf1479 100644 --- a/.env.example +++ b/.env.example @@ -74,9 +74,10 @@ PAYPAL_CLIENT_ID= PAYPAL_SECRET= PAYPAL_API=https://api-m.sandbox.paypal.com -# Redis — strongly recommended in production (required for multi-instance). -# Shared rate limiting + site-settings cache. Without it, limits are in-process -# only and do not hold across restarts or multiple app instances. +# Redis — REQUIRED for production (shared rate limits, site-settings cache, +# JWT session invalidation cache). Without REDIS_URL the app falls back to +# in-process memory: limits reset on restart and do not work across instances. +# Deploy logs a loud warning when this is unset in production. REDIS_URL=redis://127.0.0.1:6379 # Logging level (debug | info | warn | error). Defaults to 'info' in production, diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index ba7d8036..aa6a95fb 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -288,6 +288,11 @@ jobs: # Production env stays on the live tree; stage only needs a symlink for build/migrate. ln -sfn "${LIVE}/.env" "${STAGE}/.env" + if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then + echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2 + echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2 + fi + cd "${STAGE}" rm -f tsconfig.tsbuildinfo .tsbuildinfo find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts new file mode 100644 index 00000000..f6a5358f --- /dev/null +++ b/src/actions/admin-help-tickets.ts @@ -0,0 +1,136 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { adminAction } from "@/lib/safe-action"; +import { ActionError, actionOk } from "@/lib/safe-action-shared"; +import { logAudit } from "@/lib/services/audit"; + +const ticketIdField = z + .union([z.string(), z.number(), z.bigint()]) + .transform((v) => BigInt(String(v))); + +const replyHelpCenterTicketSchema = z.object({ + ticketId: ticketIdField, + content: z.string().min(1).max(5000), +}); + +const helpCenterTicketIdSchema = z.object({ + ticketId: ticketIdField, +}); + +function revalidateHelpCenterTicketPaths(ticketId: bigint) { + const id = String(ticketId); + revalidatePath("/admin/help-tickets"); + revalidatePath(`/admin/help-tickets/${id}`); + revalidatePath("/help/tickets"); + revalidatePath(`/help/tickets/${id}`); +} + +export const replyHelpCenterTicket = adminAction( + { permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema }, + async (ctx) => { + const ticketId = ctx.data.ticketId; + const ticket = await prisma.websiteHelpCenterTickets.findUnique({ + where: { id: ticketId }, + select: { id: true, open: true }, + }); + + if (!ticket) throw new ActionError("Ticket not found"); + + const now = new Date(); + const staffId = Number(ctx.session.user.id); + + await prisma.$transaction([ + prisma.websiteHelpCenterTicketReplies.create({ + data: { + ticketId, + userId: staffId, + content: ctx.data.content.trim(), + createdAt: now, + updatedAt: now, + }, + }), + prisma.websiteHelpCenterTickets.update({ + where: { id: ticketId }, + data: { updatedAt: now }, + }), + ]); + + logAudit({ + userId: staffId, + action: "help_center_ticket_reply", + target: "WebsiteHelpCenterTickets", + targetId: Number(ticketId), + }); + + revalidateHelpCenterTicketPaths(ticketId); + return actionOk(); + }, +); + +export const closeHelpCenterTicket = adminAction( + { permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema }, + async (ctx) => { + const ticketId = ctx.data.ticketId; + const ticket = await prisma.websiteHelpCenterTickets.findUnique({ + where: { id: ticketId }, + select: { id: true, open: true }, + }); + + if (!ticket) throw new ActionError("Ticket not found"); + if (!ticket.open) throw new ActionError("Ticket is already closed"); + + const now = new Date(); + await prisma.websiteHelpCenterTickets.update({ + where: { id: ticketId }, + data: { open: false, updatedAt: now }, + }); + + logAudit({ + userId: Number(ctx.session.user.id), + action: "help_center_ticket_close", + target: "WebsiteHelpCenterTickets", + targetId: Number(ticketId), + before: { open: true }, + after: { open: false }, + }); + + revalidateHelpCenterTicketPaths(ticketId); + return actionOk(); + }, +); + +export const reopenHelpCenterTicket = adminAction( + { permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema }, + async (ctx) => { + const ticketId = ctx.data.ticketId; + const ticket = await prisma.websiteHelpCenterTickets.findUnique({ + where: { id: ticketId }, + select: { id: true, open: true }, + }); + + if (!ticket) throw new ActionError("Ticket not found"); + if (ticket.open) throw new ActionError("Ticket is already open"); + + const now = new Date(); + await prisma.websiteHelpCenterTickets.update({ + where: { id: ticketId }, + data: { open: true, updatedAt: now }, + }); + + logAudit({ + userId: Number(ctx.session.user.id), + action: "help_center_ticket_reopen", + target: "WebsiteHelpCenterTickets", + targetId: Number(ticketId), + before: { open: false }, + after: { open: true }, + }); + + revalidateHelpCenterTicketPaths(ticketId); + return actionOk(); + }, +); diff --git a/src/actions/article-reactions.ts b/src/actions/article-reactions.ts index e1374035..f231d6fe 100644 --- a/src/actions/article-reactions.ts +++ b/src/actions/article-reactions.ts @@ -4,13 +4,19 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; // The reaction set the UI offers. The action rejects anything outside this list // so the website_article_reactions.reaction VARCHAR(50) only ever holds known // values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx. const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]); -type ReactionOutcome = "updated" | "invalid" | "not_found" | "error"; +type ReactionOutcome = + | "updated" + | "invalid" + | "not_found" + | "ratelimit" + | "error"; function reactionRedirect(slug: string, outcome: ReactionOutcome): never { const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news"; @@ -50,58 +56,63 @@ export async function toggleReaction(formData: FormData): Promise { redirect("/login"); } - const reaction = String(formData.get("reaction") ?? "") - .normalize("NFC") - .trim() - .toLowerCase(); - if (!ALLOWED_REACTIONS.has(reaction)) { - outcome = "invalid"; + await clientIp(); + if (!(await rateLimit(`article-reaction:${userId}`, 30, 60_000)).ok) { + outcome = "ratelimit"; } else { - const articleIdRaw = String(formData.get("articleId") ?? "") + const reaction = String(formData.get("reaction") ?? "") .normalize("NFC") - .trim(); - if (!/^\d+$/.test(articleIdRaw)) { + .trim() + .toLowerCase(); + if (!ALLOWED_REACTIONS.has(reaction)) { outcome = "invalid"; } else { - const articleId = BigInt(articleIdRaw); - - const article = await prisma.websiteArticles.findUnique({ - where: { id: articleId }, - select: { slug: true }, - }); - if (!article) { - outcome = "not_found"; + const articleIdRaw = String(formData.get("articleId") ?? "") + .normalize("NFC") + .trim(); + if (!/^\d+$/.test(articleIdRaw)) { + outcome = "invalid"; } else { - slug = article.slug; + const articleId = BigInt(articleIdRaw); - const existing = await prisma.websiteArticleReactions.findFirst({ - where: { userId, articleId, reaction }, - select: { id: true, active: true }, + const article = await prisma.websiteArticles.findUnique({ + where: { id: articleId }, + select: { slug: true }, }); - - if (existing?.active) { - await prisma.websiteArticleReactions.update({ - where: { id: existing.id }, - data: { active: false }, - }); + if (!article) { + outcome = "not_found"; } else { - await prisma.websiteArticleReactions.updateMany({ - where: { userId, articleId, active: true }, - data: { active: false }, + slug = article.slug; + + const existing = await prisma.websiteArticleReactions.findFirst({ + where: { userId, articleId, reaction }, + select: { id: true, active: true }, }); - if (existing) { + if (existing?.active) { await prisma.websiteArticleReactions.update({ where: { id: existing.id }, - data: { active: true }, + data: { active: false }, }); } else { - await prisma.websiteArticleReactions.create({ - data: { userId, articleId, reaction, active: true }, + await prisma.websiteArticleReactions.updateMany({ + where: { userId, articleId, active: true }, + data: { active: false }, }); + + if (existing) { + await prisma.websiteArticleReactions.update({ + where: { id: existing.id }, + data: { active: true }, + }); + } else { + await prisma.websiteArticleReactions.create({ + data: { userId, articleId, reaction, active: true }, + }); + } } + outcome = "updated"; } - outcome = "updated"; } } } diff --git a/src/actions/referral.ts b/src/actions/referral.ts index e69a409f..bccb7a0f 100644 --- a/src/actions/referral.ts +++ b/src/actions/referral.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { clientIp } from "@/lib/rate-limit"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { rcon } from "@/lib/services/rcon"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; @@ -40,6 +40,7 @@ export async function claimReferral(_formData: FormData): Promise { | "not_enough" | "no_referrals" | "bad_config" + | "ratelimit" | "error" = "error"; try { @@ -53,97 +54,105 @@ export async function claimReferral(_formData: FormData): Promise { redirect("/login"); } - // Reward configuration (CMS-owned website_settings). AtomCMS defaults: - // 5 referrals needed, 30 diamonds reward. - const [neededRaw, amountRaw, currencyRaw] = await Promise.all([ - prisma.websiteSetting - .findUnique({ - where: { key: "referrals_needed" }, - select: { value: true }, - }) - .catch(() => null), - prisma.websiteSetting - .findUnique({ - where: { key: "referral_reward_amount" }, - select: { value: true }, - }) - .catch(() => null), - // The seeded key is referral_reward_currency_type; fall back to the - // shorter referral_reward_currency name if that is what is configured. - prisma.websiteSetting - .findFirst({ - where: { - key: { - in: ["referral_reward_currency_type", "referral_reward_currency"], - }, - }, - select: { value: true }, - }) - .catch(() => null), - ]); - - const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; - const amount = Number.parseInt(amountRaw?.value ?? "30", 10); - const currency = (currencyRaw?.value ?? "diamonds") - .trim() - .toLowerCase() as CurrencyName; - - // The user's referral tally lives in user_referrals (one row per user). - const referrals = await prisma.userReferrals - .findFirst({ - where: { userId }, - select: { id: true, referralsTotal: true }, - orderBy: { id: "desc" }, - }) - .catch(() => null); - - const total = referrals ? Number(referrals.referralsTotal) : 0; - - if (!referrals || total <= 0) { - outcome = "no_referrals"; - } else if (total < needed) { - outcome = "not_enough"; - } else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) { - // Misconfigured reward — keep it conservative and grant nothing. - outcome = "bad_config"; + await clientIp(); + if (!(await rateLimit(`referral-claim:${userId}`, 5, 60_000)).ok) { + outcome = "ratelimit"; } else { - // Spend the threshold first so a concurrent double-submit can't claim - // twice off the same balance, then deliver the reward and log it. - await prisma.userReferrals.update({ - where: { id: referrals.id }, - data: { referralsTotal: { decrement: needed } }, - }); - - try { - await sendCurrency({ rcon, db: prisma }, userId, currency, amount); - } catch { - // sendCurrency already falls back to a direct DB write; if it still - // throws the spend stands. Roll the threshold back so the user isn't - // charged for an undelivered reward. - await prisma.userReferrals - .update({ - where: { id: referrals.id }, - data: { referralsTotal: { increment: needed } }, + // Reward configuration (CMS-owned website_settings). AtomCMS defaults: + // 5 referrals needed, 30 diamonds reward. + const [neededRaw, amountRaw, currencyRaw] = await Promise.all([ + prisma.websiteSetting + .findUnique({ + where: { key: "referrals_needed" }, + select: { value: true }, }) - .catch(() => {}); - outcome = "error"; - throw new Error("currency-delivery-failed"); - } + .catch(() => null), + prisma.websiteSetting + .findUnique({ + where: { key: "referral_reward_amount" }, + select: { value: true }, + }) + .catch(() => null), + // The seeded key is referral_reward_currency_type; fall back to the + // shorter referral_reward_currency name if that is what is configured. + prisma.websiteSetting + .findFirst({ + where: { + key: { + in: [ + "referral_reward_currency_type", + "referral_reward_currency", + ], + }, + }, + select: { value: true }, + }) + .catch(() => null), + ]); - await prisma.claimedReferralLogs - .create({ - data: { - userId, - ipAddress: await clientIp(), - createdAt: new Date(), - updatedAt: new Date(), - }, + const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; + const amount = Number.parseInt(amountRaw?.value ?? "30", 10); + const currency = (currencyRaw?.value ?? "diamonds") + .trim() + .toLowerCase() as CurrencyName; + + // The user's referral tally lives in user_referrals (one row per user). + const referrals = await prisma.userReferrals + .findFirst({ + where: { userId }, + select: { id: true, referralsTotal: true }, + orderBy: { id: "desc" }, }) - .catch(() => { - // Best-effort audit log; the reward already landed. + .catch(() => null); + + const total = referrals ? Number(referrals.referralsTotal) : 0; + + if (!referrals || total <= 0) { + outcome = "no_referrals"; + } else if (total < needed) { + outcome = "not_enough"; + } else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) { + // Misconfigured reward — keep it conservative and grant nothing. + outcome = "bad_config"; + } else { + // Spend the threshold first so a concurrent double-submit can't claim + // twice off the same balance, then deliver the reward and log it. + await prisma.userReferrals.update({ + where: { id: referrals.id }, + data: { referralsTotal: { decrement: needed } }, }); - outcome = "claimed"; + try { + await sendCurrency({ rcon, db: prisma }, userId, currency, amount); + } catch { + // sendCurrency already falls back to a direct DB write; if it still + // throws the spend stands. Roll the threshold back so the user isn't + // charged for an undelivered reward. + await prisma.userReferrals + .update({ + where: { id: referrals.id }, + data: { referralsTotal: { increment: needed } }, + }) + .catch(() => {}); + outcome = "error"; + throw new Error("currency-delivery-failed"); + } + + await prisma.claimedReferralLogs + .create({ + data: { + userId, + ipAddress: await clientIp(), + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + .catch(() => { + // Best-effort audit log; the reward already landed. + }); + + outcome = "claimed"; + } } } catch (err) { // redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts index 6b5366fb..18234f91 100644 --- a/src/actions/sessions.ts +++ b/src/actions/sessions.ts @@ -1,11 +1,13 @@ "use server"; import { auth, signOut } from "@/lib/auth"; +import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache"; +import { personalTokenScope } from "@/lib/auth/personal-token-scope"; import { prisma } from "@/lib/prisma"; /** * Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version, - * then end the current browser session too. + * revoke personal access tokens, then end the current browser session too. */ export async function signOutEverywhere(): Promise { const session = await auth(); @@ -20,6 +22,16 @@ export async function signOutEverywhere(): Promise { where: { id: userId }, data: { websiteJwtVersion: { increment: 1 } }, }); + await invalidateJwtVersionCache(userId); + } catch { + /* still continue */ + } + + // Revoke API bearer tokens (Sanctum / personal_access_tokens). + try { + await prisma.personalAccessTokens.deleteMany({ + where: personalTokenScope(userId), + }); } catch { /* still sign out locally */ } diff --git a/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx b/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx new file mode 100644 index 00000000..949a308a --- /dev/null +++ b/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { ArrowLeft, Loader2, Mail, Send, Shield, User } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { + closeHelpCenterTicket, + reopenHelpCenterTicket, + replyHelpCenterTicket, +} from "@/actions/admin-help-tickets"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { useServerAction } from "@/hooks/use-server-action"; + +interface ThreadMessage { + key: string; + userId: number; + username: string; + isStaff: boolean; + content: string; + createdAt: string; +} + +interface HelpTicketInfo { + id: string; + title: string; + open: boolean; + createdAt: string; + updatedAt: string; + creator: { + id: number; + username: string; + rank: number; + mail: string; + } | null; +} + +export function AdminHelpTicketDetail({ + ticket, + messages: initialMessages, + canEdit, +}: { + ticket: HelpTicketInfo; + messages: ThreadMessage[]; + canEdit: boolean; +}) { + const t = useTranslations("pages.admin.helpTickets"); + const [messages, setMessages] = useState(initialMessages); + const [reply, setReply] = useState(""); + const { run, isPending } = useServerAction(); + const messagesEndRef = useRef(null); + + useEffect(() => { + setMessages(initialMessages); + }, [initialMessages]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + function handleReply() { + if (!reply.trim() || isPending) return; + run( + () => + replyHelpCenterTicket({ + ticketId: ticket.id, + content: reply.trim(), + }), + { + successMessage: t("success.replied"), + onSuccess: () => setReply(""), + }, + ); + } + + function handleClose() { + run( + () => closeHelpCenterTicket({ ticketId: ticket.id }), + { successMessage: t("success.closed") }, + ); + } + + function handleReopen() { + run( + () => reopenHelpCenterTicket({ ticketId: ticket.id }), + { successMessage: t("success.reopened") }, + ); + } + + return ( +
+
+ + + +
+
+ + #{ticket.id} + + + {ticket.open ? t("statusOpen") : t("statusClosed")} + +
+

{ticket.title}

+
+
+ +
+
+ + + {t("threadTitle")} + +
+ {messages.map((msg) => ( +
+
+
+ {msg.isStaff ? ( + + ) : ( + + )} +
+
+
+
+ + {msg.username} + {msg.isStaff ? ` (${t("staffBadge")})` : ""} + + + {new Date(msg.createdAt).toLocaleString()} + +
+
+

+ {msg.content} +

+
+
+
+ ))} +
+
+ + {canEdit && ticket.open && ( +
+
+