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

@@ -0,0 +1,33 @@
import { siteSettings } from "@/lib/services/site-settings";
import RadioPlayer from "./radio-player";
/**
* Server-side guard for the radio player widget. Reads the radio_* settings on
* the server and only mounts the client <RadioPlayer> when the radio is enabled
* AND a stream URL is configured — so the widget's JS (and the polling it does)
* is never shipped to the browser while the radio is off.
*
* Mount this once in the public layout. It is intentionally tiny: the live
* config the player actually plays is refreshed client-side from
* /api/radio/config, which lets staff toggle the radio without a redeploy.
*/
export default async function RadioPlayerGate() {
let enabled = false;
let streamUrl = "";
try {
const [enabledRaw, urlRaw] = await Promise.all([
siteSettings.get("radio_enabled", "0"),
siteSettings.get("radio_stream_url", ""),
]);
const flag = (enabledRaw ?? "0").trim().toLowerCase();
enabled = flag === "1" || flag === "true";
streamUrl = (urlRaw ?? "").trim();
} catch {
// Settings unavailable — fail closed (no widget).
return null;
}
if (!enabled || !streamUrl) return null;
return <RadioPlayer />;
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Fixed bottom-right radio player widget — the Next.js port of AtomCMS's
* `radio-player.blade.php` (atom theme). A compact bar that streams the hotel
* radio via a hidden <audio> element, with play/pause, a volume slider, the
* current DJ / now-playing text, and a live listener count.
*
* It is a CLIENT component and must NOT import prisma / siteSettings — all data
* comes from the public JSON API:
* - GET /api/radio/config → { radio_enabled, radio_stream_url, radio_name, … }
* - GET /api/radio/now-playing → provider passthrough ({ nowPlaying } | AzureCast | { song, artist } …)
* - GET /api/radio/listeners → { listeners: number | null }
* These are polled every ~15s; the interval is cleared on unmount.
*
* If the radio is disabled or no stream URL is configured, it renders nothing.
* The server-side <RadioPlayerGate> already guards on the same settings so the
* widget JS isn't shipped when off — this in-component guard is belt-and-braces
* for the runtime config refresh.
*/
const POLL_MS = 15000;
type RadioConfig = {
enabled: boolean;
streamUrl: string;
name: string;
};
// ── Defensive parsing of the loosely-typed provider passthroughs ──────────────
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function asString(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : String(v);
}
function isTruthyFlag(v: unknown): boolean {
const s = asString(v).trim().toLowerCase();
return s === "1" || s === "true" || s === "yes" || s === "on";
}
/** Parse /api/radio/config (flat radio_* settings map) into the bits we need. */
function parseConfig(data: unknown): RadioConfig {
const c = isRecord(data) ? data : {};
// radio_enabled defaults to off when the key is absent.
const enabled = "radio_enabled" in c ? isTruthyFlag(c.radio_enabled) : false;
return {
enabled,
streamUrl: asString(c.radio_stream_url).trim(),
name: asString(c.radio_name).trim() || "Radio",
};
}
/**
* Extract a "now playing" line from the now-playing passthrough, covering the
* shapes the AtomCMS blade handled plus the AzureCast `now_playing.song.*`
* nesting our proxy may surface. Falls back to a generic label.
*/
function parseNowPlaying(data: unknown): string {
if (!isRecord(data)) {
const s = asString(data).trim();
return s || "Live radio";
}
const joinSongArtist = (song: unknown, artist: unknown): string | null => {
const s = asString(song).trim();
const a = asString(artist).trim();
if (s && a) return `${a} — ${s}`;
if (s) return s;
return null;
};
// Our proxy's raw-text fallback shape: { nowPlaying: "…" }.
const np = asString(data.nowPlaying).trim();
if (np) return np;
// { song, artist } / { title, artist } at the top level.
const top =
joinSongArtist(data.song, data.artist) ?? joinSongArtist(data.title, data.artist);
if (top) return top;
// AzureCast: { now_playing: { song: { title, artist, text } } }.
if (isRecord(data.now_playing)) {
const inner = data.now_playing;
if (isRecord(inner.song)) {
const s = inner.song;
const text = asString(s.text).trim();
if (text) return text;
const made = joinSongArtist(s.title, s.artist);
if (made) return made;
}
const made = joinSongArtist(inner.song, inner.artist) ?? joinSongArtist(inner.title, inner.artist);
if (made) return made;
}
return "Live radio";
}
/** Extract the current DJ / show name when the provider exposes one. */
function parseDj(data: unknown): string {
if (!isRecord(data)) return "";
const candidates: unknown[] = [data.dj, data.show_name, data.streamer, data.show];
if (isRecord(data.live)) {
candidates.push(data.live.streamer_name, data.live.streamer);
}
for (const c of candidates) {
if (isRecord(c)) {
const name = asString(c.username).trim() || asString(c.show_name).trim() || asString(c.name).trim();
if (name) return name;
} else {
const s = asString(c).trim();
if (s) return s;
}
}
return "";
}
/** Extract the listener count from /api/radio/listeners ({ listeners }). */
function parseListeners(data: unknown): number | null {
if (!isRecord(data)) return null;
const v = data.listeners;
if (typeof v === "number" && Number.isFinite(v)) return v;
return null;
}
export default function RadioPlayer() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState(50); // 0–100
const [nowPlaying, setNowPlaying] = useState("Loading…");
const [dj, setDj] = useState("");
const [listeners, setListeners] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Poll config + now-playing + listeners every POLL_MS; clear on unmount.
useEffect(() => {
let cancelled = false;
async function getJson(url: string): Promise<unknown | null> {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function refresh() {
const [cfg, np, lst] = await Promise.all([
getJson("/api/radio/config"),
getJson("/api/radio/now-playing"),
getJson("/api/radio/listeners"),
]);
if (cancelled) return;
if (cfg !== null) setConfig(parseConfig(cfg));
if (np !== null) {
setNowPlaying(parseNowPlaying(np));
setDj(parseDj(np));
}
if (lst !== null) setListeners(parseListeners(lst));
}
void refresh();
const id = setInterval(() => void refresh(), POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
// Keep the <audio> volume in sync with the slider.
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume / 100;
}, [volume]);
const toggle = useCallback(async () => {
const audio = audioRef.current;
const streamUrl = config?.streamUrl;
if (!audio || !streamUrl) return;
if (playing) {
audio.pause();
setPlaying(false);
return;
}
setError(null);
// (Re)point at the live stream each time we start so we never replay a
// buffered segment of a continuous broadcast.
if (audio.src !== streamUrl) audio.src = streamUrl;
audio.volume = volume / 100;
try {
await audio.play();
setPlaying(true);
} catch {
setError("Couldn't start playback. Check your browser settings.");
setPlaying(false);
}
}, [config?.streamUrl, playing, volume]);
// Disabled or unconfigured → render nothing (and ship no widget UI).
if (!config || !config.enabled || !config.streamUrl) return null;
const surface = "var(--color-surface)";
const primary = "var(--color-primary)";
const text = "var(--color-text)";
const muted = "var(--color-text-muted)";
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
return (
<section
aria-label={`${config.name} player`}
style={{
position: "fixed",
bottom: "1rem",
right: "1rem",
zIndex: 9999,
width: "min(20rem, calc(100vw - 2rem))",
background: surface,
color: text,
border: `1px solid ${border}`,
borderRadius: "14px",
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)",
padding: "0.7rem 0.85rem",
fontFamily: "inherit",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<button
type="button"
onClick={() => void toggle()}
aria-label={playing ? "Pause radio" : "Play radio"}
aria-pressed={playing}
style={{
flex: "none",
width: "38px",
height: "38px",
borderRadius: "999px",
display: "grid",
placeItems: "center",
border: "none",
cursor: "pointer",
background: primary,
color: "var(--button-text-color)",
fontSize: "1rem",
lineHeight: 1,
}}
>
<span aria-hidden>{playing ? "⏸" : "▶"}</span>
</button>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontSize: "0.7rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: primary,
}}
>
<span
aria-hidden
style={{
width: "8px",
height: "8px",
borderRadius: "999px",
background: playing ? "#16a34a" : muted,
boxShadow: playing
? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)"
: "none",
}}
/>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{config.name}
{dj ? ` · ${dj}` : ""}
</span>
</div>
<div
title={nowPlaying}
style={{
marginTop: "0.1rem",
fontSize: "0.8rem",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{nowPlaying}
</div>
</div>
<span
aria-label={
listeners != null ? `${listeners.toLocaleString()} listeners` : "Listeners"
}
style={{
flex: "none",
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.75rem",
fontWeight: 700,
color: muted,
}}
>
<span aria-hidden>👥</span>
<span aria-hidden>{listeners != null ? listeners.toLocaleString() : "--"}</span>
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.55rem" }}>
<span aria-hidden style={{ fontSize: "0.85rem", color: muted, lineHeight: 1 }}>
🔈
</span>
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
aria-label="Volume"
style={{
flex: 1,
height: "4px",
cursor: "pointer",
accentColor: "var(--color-primary)",
padding: 0,
}}
/>
</div>
{error ? (
<p
role="alert"
aria-live="polite"
style={{ margin: "0.45rem 0 0", fontSize: "0.72rem", color: "var(--color-danger)" }}
>
{error}
</p>
) : null}
{/* Hidden stream element. preload="none" so nothing loads until the user
hits play (and browsers block autoplay with sound regardless). */}
<audio ref={audioRef} preload="none" />
</section>
);
}