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:
Simo committed 2026-06-28 15:10:19 +02:00
1 parent e19debb795
commit 22d53d0e9c
40 files changed
+3781 -6

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Radio config + today's schedule are read live from the DB / settings table.
export const dynamic = "force-dynamic";
// Sub-pages linked from the radio home hub.
const RADIO_LINKS: { label: string; href: string }[] = [
{ label: "Schedule", href: "/radio/schedule" },
{ label: "Shouts", href: "/radio/shouts" },
{ label: "Requests", href: "/radio/requests" },
{ label: "Apply as DJ", href: "/radio/apply" },
{ label: "Leaderboard", href: "/radio/leaderboard" },
];
// Canonical weekday order so today's slots come out chronologically and we can
// match the current day against whatever casing the `day` column stores.
const WEEKDAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// radio_schedules.start_time / end_time are stored as TIME and surface through
// Prisma as a Date with the time component on a 1970 epoch date — render just
// the HH:MM portion in UTC to avoid timezone drift.
function formatTime(d: Date | null | undefined): string {
if (!d) return "";
return d.toISOString().slice(11, 16);
}
export default async function RadioPage() {
// ── Radio config from website_settings (graceful fallbacks) ──
const [streamUrl, currentDj, currentSong] = await Promise.all([
siteSettings.get("radio_stream_url", ""),
siteSettings.get("radio_current_dj", ""),
siteSettings.get("radio_current_song", ""),
]);
// ── Today's schedule (isolated; a DB hiccup degrades to empty) ──
const today = WEEKDAYS[new Date().getUTCDay()] ?? "";
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
orderBy: { startTime: "asc" },
})
.catch(() => []);
const todaysSlots = schedules.filter(
(s) => s.day.toLowerCase() === today.toLowerCase(),
);
// Resolve DJ usernames for today's slots in one query. radio_schedules.user_id
// is an UnsignedBigInt while users.id is an Int, so narrow to Number for the
// lookup (DJ ids comfortably fit in a JS-safe integer).
const djIds = Array.from(new Set(todaysSlots.map((s) => Number(s.userId))));
const djs = djIds.length
? await prisma.user
.findMany({
where: { id: { in: djIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const djById = new Map(djs.map((d) => [d.id, d.username]));
return (
<main>
{/* ── Hero: live stream + now playing ─────────────────────── */}
<div className="hero">
<h1 style={{ marginTop: 0 }}>Radio</h1>
<p className="muted" style={{ marginTop: 0 }}>
Tune in to the hotel radio — live DJ sets, requests and shoutouts.
</p>
<div className="grid cols-2" style={{ marginTop: "1rem" }}>
<div>
<p style={{ margin: "0 0 0.25rem" }}>
<strong>Now playing</strong>
</p>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}>
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
{currentDj ? `On air: ${currentDj}` : "No DJ on air."}
</p>
</div>
<div style={{ display: "flex", alignItems: "center" }}>
{streamUrl ? (
<audio
controls
src={streamUrl}
style={{ width: "100%" }}
preload="none"
>
Your browser does not support the audio element.
</audio>
) : (
<p className="muted" style={{ margin: 0 }}>
The stream is currently offline.
</p>
)}
</div>
</div>
</div>
{/* ── Sub-page links ──────────────────────────────────────── */}
<section style={{ marginBottom: "1.5rem" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
{RADIO_LINKS.map((l) => (
<Link key={l.href} href={l.href} className="btn btn-outline">
{l.label}
</Link>
))}
</div>
</section>
{/* ── Today's schedule ────────────────────────────────────── */}
<section>
<h2>Today&rsquo;s schedule{today ? ` · ${today}` : ""}</h2>
{todaysSlots.length === 0 ? (
<p className="muted">No shows scheduled for today.</p>
) : (
<div className="card">
<table>
<thead>
<tr>
<th>Time</th>
<th>Show</th>
<th>DJ</th>
</tr>
</thead>
<tbody>
{todaysSlots.map((s) => {
const djName = djById.get(Number(s.userId));
return (
<tr key={String(s.id)}>
<td>
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
</td>
<td>
{s.showName || <span className="muted">Untitled show</span>}
</td>
<td>
{djName ? (
<Link href={`/u/${djName}`}>{djName}</Link>
) : (
<span className="muted">{`User #${s.userId}`}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}