- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
137 lines
4.5 KiB
TypeScript
137 lines
4.5 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 { 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<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") ?? "")
|
|
.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) => {
|
|
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}`);
|
|
}
|