"use server"; 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"; import { creditsPerUnit } from "@/lib/services/paypal"; import { rcon } from "@/lib/services/rcon"; import { sendCurrency } from "@/lib/services/send-currency"; /** * Credits charged for a package row. AtomCMS stores `costs` in cents (USD display); * we mirror the top-up rate so $1.00 of list price costs creditsPerUnit() credits. */ function creditPriceFromCosts(costs: number): number { const rate = creditsPerUnit(); const dollars = costs < 100 ? 1 : costs / 100; return Math.max(1, Math.floor(dollars * rate)); } function parseBadgeCodes(raw: string | null | undefined): string[] { if (!raw?.trim()) return []; return raw .split(/[,;]+/) .map((s) => s.trim()) .filter((s) => s.length > 0 && s.length <= 32); } type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error"; function shopRedirect( categoryId: string, outcome: BuyOutcome, articleName?: string, ): never { const params = new URLSearchParams(); if (categoryId) params.set("category", categoryId); if (outcome === "bought") { params.set("bought", "1"); if (articleName) params.set("package", articleName); } else { params.set("error", outcome); } const qs = params.toString(); redirect(qs ? `/shop?${qs}` : "/shop"); } function isNextRedirect(e: unknown): boolean { return ( !!e && typeof e === "object" && "digest" in e && typeof (e as { digest?: unknown }).digest === "string" && (e as { digest: string }).digest.startsWith("NEXT_REDIRECT") ); } /** * Purchase a website shop package with in-game credits (top up via /shop/topup first). * The buyer id is always taken from the session, never from FormData. */ export async function buyShopArticle(formData: FormData): Promise { const categoryId = String(formData.get("categoryId") ?? "") .normalize("NFC") .trim(); const safeCategory = /^\d+$/.test(categoryId) ? categoryId : ""; let outcome: BuyOutcome = "error"; let packageName = ""; try { const session = await auth(); const userId = Number(session?.user?.id); if (!Number.isInteger(userId) || userId <= 0) { redirect("/login"); } await clientIp(); if (!(await rateLimit(`shop-buy:${userId}`, 5, 60_000)).ok) { outcome = "ratelimit"; } else { const rawId = String(formData.get("articleId") ?? "") .normalize("NFC") .trim(); if (!/^\d+$/.test(rawId)) { outcome = "invalid"; } else { const article = await prisma.websiteShopArticles.findUnique({ where: { id: BigInt(rawId) }, select: { id: true, name: true, costs: true, credits: true, duckets: true, diamonds: true, badges: true, giveRank: true, }, }); if (!article) { outcome = "invalid"; } else { packageName = article.name; const price = creditPriceFromCosts(article.costs); const buyer = await prisma.user.findUnique({ where: { id: userId }, select: { credits: true, rank: true }, }); if (!buyer || buyer.credits < price) { outcome = "credits"; } else { const badgeCodes = parseBadgeCodes(article.badges); await prisma.$transaction(async (tx) => { if (price > 0) { await tx.user.update({ where: { id: userId }, data: { credits: { decrement: price } }, }); } if ( article.giveRank != null && article.giveRank > 0 && article.giveRank > buyer.rank ) { await tx.user.update({ where: { id: userId }, data: { rank: article.giveRank }, }); } for (const code of badgeCodes) { 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 }, }); } } }); await sendCurrency( { rcon, db: prisma }, userId, "credits", article.credits, ); await sendCurrency( { rcon, db: prisma }, userId, "duckets", article.duckets, ); await sendCurrency( { rcon, db: prisma }, userId, "diamonds", article.diamonds, ); for (const code of badgeCodes) { await rcon.giveBadge(userId, code).catch(() => {}); } outcome = "bought"; } } } } } catch (e) { if (isNextRedirect(e)) throw e; outcome = "error"; } revalidatePath("/shop"); shopRedirect(safeCategory, outcome, packageName || undefined); }