Add security middleware, audit log, alerts, PayPal, cron, radio + apps
Security (launch blockers): - src/middleware.ts (edge): forwards x-pathname + real client IP. - access-guard.ts (Node, from root layout): routes non-staff to /maintenance when maintenance mode is on, banned users to /banned. New /banned + /maintenance pages (the consumers the admin toggle was missing). Admin layout enforces force_staff_2fa before /admin. - staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions. Infra (parallel agents): alert service (alert_logs + Discord embed + email), PayPal top-up (create/capture API routes + /shop/topup), cron worker (scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check, bans-cleanup), social connections page, admin radio settings/banners/ranks. Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways, apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav link added. .env.example documents the new optional vars. (radio song-requests dropped: its table is a stub in AtomCMS — columns added by un-modeled alter-migrations.) Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
This commit is contained in:
1 parent
e19debb795
commit
22d53d0e9c
40 files changed
+3781
-6
No files matched your search
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Standalone cron worker for AtomCMS-Next.
|
||||
*
|
||||
* Replaces Laravel's scheduler (app/Console/Kernel.php + queue:work) with a
|
||||
* single long-running process driven by `croner`. Run it OUTSIDE the Next.js
|
||||
* request lifecycle (e.g. a separate container / pm2 process):
|
||||
*
|
||||
* pnpm jobs:worker # -> tsx scripts/jobs-worker.ts
|
||||
*
|
||||
* Each job body is wrapped in its own try/catch so one failing tick never
|
||||
* tears down the scheduler; a thrown error or a falsy result is logged and the
|
||||
* worker keeps ticking. Croner runs ticks serially per-job (protect: true) so a
|
||||
* slow run can't overlap itself.
|
||||
*
|
||||
* DEPENDENCIES THIS FILE EXPECTS (orchestrator must provide them — noted here
|
||||
* so the build isn't silently broken):
|
||||
* - `croner` package (not yet in package.json): `pnpm add croner`.
|
||||
* - `@/lib/services/alert` exporting `alert.emulatorOffline()` — a thin
|
||||
* notifier (email/Discord) for emulator-down events. Not present yet; this
|
||||
* worker is the first consumer.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { Cron } from "croner";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { emulatorOffline } from "@/lib/services/alert";
|
||||
|
||||
// --- small logging helper (timestamped, namespaced) ------------------------
|
||||
|
||||
function log(scope: string, msg: string, ...rest: unknown[]): void {
|
||||
console.log(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, ...rest);
|
||||
}
|
||||
function logErr(scope: string, msg: string, e: unknown): void {
|
||||
console.error(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// --- (a) emulator health ping — every minute -------------------------------
|
||||
//
|
||||
// Our RconClient has no `isConnected`: the TCP transport is fire-and-forget and
|
||||
// resolves `false` on a dead/unreachable socket (it never throws for that). So
|
||||
// we treat BOTH a thrown error AND a falsy `send()` result as "offline" and
|
||||
// fire the alert. `data: null` matches the no-payload command shape AtomCMS
|
||||
// uses for keep-alive style commands.
|
||||
|
||||
async function pingEmulator(): Promise<void> {
|
||||
let online = false;
|
||||
try {
|
||||
online = await rcon.send("ping", null);
|
||||
} catch (e) {
|
||||
logErr("ping", "rcon.send threw", e);
|
||||
online = false;
|
||||
}
|
||||
|
||||
if (online) {
|
||||
log("ping", "emulator reachable");
|
||||
return;
|
||||
}
|
||||
|
||||
log("ping", "emulator unreachable — raising offline alert");
|
||||
try {
|
||||
await emulatorOffline();
|
||||
} catch (e) {
|
||||
// Never let the alert channel failing crash the tick.
|
||||
logErr("ping", "emulatorOffline failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (b) maintenance:check — every minute ----------------------------------
|
||||
//
|
||||
// website_maintenance_tasks has no scheduled-time column (id, userId, task,
|
||||
// completed, timestamps), so "scheduled maintenance" == at least one row with
|
||||
// completed = false. When such a task exists we flip the `maintenance_enabled`
|
||||
// website_setting on; otherwise we clear it. The setting value is the canonical
|
||||
// '1' / '0' string the rest of the CMS reads (see SiteSettings.getBool).
|
||||
//
|
||||
// If the model isn't present in the running schema (older DB), the prisma call
|
||||
// throws and we skip — the catch keeps the worker alive.
|
||||
|
||||
async function maintenanceCheck(): Promise<void> {
|
||||
let pending = 0;
|
||||
try {
|
||||
pending = await prisma.websiteMaintenanceTasks.count({
|
||||
where: { completed: false },
|
||||
});
|
||||
} catch (e) {
|
||||
// Table absent / DB unreachable — skip this tick rather than thrashing.
|
||||
logErr("maintenance", "could not read website_maintenance_tasks — skipping", e);
|
||||
return;
|
||||
}
|
||||
|
||||
const desired = pending > 0 ? "1" : "0";
|
||||
|
||||
try {
|
||||
const current = await prisma.websiteSetting.findUnique({
|
||||
where: { key: "maintenance_enabled" },
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
if (current?.value === desired) {
|
||||
log("maintenance", `no change (maintenance_enabled=${desired}, pending=${pending})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "maintenance_enabled" },
|
||||
update: { value: desired },
|
||||
create: { key: "maintenance_enabled", value: desired, comment: "Toggled by jobs-worker" },
|
||||
});
|
||||
log("maintenance", `maintenance_enabled -> ${desired} (pending tasks: ${pending})`);
|
||||
} catch (e) {
|
||||
logErr("maintenance", "failed to toggle maintenance_enabled", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (c) bans cleanup — hourly ---------------------------------------------
|
||||
//
|
||||
// Bans expire purely by the query-time `ban_expire > now()` filter applied
|
||||
// wherever bans are read, so there's nothing to delete on a schedule. We keep
|
||||
// the hourly tick for observability (and an obvious hook if a future hard-purge
|
||||
// is ever wanted).
|
||||
|
||||
async function bansCleanup(): Promise<void> {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const active = await prisma.ban.count({ where: { banExpire: { gt: now } } });
|
||||
log("bans", `cleanup is automatic via ban_expire>now filter — ${active} active ban(s), nothing to purge`);
|
||||
} catch (e) {
|
||||
logErr("bans", "count failed (non-fatal)", 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),
|
||||
];
|
||||
|
||||
log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`);
|
||||
|
||||
// Run once immediately on boot so we don't wait up to a minute for first signal.
|
||||
void pingEmulator();
|
||||
void maintenanceCheck();
|
||||
|
||||
// --- graceful shutdown -----------------------------------------------------
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
log("worker", `received ${signal} — stopping jobs and disconnecting`);
|
||||
for (const j of jobs) j.stop();
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch {
|
||||
// ignore — we're exiting anyway
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Don't let an unexpected async throw kill the whole worker.
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logErr("worker", "unhandledRejection", reason);
|
||||
});
|
||||
Reference in new issue
Block a user