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.
This commit is contained in:
1 parent
8cedf5614e
commit
f7b3845131
30 files changed
+1734
-10
No files matched your search
@@ -0,0 +1,134 @@
|
||||
"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}`);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
/**
|
||||
* Claim the referral reward for the SIGNED-IN user. Faithful to AtomCMS's
|
||||
* ReferralController::__invoke:
|
||||
* - the user id is re-read from the session (auth()), NEVER from FormData,
|
||||
* so a crafted form cannot claim on another account;
|
||||
* - the user must have referred at least `referrals_needed` people
|
||||
* (user_referrals.referrals_total >= needed), otherwise it's rejected;
|
||||
* - on success `referrals_total` is decremented by the threshold, the
|
||||
* configured reward is granted, and the claim is logged.
|
||||
*
|
||||
* The reward amount/currency are CMS-configurable via website_settings
|
||||
* (referral_reward_amount + referral_reward_currency_type). If the currency
|
||||
* setting is not one of the four known wallets we keep the claim conservative
|
||||
* and fail rather than guessing — no currency is moved.
|
||||
*
|
||||
* Errors redirect back to /me with a machine-readable ?error= code; success
|
||||
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
|
||||
* internal control-flow throw is never swallowed.
|
||||
*/
|
||||
const VALID_CURRENCIES = new Set<CurrencyName>([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" =
|
||||
"error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } })
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } })
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase() as CurrencyName;
|
||||
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
});
|
||||
|
||||
outcome = "claimed";
|
||||
}
|
||||
} catch (err) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||
// navigation actually happens instead of being treated as a failure.
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"digest" in err &&
|
||||
typeof (err as { digest?: unknown }).digest === "string" &&
|
||||
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if (outcome === "claimed") outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/me");
|
||||
|
||||
if (outcome === "claimed") {
|
||||
redirect("/me?claimed=1");
|
||||
}
|
||||
redirect(`/me?error=${outcome}`);
|
||||
}
|
||||
Reference in new issue
Block a user