Add public REST API, anti-abuse protections, radio/GitHub cron jobs

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).
This commit is contained in:
Simo committed 2026-06-28 21:44:02 +02:00
1 parent 5a4b6f27e9
commit 80f591a343
29 files changed
+1697 -7

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma";
// Radio player config: the subset of radio_* website_settings the front-end
// player needs (stream URL, name, autoplay, enabled, widget visibility) as a
// flat { key: value } map. No secrets live among these keys.
export const dynamic = "force-dynamic";
// radio_* keys relevant to the public player widget. Mirrors what the AtomCMS
// radio-player blade requests from /api/radio/config.
const CONFIG_KEYS = new Set([
"radio_enabled",
"radio_name",
"radio_stream_url",
"radio_stream_backup_url",
"radio_auto_play",
"radio_auto_play_delay",
"radio_mute_on_start",
"radio_volume",
"radio_style",
"radio_player_type",
"radio_logo_url",
"radio_widget_enabled",
"radio_widget_show_globally",
"radio_widget_position",
]);
export async function GET(_req: Request) {
try {
const rows = await prisma.websiteSetting.findMany({
where: { key: { in: Array.from(CONFIG_KEYS) } },
select: { key: true, value: true },
});
const config: Record<string, string> = {};
for (const row of rows) {
config[row.key] = row.value;
}
return apiJson(config);
} catch {
// DB unavailable — serve an empty config rather than a 500.
return apiJson({}, { status: 200 });
}
}
+89
View File
@@ -0,0 +1,89 @@
import { apiJson } from "@/lib/api";
import { siteSettings } from "@/lib/services/site-settings";
// Proxy for the configured radio "listeners" provider. The provider URL is
// stored in radio_listeners_api_url; we fetch it server-side with a short, hard
// timeout and reduce the response to a single listener count.
export const dynamic = "force-dynamic";
const FETCH_TIMEOUT_MS = 4000;
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
// Best-effort listener-count extraction across the common provider shapes
// (AzureCast nests under listeners.current/total; others expose num_listeners,
// listeners, unique_listeners, count, or a bare number).
function findCount(value: unknown, depth = 0): number | null {
if (depth > 4) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
return Number(value);
}
if (!isRecord(value)) return null;
const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"];
for (const key of keys) {
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
return Number(v);
}
}
for (const v of Object.values(value)) {
if (isRecord(v)) {
const found = findCount(v, depth + 1);
if (found !== null) return found;
}
}
return null;
}
export async function GET(_req: Request) {
let url: string | null = null;
try {
url = (await siteSettings.get("radio_listeners_api_url", "")) || null;
} catch {
url = null;
}
// Not configured — no listener data available.
if (!url) {
return apiJson({ listeners: null });
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
cache: "no-store",
headers: { accept: "application/json, text/plain, */*" },
});
const raw = (await res.text()).trim();
if (raw === "") {
return apiJson({ listeners: null });
}
let parsed: unknown = raw;
try {
parsed = JSON.parse(raw);
} catch {
// leave as raw string; findCount handles numeric strings.
}
return apiJson({ listeners: findCount(parsed) });
} catch (e) {
const aborted = e instanceof Error && e.name === "AbortError";
return apiJson({
listeners: null,
error: aborted
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
: "Fetch failed",
});
} finally {
clearTimeout(timer);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { apiJson } from "@/lib/api";
import { siteSettings } from "@/lib/services/site-settings";
// Proxy for the configured radio "now playing" provider. The provider URL is
// stored in radio_now_playing_api_url; we fetch it server-side (never exposing
// the URL or any provider key to the browser) with a short, hard timeout.
export const dynamic = "force-dynamic";
const FETCH_TIMEOUT_MS = 4000;
export async function GET(_req: Request) {
let url: string | null = null;
try {
url = (await siteSettings.get("radio_now_playing_api_url", "")) || null;
} catch {
url = null;
}
// Not configured — there is nothing to play.
if (!url) {
return apiJson({ nowPlaying: null });
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
cache: "no-store",
headers: { accept: "application/json, text/plain, */*" },
});
const raw = (await res.text()).trim();
if (raw === "") {
return apiJson({ nowPlaying: null });
}
// Return parsed JSON when the provider speaks JSON, else the raw text body.
try {
return apiJson(JSON.parse(raw));
} catch {
return apiJson({ nowPlaying: raw.slice(0, 2000) });
}
} catch (e) {
const aborted = e instanceof Error && e.name === "AbortError";
return apiJson({
error: aborted
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
: "Fetch failed",
});
} finally {
clearTimeout(timer);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma";
// Latest 50 radio shouts with their author's username/look resolved. Mirrors the
// query behind the public /radio/shouts page (radio_shouts ordered by created_at
// desc, then joined to users by user_id).
export const dynamic = "force-dynamic";
export async function GET(_req: Request) {
try {
const shouts = await prisma.radioShouts.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
// Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt
// while users.id is an Int, so narrow to Number for the lookup.
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
const authors = authorIds.length
? await prisma.user.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true, look: true },
})
: [];
const authorById = new Map(authors.map((a) => [a.id, a]));
const data = shouts.map((s) => {
const author = authorById.get(Number(s.userId));
return {
id: s.id,
userId: s.userId,
username: author?.username ?? null,
look: author?.look ?? null,
message: s.message,
createdAt: s.createdAt,
};
});
return apiJson({ shouts: data });
} catch {
// DB unavailable — serve an empty list rather than a 500.
return apiJson({ shouts: [] }, { status: 200 });
}
}