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

+145
View File
@@ -0,0 +1,145 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { applyDj } from "@/actions/radio-apply";
export const dynamic = "force-dynamic";
export default async function RadioApplyPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// Surface the applicant's most recent application (if any) so they aren't
// left guessing. Isolated query: a DB hiccup degrades this to null, not 500.
const existing = await prisma.radioApplications
.findFirst({
where: { userId: BigInt(userId) },
orderBy: { id: "desc" },
select: { id: true, status: true, createdAt: true },
})
.catch(() => null);
const submittedDate = existing?.createdAt
? existing.createdAt.toISOString().slice(0, 10)
: null;
return (
<main>
<div className="hero">
<h1 style={{ margin: "0 0 0.25rem" }}>Apply to be a Radio DJ</h1>
<p className="muted" style={{ margin: 0 }}>
Tell us about yourself and why you&apos;d be a great fit for the airwaves.
</p>
</div>
{existing ? (
<div className="card" style={{ marginBottom: "1.25rem" }}>
<p style={{ margin: 0 }}>
<strong>Your latest application:</strong> {existing.status}
{submittedDate ? (
<span className="muted"> · submitted {submittedDate}</span>
) : null}
</p>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
You can submit again below if your details have changed.
</p>
</div>
) : null}
<form action={applyDj} className="card">
<div className="grid cols-2">
<div>
<label htmlFor="realName" className="muted">
Real name
</label>
<input
id="realName"
name="realName"
type="text"
required
maxLength={255}
placeholder="Your full name"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="age" className="muted">
Age
</label>
<input
id="age"
name="age"
type="number"
required
min={13}
max={120}
placeholder="18"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted">
Availability
</label>
<textarea
id="availability"
name="availability"
required
rows={3}
placeholder="Which days and times can you broadcast?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="musicStyle" className="muted">
Music style
</label>
<input
id="musicStyle"
name="musicStyle"
type="text"
maxLength={255}
placeholder="e.g. House, Pop, Hip-Hop"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="experience" className="muted">
Experience <span className="muted">(optional)</span>
</label>
<textarea
id="experience"
name="experience"
rows={3}
placeholder="Any prior DJ or hosting experience?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="motivation" className="muted">
Motivation
</label>
<textarea
id="motivation"
name="motivation"
required
rows={4}
placeholder="Why do you want to join the radio team?"
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
Submit application
</button>
</form>
</main>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
}
export default async function RadioContestDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Route param is a string; RadioContests.id is a BigInt. Guard against a
// non-numeric id before constructing the BigInt (would otherwise throw).
let contestId: bigint;
try {
contestId = BigInt(id);
} catch {
notFound();
}
const contest = await prisma.radioContests
.findUnique({ where: { id: contestId! } })
.catch(() => null);
if (!contest) notFound();
const contestIdStr = contest.id.toString();
return (
<main>
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
<Link href="/radio/contests">← Back to contests</Link>
</p>
<div className="card">
<h1 style={{ margin: "0 0 0.5rem" }}>Contest #{contestIdStr}</h1>
<table>
<tbody>
<tr>
<th>Created</th>
<td>{formatDate(contest.createdAt) || <span className="muted">—</span>}</td>
</tr>
<tr>
<th>Updated</th>
<td>{formatDate(contest.updatedAt) || <span className="muted">—</span>}</td>
</tr>
</tbody>
</table>
</div>
</main>
);
}
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function RadioContestsPage() {
// RadioContests.id is a BigInt — stringify before use in keys/routes.
const contests = await prisma.radioContests
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []);
return (
<main>
<div className="hero">
<h1 style={{ margin: 0 }}>Radio Contests</h1>
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
Tune in and take part in our latest on-air contests.
</p>
</div>
{contests.length === 0 ? (
<p className="muted">No contests yet.</p>
) : (
<div className="grid cols-2">
{contests.map((c) => {
const id = c.id.toString();
return (
<article key={id} className="card hover">
<h3 style={{ margin: "0 0 0.25rem" }}>
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link>
</h3>
<p className="muted" style={{ margin: 0 }}>
{formatDate(c.createdAt) || "Date unknown"}
</p>
</article>
);
})}
</div>
)}
</main>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
}
export default async function RadioGiveawayDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Route param is a string; RadioGiveaways.id is a BigInt. Guard against a
// non-numeric id before constructing the BigInt (would otherwise throw).
let giveawayId: bigint;
try {
giveawayId = BigInt(id);
} catch {
notFound();
}
const giveaway = await prisma.radioGiveaways
.findUnique({ where: { id: giveawayId! } })
.catch(() => null);
if (!giveaway) notFound();
const giveawayIdStr = giveaway.id.toString();
return (
<main>
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
<Link href="/radio/giveaways">← Back to giveaways</Link>
</p>
<div className="card">
<h1 style={{ margin: "0 0 0.5rem" }}>Giveaway #{giveawayIdStr}</h1>
<table>
<tbody>
<tr>
<th>Created</th>
<td>{formatDate(giveaway.createdAt) || <span className="muted">—</span>}</td>
</tr>
<tr>
<th>Updated</th>
<td>{formatDate(giveaway.updatedAt) || <span className="muted">—</span>}</td>
</tr>
</tbody>
</table>
</div>
</main>
);
}
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function RadioGiveawaysPage() {
// RadioGiveaways.id is a BigInt — stringify before use in keys/routes.
const giveaways = await prisma.radioGiveaways
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []);
return (
<main>
<div className="hero">
<h1 style={{ margin: 0 }}>Radio Giveaways</h1>
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
Listen in for your chance to win in our radio giveaways.
</p>
</div>
{giveaways.length === 0 ? (
<p className="muted">No giveaways yet.</p>
) : (
<div className="grid cols-2">
{giveaways.map((g) => {
const id = g.id.toString();
return (
<article key={id} className="card hover">
<h3 style={{ margin: "0 0 0.25rem" }}>
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link>
</h3>
<p className="muted" style={{ margin: 0 }}>
{formatDate(g.createdAt) || "Date unknown"}
</p>
</article>
);
})}
</div>
)}
</main>
);
}
+106
View File
@@ -0,0 +1,106 @@
import Link from "next/link";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// The radio_listener_points table in the live AtomCMS DB carries user_id +
// points columns, but the generated Prisma model is a stub (id/timestamps only),
// so it has no `points`/`userId` delegate fields. We therefore read it via
// $queryRaw, joining users for the username/look — the same approach used for
// the keyless/@@ignore'd emulator tables (see src/app/admin/logs). MySQL may
// return BigInt columns as bigint, so values are coerced defensively.
type LeaderRow = {
username: string;
look: string;
points: number | bigint;
};
async function loadRows(): Promise<{ username: string; look: string; points: number }[]> {
try {
const rows = await prisma.$queryRaw<LeaderRow[]>`
SELECT u.username AS username, u.look AS look, p.points AS points
FROM radio_listener_points p
INNER JOIN users u ON u.id = p.user_id
ORDER BY p.points DESC
LIMIT 50
`;
return rows.map((r) => ({
username: r.username,
look: r.look,
points: Number(r.points),
}));
} catch {
return [];
}
}
export default async function RadioLeaderboardPage() {
const [rows, imagerBase] = await Promise.all([
loadRows(),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
]);
return (
<main>
<h1>Radio Listener Leaderboard</h1>
<p className="muted" style={{ marginTop: 0 }}>
Top 50 listeners by points earned tuning in to the radio.
</p>
<div className="card">
{rows.length === 0 ? (
<p className="muted" style={{ margin: 0 }}>
No listener points to show yet.
</p>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3rem" }}>#</th>
<th style={{ width: "3.5rem" }}>Avatar</th>
<th>Listener</th>
<th style={{ textAlign: "right" }}>Points</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
size: "s",
headOnly: true,
});
return (
<tr key={`${row.username}-${i}`}>
<td style={{ fontWeight: 800 }}>{i + 1}</td>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${row.username} avatar`}
width={40}
height={40}
/>
</td>
<td>
<Link
href={`/u/${encodeURIComponent(row.username)}`}
style={{ fontWeight: 700 }}
>
{row.username}
</Link>
</td>
<td style={{ textAlign: "right", fontWeight: 800 }}>
{row.points.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</main>
);
}
+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>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// Weekly order so the table reads Monday → Sunday regardless of insert order.
// The `day` column stores a free-form string; we key case-insensitively.
const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] as const;
// `start_time` / `end_time` are MySQL TIME columns; Prisma hydrates them as a
// Date on the 1970-01-01 epoch. Pull just HH:MM in UTC (Time has no zone).
function formatTime(d: Date | null | undefined): string {
if (!d) return "";
return d.toISOString().slice(11, 16);
}
type Slot = {
id: string;
dj: string;
showName: string | null;
description: string | null;
start: string;
end: string;
};
export default async function RadioSchedulePage() {
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
orderBy: { startTime: "asc" },
})
.catch(() => []);
// Resolve DJ usernames. schedule.userId is BigInt; User.id is Int — convert.
const djIds = Array.from(new Set(schedules.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((u) => [u.id, u.username]));
// Bucket slots by normalised day name.
const byDay = new Map<string, Slot[]>();
for (const day of DAYS) byDay.set(day, []);
for (const s of schedules) {
const key =
DAYS.find((d) => d.toLowerCase() === s.day.trim().toLowerCase()) ?? s.day;
const slot: Slot = {
id: String(s.id),
dj: djById.get(Number(s.userId)) ?? `User #${s.userId}`,
showName: s.showName,
description: s.description,
start: formatTime(s.startTime),
end: formatTime(s.endTime),
};
const bucket = byDay.get(key);
if (bucket) bucket.push(slot);
else byDay.set(key, [slot]);
}
const hasAny = schedules.length > 0;
return (
<main>
<h1>Radio Schedule</h1>
<p className="muted" style={{ marginTop: 0 }}>
Our weekly line-up of DJs and shows.
</p>
{!hasAny ? (
<p className="muted">No shows scheduled yet.</p>
) : (
<div className="card">
<table>
<thead>
<tr>
<th>Day</th>
<th>Time</th>
<th>DJ</th>
<th>Show</th>
</tr>
</thead>
<tbody>
{DAYS.map((day) => {
const slots = byDay.get(day) ?? [];
if (slots.length === 0) {
return (
<tr key={day}>
<td>
<strong>{day}</strong>
</td>
<td colSpan={3} className="muted">
No shows
</td>
</tr>
);
}
return slots.map((slot, i) => (
<tr key={slot.id}>
<td>{i === 0 ? <strong>{day}</strong> : ""}</td>
<td>
{slot.start}
{slot.end ? ` – ${slot.end}` : ""}
</td>
<td>{slot.dj}</td>
<td>
{slot.showName || <span className="muted">—</span>}
{slot.description ? (
<div className="muted">{slot.description}</div>
) : null}
</td>
</tr>
));
})}
</tbody>
</table>
</div>
)}
</main>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { postShout } from "@/actions/radio-shouts";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function RadioShoutsPage() {
const [shouts, imagerBase, session] = await Promise.all([
prisma.radioShouts
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
auth(),
]);
const isLoggedIn = Boolean(session?.user?.id);
// Resolve author usernames/looks. shout.userId is BigInt; User.id is Int.
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 },
})
.catch(() => [])
: [];
const authorById = new Map(authors.map((a) => [a.id, a]));
return (
<main>
<h1>Radio Shouts</h1>
<p className="muted" style={{ marginTop: 0 }}>
Shout-outs and requests from the community.
</p>
{isLoggedIn ? (
<form action={postShout} className="card" style={{ marginBottom: "1.5rem" }}>
{/* The author is taken from the session inside the action, never from
this form — no user id is submitted. */}
<label htmlFor="shout-message" className="muted">
Post a shout
</label>
<textarea
id="shout-message"
name="message"
required
maxLength={255}
rows={3}
placeholder="Send a shout to the DJ…"
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary">
Shout
</button>
</form>
) : (
<p className="muted">Log in to post a shout.</p>
)}
{shouts.length === 0 ? (
<p className="muted">No shouts yet. Be the first!</p>
) : (
<div className="grid">
{shouts.map((s) => {
const author = authorById.get(Number(s.userId));
const authorAvatar = author
? avatarImageUrl(imagerBase ?? "", author.look, {
size: "s",
headOnly: true,
})
: null;
return (
<div
key={String(s.id)}
className="card"
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
>
{authorAvatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
className="avatar"
src={authorAvatar}
alt={author?.username ?? "author"}
width={40}
height={40}
/>
) : null}
<div style={{ flex: 1 }}>
<p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
<span className="muted">{formatDate(s.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{s.message}</p>
</div>
</div>
);
})}
</div>
)}
</main>
);
}