Phase A — Public REST API (was the biggest gap). 20 JSON endpoints under
/api mirroring AtomCMS: users/[username], online(+/count), me, articles
(+/[slug]), photos, home, staff, teams, leaderboard, shop(+/categories),
values(+/categories), settings, radio/{config,now-playing,listeners,
shouts}. Shared src/lib/api.ts (apiJson — BigInt-safe + CORS, pagination).
Read-only, fail-soft, and field-safe (never exposes password/auth_ticket/
2FA secrets/mail).
Phase B — Anti-abuse on registration: CAPTCHA (Cloudflare Turnstile /
Google reCAPTCHA, settings-driven, widget rendered on the register page),
VPN/proxy detection (proxycheck.io / IPQualityScore via /admin/vpn
settings), and max-accounts-per-IP. All fail-open when unconfigured.
src/lib/services/{captcha,ip-lookup}.ts.
Phase C — jobs-worker cron suite: radio-record-songs (30s, logs track
changes to radio_song_plays), radio-auto-dj (rotates radio_auto_dj_playlist
when no live DJ), github-update-check (hourly, sets update_available).
Shared src/lib/services/radio.ts (now-playing/listeners parsing).
Verified live (prod, amx_test): /api/* return real JSON (leaderboard 6
users, settings carry no secrets, user endpoint hides password). tsc 0,
vitest 49/49, next build 0 (20 new API routes).
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
import { apiJson } from "@/lib/api";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
// Public REST API — leaderboard. Mirrors src/app/leaderboard/page.tsx.
|
|
// AtomCMS-faithful currency type ids (see prisma/schema.prisma UsersCurrency):
|
|
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
|
export const dynamic = "force-dynamic";
|
|
|
|
type LeaderboardType = "credits" | "diamonds" | "duckets";
|
|
|
|
const CURRENCY_TYPE: Record<Exclude<LeaderboardType, "credits">, number> = {
|
|
diamonds: 5,
|
|
duckets: 0,
|
|
};
|
|
|
|
type Row = { rank: number; username: string; look: string; value: number };
|
|
|
|
async function loadCreditsRows(): Promise<Row[]> {
|
|
const users = await prisma.user.findMany({
|
|
orderBy: { credits: "desc" },
|
|
take: 20,
|
|
select: { username: true, look: true, credits: true },
|
|
});
|
|
return users.map((u, i) => ({
|
|
rank: i + 1,
|
|
username: u.username,
|
|
look: u.look,
|
|
value: u.credits,
|
|
}));
|
|
}
|
|
|
|
async function loadCurrencyRows(type: number): Promise<Row[]> {
|
|
const top = await prisma.usersCurrency.findMany({
|
|
where: { type },
|
|
orderBy: { amount: "desc" },
|
|
take: 20,
|
|
select: { userId: true, amount: true },
|
|
});
|
|
if (top.length === 0) return [];
|
|
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: top.map((t) => t.userId) } },
|
|
select: { id: true, username: true, look: true },
|
|
});
|
|
const byId = new Map(users.map((u) => [u.id, u]));
|
|
|
|
return top
|
|
.map((t) => {
|
|
const u = byId.get(t.userId);
|
|
if (!u) return null;
|
|
return { username: u.username, look: u.look, value: t.amount };
|
|
})
|
|
.filter((r): r is Omit<Row, "rank"> => r !== null)
|
|
.map((r, i) => ({ rank: i + 1, ...r }));
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
try {
|
|
const sp = new URL(req.url).searchParams;
|
|
const requested = sp.get("type");
|
|
const type: LeaderboardType =
|
|
requested === "diamonds" || requested === "duckets" ? requested : "credits";
|
|
|
|
const rows =
|
|
type === "credits"
|
|
? await loadCreditsRows()
|
|
: await loadCurrencyRows(CURRENCY_TYPE[type]);
|
|
|
|
return apiJson({ type, data: rows }, { status: 200 });
|
|
} catch {
|
|
return apiJson({ type: "credits", data: [] }, { status: 200 });
|
|
}
|
|
}
|