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:
1 parent
5a4b6f27e9
commit
80f591a343
29 files changed
+1697
-7
No files matched your search
+120
-1
@@ -22,8 +22,10 @@
|
||||
import "dotenv/config";
|
||||
import { Cron } from "croner";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { emulatorOffline } from "@/lib/services/alert";
|
||||
import { fetchNowPlaying } from "@/lib/services/radio";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// --- small logging helper (timestamped, namespaced) ------------------------
|
||||
|
||||
@@ -129,12 +131,129 @@ async function bansCleanup(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- (d) radio: record song plays — every 30s ------------------------------
|
||||
//
|
||||
// Polls the configured now-playing endpoint (AtomCMS radio:record-songs). On a
|
||||
// track CHANGE it appends a row to radio_song_plays so the public history +
|
||||
// admin pages have data. De-dups against the most recent recorded play.
|
||||
|
||||
let lastRecordedTitle: string | null = null;
|
||||
|
||||
async function recordSongPlay(): Promise<void> {
|
||||
let np: Awaited<ReturnType<typeof fetchNowPlaying>>;
|
||||
try {
|
||||
np = await fetchNowPlaying();
|
||||
} catch (e) {
|
||||
logErr("radio", "now-playing fetch threw", e);
|
||||
return;
|
||||
}
|
||||
if (!np || !np.title) return;
|
||||
if (np.title === lastRecordedTitle) return;
|
||||
|
||||
try {
|
||||
const latest = await prisma.radioSongPlays.findFirst({
|
||||
orderBy: { id: "desc" },
|
||||
select: { title: true },
|
||||
});
|
||||
if (latest?.title === np.title) {
|
||||
lastRecordedTitle = np.title;
|
||||
return;
|
||||
}
|
||||
await prisma.radioSongPlays.create({
|
||||
data: { title: np.title, artist: np.artist, playedAt: new Date(), createdAt: new Date() },
|
||||
});
|
||||
lastRecordedTitle = np.title;
|
||||
log("radio", `recorded play: ${np.artist ? `${np.artist} - ` : ""}${np.title}`);
|
||||
} catch (e) {
|
||||
logErr("radio", "could not record song play", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (e) radio: auto-DJ rotation — every minute ----------------------------
|
||||
//
|
||||
// When no live DJ is broadcasting (radio_current_dj_id empty) and auto-DJ is
|
||||
// enabled, advances the radio_auto_dj_playlist by sort_order and writes the
|
||||
// current track to the radio_now_playing setting (read by the player/API).
|
||||
|
||||
async function autoDj(): Promise<void> {
|
||||
try {
|
||||
if (!(await siteSettings.getBool("radio_auto_dj_enabled", false))) return;
|
||||
const liveDj = (await siteSettings.get("radio_current_dj_id", "")) ?? "";
|
||||
if (liveDj) return; // a real DJ is on air — don't override
|
||||
|
||||
const tracks = await prisma.radioAutoDjPlaylist.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
|
||||
select: { id: true, title: true, artist: true, playCount: true },
|
||||
});
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
// Pick the least-recently-played (lowest playCount, then lowest id).
|
||||
const next = tracks.slice().sort((a, b) => a.playCount - b.playCount || Number(a.id - b.id))[0];
|
||||
await prisma.radioAutoDjPlaylist.update({
|
||||
where: { id: next.id },
|
||||
data: { playCount: { increment: 1 }, lastPlayedAt: new Date() },
|
||||
});
|
||||
const label = `${next.artist ? `${next.artist} - ` : ""}${next.title}`;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "radio_now_playing" },
|
||||
update: { value: label },
|
||||
create: { key: "radio_now_playing", value: label, comment: "Auto-DJ now playing" },
|
||||
});
|
||||
log("radio", `auto-DJ now playing: ${label}`);
|
||||
} catch (e) {
|
||||
logErr("radio", "auto-DJ tick failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (f) github: update check — hourly -------------------------------------
|
||||
//
|
||||
// Compares the latest commit on the configured GitHub repo against the last one
|
||||
// seen, and stores update_available + update_latest_sha settings the admin
|
||||
// dashboard can surface. No-ops unless github_repo (owner/repo) is set.
|
||||
|
||||
async function githubUpdateCheck(): Promise<void> {
|
||||
try {
|
||||
const repo = (await siteSettings.get("github_repo", "")) ?? "";
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) return;
|
||||
const branch = (await siteSettings.get("github_branch", "main")) ?? "main";
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}/commits/${branch}`, {
|
||||
headers: { accept: "application/vnd.github+json", "user-agent": "atomcms-next" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { sha?: string };
|
||||
const sha = data?.sha;
|
||||
if (!sha) return;
|
||||
|
||||
const known = (await siteSettings.get("update_current_sha", "")) ?? "";
|
||||
const available = known ? known !== sha ? "1" : "0" : "0";
|
||||
for (const [key, value] of [
|
||||
["update_latest_sha", sha],
|
||||
["update_available", available],
|
||||
] as const) {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: "GitHub update check" },
|
||||
});
|
||||
}
|
||||
log("github", `latest ${sha.slice(0, 7)} (update ${available === "1" ? "AVAILABLE" : "none"})`);
|
||||
} catch (e) {
|
||||
logErr("github", "update check failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- scheduler wiring ------------------------------------------------------
|
||||
|
||||
const jobs: Cron[] = [
|
||||
new Cron("* * * * *", { name: "emulator-ping", protect: true }, pingEmulator),
|
||||
new Cron("* * * * *", { name: "maintenance-check", protect: true }, maintenanceCheck),
|
||||
new Cron("0 * * * *", { name: "bans-cleanup", protect: true }, bansCleanup),
|
||||
new Cron("*/30 * * * * *", { name: "radio-record-songs", protect: true }, recordSongPlay),
|
||||
new Cron("* * * * *", { name: "radio-auto-dj", protect: true }, autoDj),
|
||||
new Cron("0 * * * *", { name: "github-update-check", protect: true }, githubUpdateCheck),
|
||||
];
|
||||
|
||||
log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`);
|
||||
|
||||
+31
-6
@@ -1,11 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { sendVerification } from "@/actions/email-verify";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { checkVpn } from "@/lib/services/ip-lookup";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const USERNAME_RE = /^[A-Za-z0-9_\-=?!@:.,]{3,25}$/;
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
@@ -17,15 +19,41 @@ export async function register(formData: FormData): Promise<void> {
|
||||
const mail = String(formData.get("mail") ?? "").trim().toLowerCase();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
const ip = await clientIp();
|
||||
|
||||
let error: string | null = null;
|
||||
if (!USERNAME_RE.test(username)) error = "Username must be 3-25 valid characters";
|
||||
else if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
else if (!EMAIL_RE.test(mail)) error = "Enter a valid email address";
|
||||
|
||||
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
|
||||
if (!error && !rateLimit(`register:${ip}`, 5, 10 * 60_000).ok) {
|
||||
error = "Too many sign-up attempts. Please wait a few minutes and try again.";
|
||||
}
|
||||
|
||||
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
|
||||
if (!error) {
|
||||
const limit = rateLimit(`register:${await clientIp()}`, 5, 10 * 60_000);
|
||||
if (!limit.ok) error = "Too many sign-up attempts. Please wait a few minutes and try again.";
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "");
|
||||
if (!(await verifyCaptcha(token, ip))) error = "Captcha verification failed. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if (!error && (await checkVpn(ip)).blocked) {
|
||||
error =
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed.";
|
||||
}
|
||||
|
||||
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
||||
if (!error) {
|
||||
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
|
||||
if (max > 0) {
|
||||
const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0);
|
||||
if (count >= max) error = "You have reached the maximum number of accounts for your connection.";
|
||||
}
|
||||
}
|
||||
|
||||
// Uniqueness check (kept out of the success path's try so NEXT_REDIRECT propagates).
|
||||
@@ -42,9 +70,6 @@ export async function register(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
const h = await headers();
|
||||
const ip =
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
const created = await prisma.user.create({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/articles/:slug — single website_article by slug, including the
|
||||
* fullStory body. Returns { error } (404) when the slug is unknown.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
try {
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
fullStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return apiJson({ error: "Article not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return apiJson({ data: article });
|
||||
} catch {
|
||||
// DB unavailable — treat as not found rather than a 500.
|
||||
return apiJson({ error: "Article not found" }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/articles — paginated list of website_articles, newest first.
|
||||
* Mirrors the /news page query (prisma.websiteArticles). Returns list cards
|
||||
* (shortStory only, never fullStory) plus pagination metadata.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
try {
|
||||
const [total, articles] = await Promise.all([
|
||||
prisma.websiteArticles.count(),
|
||||
prisma.websiteArticles.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data: articles,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/home — combined landing payload: the latest 4 website_articles and
|
||||
* the current online player count (users.online === "1"). Mirrors the queries
|
||||
* used by the public pages and the admin dashboard.
|
||||
*/
|
||||
export async function GET(_req: Request) {
|
||||
let articles: unknown[] = [];
|
||||
let online = 0;
|
||||
let hotelName = "Atom";
|
||||
|
||||
try {
|
||||
[articles, online, hotelName] = await Promise.all([
|
||||
prisma.websiteArticles.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
shortStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
}),
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
siteSettings.get("hotel_name", "Atom").then((v) => v ?? "Atom"),
|
||||
]);
|
||||
|
||||
return apiJson({ articles, online, hotelName });
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson({ articles: [], online: 0, hotelName }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Public REST API — the currently signed-in user.
|
||||
//
|
||||
// Reads the NextAuth session, then re-queries prisma.user by the session id to
|
||||
// return a safe field set (never password / auth_ticket / 2FA secrets / pincode
|
||||
// / mail). Returns { user: null } when unauthenticated or on DB failure.
|
||||
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
const session = await auth();
|
||||
const id = session?.user?.id ? Number(session.user.id) : null;
|
||||
if (!id || Number.isNaN(id)) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
look: true,
|
||||
motto: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
gender: true,
|
||||
online: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
|
||||
return apiJson({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
look: user.look,
|
||||
motto: user.motto,
|
||||
rank: user.rank,
|
||||
credits: user.credits,
|
||||
pixels: user.pixels,
|
||||
points: user.points,
|
||||
gender: user.gender,
|
||||
online: user.online === "1",
|
||||
accountCreated: user.accountCreated,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Public REST API — count of currently-online users.
|
||||
//
|
||||
// `online` is the emulator's string flag "1" / "0" (see User model). Returns
|
||||
// { count: 0 } (never 500) on DB failure.
|
||||
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const count = await prisma.user.count({
|
||||
where: { online: "1" },
|
||||
});
|
||||
|
||||
return apiJson({ count });
|
||||
} catch {
|
||||
return apiJson({ count: 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Public REST API — list of currently-online users (username + look only).
|
||||
//
|
||||
// `online` is stored by the emulator as the string "1" / "0" (see User model in
|
||||
// prisma/schema.prisma). Capped at 100 rows. Returns empty data (never 500) on
|
||||
// DB failure.
|
||||
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { online: "1" },
|
||||
select: { username: true, look: true },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return apiJson({ users });
|
||||
} catch {
|
||||
return apiJson({ users: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/photos — recent community photos (camera_web), newest first.
|
||||
* Mirrors the /photos page query (prisma.cameraWeb). Returns id, userId, url
|
||||
* and timestamp plus pagination metadata.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
try {
|
||||
const [total, photos] = await Promise.all([
|
||||
prisma.cameraWeb.count(),
|
||||
prisma.cameraWeb.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
url: true,
|
||||
timestamp: true,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data: photos,
|
||||
meta: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — return an empty payload instead of a 500.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Public website settings as a flat { key: value } map. Secrets are stripped so
|
||||
// this can be served to the game client / external integrations.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Any key containing one of these tokens is considered sensitive and never
|
||||
// exposed through the public API (mirrors AtomCMS's public settings filtering).
|
||||
const SENSITIVE = ["secret", "api_key", "password", "token", "webhook"];
|
||||
|
||||
function isSensitive(key: string): boolean {
|
||||
const k = key.toLowerCase();
|
||||
return SENSITIVE.some((needle) => k.includes(needle));
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
if (isSensitive(row.key)) continue;
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
|
||||
return apiJson(settings);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty settings map rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Public REST: website store categories (website_shop_categories).
|
||||
// AtomCMS JSON API parity — read-only list ordered by `order` then name.
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const data = await prisma.websiteShopCategories.findMany({
|
||||
orderBy: [{ order: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
order: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unreachable — never 500; return empty data.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Public REST: website store packages (website_shop_articles).
|
||||
// AtomCMS JSON API parity — read-only list of buyable packages, paginated and
|
||||
// ordered by `position` (then name), matching the admin /admin/shop query.
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
// Optional ?category=<id> filter (website_shop_category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
|
||||
try {
|
||||
where = { categoryId: BigInt(categoryRaw) };
|
||||
} catch {
|
||||
where = {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.websiteShopArticles.count({ where }),
|
||||
prisma.websiteShopArticles.findMany({
|
||||
where,
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
name: true,
|
||||
info: true,
|
||||
iconUrl: true,
|
||||
color: true,
|
||||
costs: true,
|
||||
giveRank: true,
|
||||
isGiftable: true,
|
||||
credits: true,
|
||||
duckets: true,
|
||||
diamonds: true,
|
||||
badges: true,
|
||||
furniture: true,
|
||||
position: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data,
|
||||
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Public REST API — staff list. Mirrors src/app/staff/page.tsx: users whose
|
||||
// rank is >= min_staff_rank (default 7). Only safe fields are exposed.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7;
|
||||
|
||||
const staff = await prisma.user.findMany({
|
||||
where: { rank: { gte: minStaffRank } },
|
||||
select: { username: true, look: true, rank: true, motto: true },
|
||||
orderBy: [{ rank: "desc" }, { username: "asc" }],
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return apiJson({ data: staff }, { status: 200 });
|
||||
} catch {
|
||||
// Never 500 — serve an empty payload if the DB is unreachable.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Public REST API — website teams (staff ranks). Mirrors src/app/staff/page.tsx:
|
||||
// visible ranks (hiddenRank=false) ordered by id.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteTeams.findMany({
|
||||
where: { hiddenRank: false },
|
||||
select: {
|
||||
id: true,
|
||||
rankName: true,
|
||||
badge: true,
|
||||
jobDescription: true,
|
||||
staffColor: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
// apiJson serialises BigInt ids → string automatically.
|
||||
return apiJson({ data: rows }, { status: 200 });
|
||||
} catch {
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Public REST API — single user profile by username.
|
||||
//
|
||||
// AtomCMS exposed read-only profile JSON for the game site / external
|
||||
// integrations. Mirrors the same safe field set selected by the public profile
|
||||
// page (src/app/u/[username]/page.tsx). Never exposes password / auth_ticket /
|
||||
// 2FA secrets / pincode / mail.
|
||||
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ username: string }> },
|
||||
) {
|
||||
const { username } = await params;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: {
|
||||
username: true,
|
||||
look: true,
|
||||
motto: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
online: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiJson({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return apiJson({
|
||||
username: user.username,
|
||||
look: user.look,
|
||||
motto: user.motto,
|
||||
rank: user.rank,
|
||||
credits: user.credits,
|
||||
online: user.online === "1",
|
||||
accountCreated: user.accountCreated,
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — behave as "not found" rather than 500.
|
||||
return apiJson({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Public REST: rare value categories (website_rare_value_categories).
|
||||
// AtomCMS JSON API parity — read-only list ordered by priority then name,
|
||||
// matching the admin /admin/rare-values query.
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const data = await prisma.websiteRareValueCategories.findMany({
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
badge: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unreachable — never 500; return empty data.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Public REST: rare furni trade values (website_rare_values).
|
||||
// AtomCMS JSON API parity — read-only catalog of rares with their credit /
|
||||
// currency values. Supports ?category=<id> filter; paginated, ordered by name.
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const { page, perPage, skip, take } = pagination(sp);
|
||||
|
||||
// Optional ?category=<id> filter (category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
|
||||
try {
|
||||
where = { categoryId: BigInt(categoryRaw) };
|
||||
} catch {
|
||||
where = {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.websiteRareValues.count({ where }),
|
||||
prisma.websiteRareValues.findMany({
|
||||
where,
|
||||
orderBy: { name: "asc" },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
itemId: true,
|
||||
name: true,
|
||||
creditValue: true,
|
||||
currencyValue: true,
|
||||
currencyType: true,
|
||||
furnitureIcon: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiJson({
|
||||
data,
|
||||
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||
});
|
||||
} catch {
|
||||
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||
return apiJson(
|
||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import Script from "next/script";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { register } from "@/actions/register";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RegisterPage({
|
||||
searchParams,
|
||||
@@ -10,6 +14,8 @@ export default async function RegisterPage({
|
||||
}) {
|
||||
const t = await getTranslations("pages.register");
|
||||
const { error } = await searchParams;
|
||||
const captcha = await captchaConfig();
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
@@ -28,10 +34,22 @@ export default async function RegisterPage({
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
||||
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
||||
) : null}
|
||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("register")}
|
||||
</button>
|
||||
</form>
|
||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
||||
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
|
||||
) : null}
|
||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
||||
<Script src="https://www.google.com/recaptcha/api.js" async defer />
|
||||
) : null}
|
||||
{error ? (
|
||||
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>
|
||||
{error}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
||||
* provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA
|
||||
* (the two AtomCMS offers, mutually exclusive). FAIL-OPEN by configuration: when
|
||||
* no provider/secret is set, registration isn't blocked; only an explicitly
|
||||
* configured provider with a failing/absent token blocks.
|
||||
*
|
||||
* Settings keys:
|
||||
* captcha_provider = "turnstile" | "recaptcha" | "none" (default none)
|
||||
* turnstile_secret / turnstile_site_key
|
||||
* recaptcha_secret / recaptcha_site_key
|
||||
*/
|
||||
export interface CaptchaConfig {
|
||||
provider: "turnstile" | "recaptcha" | "none";
|
||||
siteKey: string;
|
||||
/** Form field the widget writes the token into. */
|
||||
field: string;
|
||||
}
|
||||
|
||||
const TURNSTILE_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||
|
||||
/** Public config the register page needs to render the widget (no secrets). */
|
||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||
const provider = ((await siteSettings.get("captcha_provider", "none")) ?? "none").toLowerCase();
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
provider: "turnstile",
|
||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
||||
field: "cf-turnstile-response",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
provider: "recaptcha",
|
||||
siteKey: (await siteSettings.get("recaptcha_site_key", "")) ?? "",
|
||||
field: "g-recaptcha-response",
|
||||
};
|
||||
}
|
||||
return { provider: "none", siteKey: "", field: "" };
|
||||
}
|
||||
|
||||
/** Verify a submitted token. Returns true when allowed (incl. fail-open). */
|
||||
export async function verifyCaptcha(token: string | null, remoteIp?: string): Promise<boolean> {
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider === "none" || !cfg.siteKey) return true;
|
||||
|
||||
const secretKey = cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
if (!secret) return true; // configured but no secret — don't hard-block
|
||||
if (!token) return false;
|
||||
|
||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
||||
const body = new URLSearchParams({ secret, response: token });
|
||||
if (remoteIp) body.set("remoteip", remoteIp);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data?.success === true;
|
||||
} catch {
|
||||
// Network/timeout — fail-open so a provider outage can't lock out signups.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* VPN / proxy / Tor detection via an external provider, driven by
|
||||
* website_settings (configured at /admin/vpn). Mirrors AtomCMS's IP lookup used
|
||||
* to block registrations from anonymising IPs. FAIL-OPEN: any error, missing
|
||||
* config, or disabled toggle returns "not blocked".
|
||||
*
|
||||
* Settings keys: vpn_block_enabled ("1"), vpn_provider ("proxycheck" |
|
||||
* "ipqualityscore"), vpn_api_key.
|
||||
*/
|
||||
export interface IpVerdict {
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const PRIVATE_RE =
|
||||
/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd|localhost$|0\.0\.0\.0$)/i;
|
||||
|
||||
export async function checkVpn(ip: string): Promise<IpVerdict> {
|
||||
if (!ip || PRIVATE_RE.test(ip)) return { blocked: false };
|
||||
if (!(await siteSettings.getBool("vpn_block_enabled", false))) return { blocked: false };
|
||||
|
||||
const provider = ((await siteSettings.get("vpn_provider", "proxycheck")) ?? "proxycheck").toLowerCase();
|
||||
const apiKey = (await siteSettings.get("vpn_api_key", "")) ?? "";
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 4000);
|
||||
|
||||
if (provider === "ipqualityscore") {
|
||||
if (!apiKey) return { blocked: false };
|
||||
const res = await fetch(
|
||||
`https://ipqualityscore.com/api/json/ip/${encodeURIComponent(apiKey)}/${encodeURIComponent(ip)}`,
|
||||
{ signal: controller.signal, cache: "no-store" },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
const d = (await res.json()) as { proxy?: boolean; vpn?: boolean; tor?: boolean };
|
||||
if (d?.vpn || d?.tor || d?.proxy) return { blocked: true, reason: "VPN/proxy detected" };
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Default: proxycheck.io (works keyless at a low rate; key raises limits).
|
||||
const url = `https://proxycheck.io/v2/${encodeURIComponent(ip)}?vpn=1&risk=1${apiKey ? `&key=${encodeURIComponent(apiKey)}` : ""}`;
|
||||
const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||
clearTimeout(timer);
|
||||
const d = (await res.json()) as Record<string, { proxy?: string; type?: string }>;
|
||||
const entry = d?.[ip];
|
||||
if (entry?.proxy === "yes") return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
||||
return { blocked: false };
|
||||
} catch {
|
||||
return { blocked: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* Best-effort radio stream helpers, shared by the public API and the jobs
|
||||
* worker. They poll the now-playing / listeners endpoints configured in
|
||||
* website_settings (AzureCast / Icecast / Shoutcast all differ), parsing the
|
||||
* common shapes. Everything fails soft (returns null) on error/missing config.
|
||||
*/
|
||||
export interface NowPlaying {
|
||||
title: string;
|
||||
artist: string | null;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, ms = 4000): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text; // plain-text "Artist - Title" (Shoutcast/Icecast metadata)
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNowPlaying(d: unknown): NowPlaying | null {
|
||||
if (!d) return null;
|
||||
// AzureCast: { now_playing: { song: { title, artist } } }
|
||||
const azure = (d as { now_playing?: { song?: { title?: string; artist?: string; text?: string } } })
|
||||
.now_playing?.song;
|
||||
if (azure?.title || azure?.text) {
|
||||
return { title: azure.title ?? azure.text ?? "", artist: azure.artist ?? null };
|
||||
}
|
||||
// Generic JSON: { title, artist } or { songtitle }
|
||||
const generic = d as { title?: string; artist?: string; songtitle?: string };
|
||||
if (generic.title) return { title: generic.title, artist: generic.artist ?? null };
|
||||
if (generic.songtitle) {
|
||||
const [a, t] = generic.songtitle.split(" - ");
|
||||
return t ? { title: t.trim(), artist: a.trim() } : { title: generic.songtitle, artist: null };
|
||||
}
|
||||
// Plain text "Artist - Title"
|
||||
if (typeof d === "string" && d.trim()) {
|
||||
const parts = d.split(" - ");
|
||||
return parts.length > 1
|
||||
? { title: parts.slice(1).join(" - ").trim(), artist: parts[0].trim() }
|
||||
: { title: d.trim(), artist: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchNowPlaying(): Promise<NowPlaying | null> {
|
||||
const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
|
||||
if (!url) return null;
|
||||
return parseNowPlaying(await fetchJson(url));
|
||||
}
|
||||
|
||||
export async function fetchListeners(): Promise<number | null> {
|
||||
const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? "";
|
||||
if (!url) return null;
|
||||
const d = await fetchJson(url);
|
||||
if (d == null) return null;
|
||||
const obj = d as { listeners?: unknown; current_listeners?: unknown };
|
||||
const raw =
|
||||
typeof d === "number"
|
||||
? d
|
||||
: (obj.listeners ?? obj.current_listeners ?? (typeof d === "string" ? Number(d) : null));
|
||||
const n = Number(
|
||||
typeof raw === "object" && raw && "total" in (raw as Record<string, unknown>)
|
||||
? (raw as { total: unknown }).total
|
||||
: raw,
|
||||
);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
Reference in new issue
Block a user