"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { sendCurrency } from "@/lib/services/send-currency"; 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") ?? "").trim(); if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid"); let outcome: "bought" | "invalid" | "credits" | "fail" = "fail"; let boughtCode = ""; try { const badge = await prisma.websiteDrawbadges.findUnique({ where: { id: BigInt(rawId) }, select: { id: true, badgePath: true, published: true }, }); if (!badge || !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 { // Deduct first, then grant. sendCurrency falls back to a direct DB // write when RCON is offline; a negative amount is not supported, so // the debit is an atomic credits decrement and the credit (grant) is // the badge itself. if (price > 0) { await prisma.user.update({ where: { id: userId }, data: { credits: { decrement: price } }, }); } // Grant the badge live so it appears immediately for online users. await rcon.giveBadge(userId, code); // Persist it so it survives a relog / offline grant. users_badges has // no unique (user_id, badge_code) constraint, so guard duplicates and // compute the next free slot ourselves (mirrors admin giveBadge). try { const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode: code }, select: { id: true }, }); if (!existing) { const max = await prisma.usersBadges.aggregate({ where: { userId }, _max: { slotId: true }, }); const slotId = (max._max.slotId ?? 0) + 1; await prisma.usersBadges.create({ data: { userId, slotId, badgeCode: code }, }); } } catch { // Best-effort: the RCON grant already succeeded for online users. } 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}`); }