"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import type { Prisma } from "@/generated/prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { rcon } from "@/lib/services/rcon"; import { siteSettings } from "@/lib/services/site-settings"; /** * Buy a published community-drawn badge for the SIGNED-IN user. Faithful to * AtomCMS's DrawBadgeController buy flow: * - the buyer id is re-read from the session (auth()), NEVER from FormData, * so a crafted form can't purchase on another account; * - only PUBLISHED badges are purchasable; * - the price is a flat, configurable amount (website_settings → the same * `drawbadge.price` key AtomCMS uses), with a safe default; * - the buyer must hold at least `price` credits, which are then deducted; * - the badge is granted live via the emulator (givebadge RCON) and persisted * into users_badges so it survives a relog (mirrors admin giveBadge). * * website_drawbadges has no price/code columns — the price comes from settings * and the emulator badge code is derived from the badge's stored `badge_path` * (the sprite filename, e.g. `album1584/MYBADGE.gif` → `MYBADGE`). */ const DEFAULT_PRICE = 50; // The emulator badge code is the badge_path filename without its directory or // extension, restricted to the code charset the client accepts. function badgeCodeFromPath(badgePath: string): string { const base = badgePath.split(/[\\/]/).pop() ?? badgePath; const noExt = base.replace(/\.[^.]+$/, ""); return noExt.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32); } async function resolvePrice(): Promise { const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE)); const n = Number(raw); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_PRICE; } export async function buyBadge(formData: FormData): Promise { const session = await auth(); if (!session?.user?.id) redirect("/login"); const userId = Number(session.user.id); if (!Number.isFinite(userId)) redirect("/login"); // The form posts the badge row id; everything else (price, code) is resolved // server-side from trusted data — never from the client. const rawId = String(formData.get("id") ?? "") .normalize("NFC") .trim(); if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid"); let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail"; let boughtCode = ""; try { await clientIp(); if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) { outcome = "ratelimit"; } else { const badge = await prisma.websiteDrawbadges.findUnique({ where: { id: BigInt(rawId) }, select: { id: true, badgePath: true, published: true }, }); if (!badge?.published) { outcome = "invalid"; } else { const code = badgeCodeFromPath(badge.badgePath); if (code.length === 0) { outcome = "invalid"; } else { const price = await resolvePrice(); // Re-read the buyer's live credit balance and verify it covers the cost. const buyer = await prisma.user.findUnique({ where: { id: userId }, select: { credits: true }, }); if (!buyer || buyer.credits < price) { outcome = "credits"; } else { // Atomically deduct credits and persist the badge so a failure // between the two operations cannot orphan the user. if (price > 0) { await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.user.update({ where: { id: userId }, data: { credits: { decrement: price } }, }); const existing = await tx.usersBadges.findFirst({ where: { userId, badgeCode: code }, select: { id: true }, }); if (!existing) { const max = await tx.usersBadges.aggregate({ where: { userId }, _max: { slotId: true }, }); const slotId = (max._max.slotId ?? 0) + 1; await tx.usersBadges.create({ data: { userId, slotId, badgeCode: code }, }); } }); } // Grant the badge live so it appears immediately for online users. await rcon.giveBadge(userId, code).catch(() => {}); outcome = "bought"; boughtCode = code; } } } } } catch { outcome = "fail"; } revalidatePath("/draw-badge"); // redirect() throws — it must live OUTSIDE the try/catch. if (outcome === "bought") { redirect(`/draw-badge?bought=${encodeURIComponent(boughtCode)}`); } redirect(`/draw-badge?error=${outcome}`); }