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).
33 lines
1.3 KiB
TypeScript
33 lines
1.3 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
|
|
/**
|
|
* JSON response helper for the public REST API. Serialises BigInt (Prisma ids)
|
|
* to strings — JSON.stringify throws on BigInt otherwise — and sets permissive
|
|
* CORS so the game client / external integrations can read it (mirrors the
|
|
* AtomCMS API CORS config).
|
|
*/
|
|
export function apiJson(data: unknown, init?: ResponseInit): NextResponse {
|
|
const body = JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
|
|
return new NextResponse(body, {
|
|
status: init?.status ?? 200,
|
|
headers: {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"access-control-allow-origin": "*",
|
|
"cache-control": "no-store",
|
|
...(init?.headers ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Standard error envelope. */
|
|
export function apiError(message: string, status = 400): NextResponse {
|
|
return apiJson({ error: message }, { status });
|
|
}
|
|
|
|
/** Clamp a ?page / ?perPage pair from search params. */
|
|
export function pagination(searchParams: URLSearchParams, defaultPer = 20, maxPer = 100) {
|
|
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
|
const perPage = Math.min(maxPer, Math.max(1, Number(searchParams.get("perPage") ?? defaultPer) || defaultPer));
|
|
return { page, perPage, skip: (page - 1) * perPage, take: perPage };
|
|
}
|