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(", ")}`);
|
||||
|
||||
Reference in new issue
Block a user