Files
EpicNext-Cms/src/actions/draw-badge.ts
T
Simo f7b3845131 Close the web-feasible 100% gaps: REST write/token API, tickets, draw-badge, /me, sanitisation, dusk, radio SSE
Final parity push (web-tier only):
- REST API write + token auth: POST /api/tokens (issue a personal_access_token
  for the session user), Bearer auth via src/lib/api-auth.ts, POST
  /api/articles/[slug]/comment, GET/DELETE /api/me/tokens, full tickets API
  (/api/tickets +[id] +[id]/reply), radio current-dj/points/points-leaderboard/
  embed-config + POST shouts, and a real-time /api/radio/stream (SSE). 31 public
  API routes total.
- Pages: /draw-badge (buy a custom profile badge → credits + RCON), /me
  dashboard (stats + online friends + referral claim). Wired into the nav.
- HTML sanitisation (sanitize-html) — the HTMLPurifier equivalent — applied to
  writeable boxes + article bodies before dangerouslySetInnerHTML.
- "Dusk" dark theme preset + a default-dark site option honoured by the
  no-flash boot script.

Verified live (prod, amx_test): token issue → Bearer endpoint 200, no-token
401; /api/me/tokens lists it; current-dj/leaderboard JSON; /me + /draw-badge
200; reverted the test user + tokens. tsc 0, vitest 49/49, next build 0.
2026-06-29 18:15:01 +02:00

135 lines
5.0 KiB
TypeScript

"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<number> {
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<void> {
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}`);
}