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

+33
View File
@@ -6,6 +6,39 @@ DATABASE_URL=mysql://user:[email protected]:3306/atomcms
# Optional pool tuning (defaults shown)
DATABASE_POOL_SIZE=40
DATABASE_IDLE_TIMEOUT_MS=300000
DATABASE_CONNECT_TIMEOUT_MS=10000
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
HOTEL_NAME=Atom
APP_URL=http://localhost:3000
# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets
AUTH_SECRET=
APP_KEY=
CONVERT_PASSWORDS=false
# RCON link to the Arcturus emulator
RCON_HOST=127.0.0.1
RCON_PORT=3001
# Optional OAuth (enabled when both id+secret are set)
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Optional SMTP (password reset / alert emails)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Optional alerting (jobs worker / alert service)
DISCORD_WEBHOOK_URL=
ALERT_EMAIL=
# Optional PayPal top-up (sandbox by default)
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
+3 -1
View File
@@ -14,12 +14,14 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status"
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"jobs:worker": "tsx scripts/jobs-worker.ts"
},
"dependencies": {
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.2",
"croner": "^10.0.1",
"hash-wasm": "^4.12.0",
"next": "^16.2.9",
"next-auth": "5.0.0-beta.31",
+9
View File
@@ -17,6 +17,9 @@ importers:
bcryptjs:
specifier: ^3.0.2
version: 3.0.3
croner:
specifier: ^10.0.1
version: 10.0.1
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
@@ -1483,6 +1486,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
[email protected]:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
[email protected]:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3198,6 +3205,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
path-key: 3.1.1
+165
View File
@@ -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);
});
+17 -2
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
type BanType = "account" | "ip" | "machine" | "super";
const BAN_TYPES: ReadonlySet<string> = new Set(["account", "ip", "machine", "super"]);
@@ -40,12 +41,26 @@ export async function createBan(formData: FormData): Promise<void> {
});
if (user) await rcon.disconnectUser(userId, user.username);
await logStaffActivity({
staffId: staff.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
targetType: "user",
targetId: userId,
});
revalidatePath("/admin/bans");
}
export async function liftBan(formData: FormData): Promise<void> {
await requireStaff();
const staff = await requireStaff();
const id = Number(formData.get("id"));
if (id > 0) await prisma.ban.delete({ where: { id } });
if (id > 0) {
await prisma.ban.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "ban_lift",
description: `Lifted ban #${id}`,
});
}
revalidatePath("/admin/bans");
}
+233
View File
@@ -0,0 +1,233 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireStaff } from '@/lib/admin/guard';
import { prisma } from '@/lib/prisma';
import { siteSettings } from '@/lib/services/site-settings';
// ── Helpers ────────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
function str(raw: FormDataEntryValue | null): string {
return typeof raw === 'string' ? raw : '';
}
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase();
return v === '1' || v === 'true' || v === 'on';
}
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
/**
* Upsert one radio_* website_settings key. Mirrors AtomCMS's
* RadioSettings Filament page (key/value rows in website_settings). Busts the
* siteSettings cache so the public radio pages pick the change up immediately.
*/
export async function saveRadioSetting(formData: FormData): Promise<void> {
await requireStaff();
const key = str(formData.get('key')).trim().slice(0, 255);
const value = str(formData.get('value'));
const comment = str(formData.get('comment')).trim().slice(0, 255);
if (!key) return;
try {
await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: comment || null },
});
siteSettings.reload();
} catch {
// DB unavailable — fail soft so the action does not throw.
}
revalidatePath('/admin/radio/settings');
}
/**
* Bulk-save every radio_* field submitted by the settings form in one pass.
* The form posts a hidden `__keys` field listing the keys it rendered so we
* only touch those (and never wipe unrelated settings).
*/
export async function saveRadioSettings(formData: FormData): Promise<void> {
await requireStaff();
const keysRaw = str(formData.get('__keys'));
const keys = keysRaw
.split(',')
.map((k) => k.trim())
.filter((k) => k.startsWith('radio_') || k.startsWith('auto_dj_'));
if (keys.length === 0) return;
try {
await prisma.$transaction(
keys.map((key) => {
const value = str(formData.get(key));
return prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment: null },
});
}),
);
siteSettings.reload();
} catch {
// Fail soft.
}
revalidatePath('/admin/radio/settings');
}
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
export async function createRadioBanner(formData: FormData): Promise<void> {
const staff = await requireStaff();
const imagePath = str(formData.get('imagePath')).trim().slice(0, 255);
if (!imagePath) return;
const title = str(formData.get('title')).trim().slice(0, 255);
const description = str(formData.get('description')).trim();
const sortOrderNum = Number(str(formData.get('sortOrder')));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
const isActive = bool(formData.get('isActive'));
const now = new Date();
try {
await prisma.radioBanners.create({
data: {
userId: BigInt(staff.id),
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
},
});
} catch {
// Fail soft.
}
revalidatePath('/admin/radio/banners');
}
export async function updateRadioBanner(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
const imagePath = str(formData.get('imagePath')).trim().slice(0, 255);
const title = str(formData.get('title')).trim().slice(0, 255);
const description = str(formData.get('description')).trim();
const sortOrderNum = Number(str(formData.get('sortOrder')));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
const isActive = bool(formData.get('isActive'));
if (!imagePath) return;
try {
await prisma.radioBanners.update({
where: { id },
data: {
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
updatedAt: new Date(),
},
});
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/radio/banners');
}
export async function deleteRadioBanner(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
try {
await prisma.radioBanners.delete({ where: { id } });
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/radio/banners');
}
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
export async function createRadioRank(formData: FormData): Promise<void> {
await requireStaff();
const name = str(formData.get('name')).trim().slice(0, 255);
if (!name) return;
const description = str(formData.get('description')).trim().slice(0, 255);
const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255);
const isActive = bool(formData.get('isActive'));
const now = new Date();
try {
await prisma.radioRanks.create({
data: {
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
createdAt: now,
updatedAt: now,
},
});
} catch {
// Fail soft.
}
revalidatePath('/admin/radio/ranks');
}
export async function updateRadioRank(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
const name = str(formData.get('name')).trim().slice(0, 255);
const description = str(formData.get('description')).trim().slice(0, 255);
const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255);
const isActive = bool(formData.get('isActive'));
if (!name) return;
try {
await prisma.radioRanks.update({
where: { id },
data: {
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
updatedAt: new Date(),
},
});
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/radio/ranks');
}
export async function deleteRadioRank(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
try {
await prisma.radioRanks.delete({ where: { id } });
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/radio/ranks');
}
+17 -2
View File
@@ -5,16 +5,24 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
import { logStaffActivity } from "@/lib/services/staff-activity";
const CURRENCIES: ReadonlySet<string> = new Set(["credits", "duckets", "diamonds", "points"]);
export async function giveCurrency(formData: FormData): Promise<void> {
await requireStaff();
const staff = await requireStaff();
const userId = Number(formData.get("userId"));
const type = String(formData.get("type"));
const amount = Number(formData.get("amount"));
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount);
await logStaffActivity({
staffId: staff.id,
action: "give_currency",
description: `Gave ${amount} ${type} to user #${userId}`,
targetType: "user",
targetId: userId,
});
}
revalidatePath(`/admin/users/${userId}`);
}
@@ -31,12 +39,19 @@ export async function setMotto(formData: FormData): Promise<void> {
}
export async function setRank(formData: FormData): Promise<void> {
await requireStaff();
const staff = await requireStaff();
const userId = Number(formData.get("userId"));
const rank = Number(formData.get("rank"));
if (userId > 0 && rank > 0) {
await prisma.user.update({ where: { id: userId }, data: { rank } });
await rcon.setRank(userId, rank);
await logStaffActivity({
staffId: staff.id,
action: "rank_change",
description: `Set rank of user #${userId} to ${rank}`,
targetType: "user",
targetId: userId,
});
}
revalidatePath(`/admin/users/${userId}`);
}
+92
View File
@@ -0,0 +1,92 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// AtomCMS validates the application body with `min:10`. Mirror that floor and
// cap the write defensively (column is TEXT, but we keep applications sane).
const CONTENT_MIN = 10;
const CONTENT_MAX = 5000;
/**
* Submit a STAFF application for an open position.
*
* Faithful to AtomCMS's StaffApplicationsController@store:
* - the applicant (user_id) is re-read from the session via auth() and is
* NEVER trusted from the submitted FormData;
* - rank_id is the open position's permission id (the rank being applied for);
* - a user may only apply once per rank (idempotency guard);
* - content must be at least 10 characters.
*/
export async function applyStaff(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
// rank_id comes from the open position's permission_id (an Int in the schema).
const rankId = Number(formData.get("rankId"));
if (!Number.isInteger(rankId) || rankId <= 0) return;
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return;
try {
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
if (existing) return;
const now = new Date();
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
revalidatePath("/apply/staff");
}
/**
* Submit a TEAM application.
*
* The Prisma `website_staff_applications` slice has no dedicated team column, so
* (per the conversion brief) team applications REUSE the staff-applications
* table with the team acting as the rank: rank_id carries the team id. The
* applicant is re-read from the session, never trusted from the form, and a user
* may only apply once per team.
*/
export async function applyTeam(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
// id is the application's rank flag.
const rankId = Number(formData.get("teamId"));
if (!Number.isInteger(rankId) || rankId <= 0) return;
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return;
try {
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
if (existing) return;
const now = new Date();
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
});
} catch {
return;
}
revalidatePath("/apply/team");
}
+65
View File
@@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Column bounds from prisma/schema.prisma (radio_applications):
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
const NAME_MAX = 255;
const TEXT_MAX = 5000;
const STYLE_MAX = 5000;
function str(form: FormData, key: string, max: number): string {
return String(form.get(key) ?? "").trim().slice(0, max);
}
/**
* Submit a radio DJ application.
*
* The applicant (userId) is ALWAYS re-read from the session via auth() and is
* never taken from the submitted FormData, so a crafted form cannot file an
* application on behalf of another account. radio_applications.user_id is an
* UnsignedBigInt, hence the BigInt() coercion.
*/
export async function applyDj(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const realName = str(formData, "realName", NAME_MAX);
const availability = str(formData, "availability", TEXT_MAX);
const motivation = str(formData, "motivation", TEXT_MAX);
const experience = str(formData, "experience", TEXT_MAX);
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
const ageRaw = Number(formData.get("age"));
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
// Required fields per the schema (NOT NULL): real_name, age, availability,
// motivation. experience + music_style are nullable.
if (!realName || !availability || !motivation || age <= 0) return;
const now = new Date();
try {
await prisma.radioApplications.create({
data: {
userId: BigInt(userId),
realName,
age,
availability,
motivation,
experience: experience || null,
musicStyle: musicStyle || null,
status: "pending",
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable or duplicate — fail soft; nothing to persist.
return;
}
revalidatePath("/radio/apply");
}
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// radio_shouts.message is TEXT, but cap the write to keep shouts tweet-sized.
const MESSAGE_MAX = 255;
/**
* Post a radio shout.
*
* The AUTHOR (userId) is re-read from the session via auth() and is never
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
* session id is widened to BigInt for the insert.
*/
export async function postShout(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const message = String(formData.get("message") ?? "")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) return;
const now = new Date();
try {
await prisma.radioShouts.create({
data: {
userId: BigInt(userId),
message,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
revalidatePath("/radio/shouts");
}
+11
View File
@@ -1,12 +1,23 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: ReactNode }) {
const staff = await requireStaff();
// Force staff 2FA before the admin panel (AtomCMS ForceStaffTwoFactorMiddleware).
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
.catch(() => null);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
return (
<div style={{ display: "grid", gridTemplateColumns: "190px 1fr", gap: "1.5rem" }}>
<aside className="card" style={{ height: "fit-content" }}>
+253
View File
@@ -0,0 +1,253 @@
import Link from 'next/link';
import {
createRadioBanner,
deleteRadioBanner,
updateRadioBanner,
} from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
type Banner = {
id: bigint;
userId: bigint;
imagePath: string;
title: string | null;
description: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
}
export default async function AdminRadioBannersPage() {
let banners: Banner[] = [];
let dbError = false;
try {
banners = await prisma.radioBanners.findMany({
select: {
id: true,
userId: true,
imagePath: true,
title: true,
description: true,
sortOrder: true,
isActive: true,
createdAt: true,
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
} catch {
dbError = true;
banners = [];
}
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link>
</nav>
<h1>Radio Banners</h1>
<p className="muted">
Promotional banners shown on the radio page, ordered by sort order.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load banners (database unavailable).
</p>
</div>
) : null}
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add banner</h3>
<div className="grid cols-2">
<div>
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
Image path / URL
</label>
<input
id="new_imagePath"
name="imagePath"
placeholder="/storage/radio/banner.png"
required
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
Title
</label>
<input id="new_title" name="title" style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<textarea
id="new_description"
name="description"
rows={2}
style={{ width: '100%' }}
/>
</div>
<div>
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
Sort order
</label>
<input
id="new_sortOrder"
name="sortOrder"
type="number"
defaultValue={0}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create banner
</button>
</div>
</form>
{banners.length === 0 ? (
<p className="muted">No banners yet.</p>
) : (
<div className="grid">
{banners.map((b) => (
<div key={String(b.id)} className="card">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.75rem',
}}
>
<strong>#{String(b.id)}</strong>
<span className="muted">
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
{b.isActive ? 'active' : 'hidden'}
</span>
</div>
{b.imagePath ? (
<img
src={b.imagePath}
alt={b.title ?? 'Radio banner'}
className="article-img"
style={{ margin: '0.5rem 0' }}
/>
) : null}
<form action={updateRadioBanner}>
<input type="hidden" name="id" value={String(b.id)} />
<div className="grid cols-2">
<div>
<label
htmlFor={`img_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Image path / URL
</label>
<input
id={`img_${b.id}`}
name="imagePath"
defaultValue={b.imagePath}
required
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`title_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Title
</label>
<input
id={`title_${b.id}`}
name="title"
defaultValue={b.title ?? ''}
style={{ width: '100%' }}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label
htmlFor={`desc_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Description
</label>
<textarea
id={`desc_${b.id}`}
name="description"
rows={2}
defaultValue={b.description ?? ''}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor={`sort_${b.id}`}
style={{ display: 'block', fontWeight: 700 }}
>
Sort order
</label>
<input
id={`sort_${b.id}`}
name="sortOrder"
type="number"
defaultValue={b.sortOrder}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={b.isActive}
/>{' '}
Active
</label>
</div>
</div>
<div style={{ marginTop: '0.6rem' }}>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
<input type="hidden" name="id" value={String(b.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
))}
</div>
)}
</main>
);
}
+176
View File
@@ -0,0 +1,176 @@
import Link from 'next/link';
import {
createRadioRank,
deleteRadioRank,
updateRadioRank,
} from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
type Rank = {
id: bigint;
name: string;
description: string | null;
badgeCode: string | null;
isActive: boolean;
};
export default async function AdminRadioRanksPage() {
let ranks: Rank[] = [];
let dbError = false;
try {
ranks = await prisma.radioRanks.findMany({
select: {
id: true,
name: true,
description: true,
badgeCode: true,
isActive: true,
},
orderBy: { id: 'asc' },
});
} catch {
dbError = true;
ranks = [];
}
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link>
</nav>
<h1>Radio Ranks</h1>
<p className="muted">DJ / staff ranks assignable on radio applications.</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load ranks (database unavailable).
</p>
</div>
) : null}
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add rank</h3>
<div className="grid cols-3">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input id="new_name" name="name" required style={{ width: '100%' }} />
</div>
<div>
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
Badge code
</label>
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
</label>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
Description
</label>
<input id="new_desc" name="description" style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create rank
</button>
</div>
</form>
{ranks.length === 0 ? (
<p className="muted">No ranks yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Badge</th>
<th>Description</th>
<th>Active</th>
<th />
</tr>
</thead>
<tbody>
{ranks.map((r) => (
<tr key={String(r.id)}>
<td>{String(r.id)}</td>
<td colSpan={5}>
<form
action={updateRadioRank}
style={{
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<input type="hidden" name="id" value={String(r.id)} />
<input
name="name"
defaultValue={r.name}
required
placeholder="Name"
style={{ minWidth: 140 }}
/>
<input
name="badgeCode"
defaultValue={r.badgeCode ?? ''}
placeholder="Badge"
style={{ minWidth: 100 }}
/>
<input
name="description"
defaultValue={r.description ?? ''}
placeholder="Description"
style={{ flex: 1, minWidth: 160 }}
/>
<label
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
>
<input
type="checkbox"
name="isActive"
value="1"
defaultChecked={r.isActive}
/>{' '}
Active
</label>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
</td>
</tr>
))}
{ranks.map((r) => (
<tr key={`del_${String(r.id)}`}>
<td colSpan={6} style={{ borderTop: 'none', paddingTop: 0 }}>
<form action={deleteRadioRank} style={{ display: 'inline' }}>
<input type="hidden" name="id" value={String(r.id)} />
<button type="submit" className="btn btn-danger">
Delete rank #{String(r.id)}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+301
View File
@@ -0,0 +1,301 @@
import Link from 'next/link';
import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
// Curated radio_* keys (faithful to AtomCMS RadioSettingsSeeder), grouped for
// the editor. Any extra radio_* rows found in the DB are appended under "Other".
type Field = { key: string; comment: string };
type Group = { title: string; fields: Field[] };
const GROUPS: Group[] = [
{
title: 'AzureCast & Stream',
fields: [
{ key: 'radio_enabled', comment: 'Radio enabled (0=no, 1=yes)' },
{ key: 'radio_stream_url', comment: 'Main stream URL' },
{ key: 'radio_stream_backup_url', comment: 'Backup stream URL' },
{ key: 'radio_azurecast_base_url', comment: 'AzureCast base URL' },
{ key: 'radio_azurecast_station_id', comment: 'AzureCast station ID' },
{ key: 'radio_azurecast_api_key', comment: 'AzureCast API key' },
{ key: 'radio_azurecast_port', comment: 'AzureCast stream port' },
{ key: 'radio_azurecast_protocol', comment: 'AzureCast protocol (http/https)' },
{ key: 'radio_azurecast_use_proxy', comment: 'Use AzureCast proxy (0=no, 1=yes)' },
],
},
{
title: 'External sources',
fields: [
{ key: 'radio_sambroadcaster_api_url', comment: 'Sambroadcaster API URL' },
{ key: 'radio_sambroadcaster_password', comment: 'Sambroadcaster password' },
{ key: 'radio_virtual_dj_url', comment: 'Virtual DJ URL' },
{ key: 'radio_virtual_dj_password', comment: 'Virtual DJ password' },
{ key: 'radio_djs_api_url', comment: 'DJs API URL' },
],
},
{
title: 'Player',
fields: [
{ key: 'radio_style', comment: 'Player style (dark/light/blue/purple/pink/gold)' },
{ key: 'radio_player_type', comment: 'Player type (default/minimal/embedded)' },
{ key: 'radio_auto_play', comment: 'Auto-play on page load (0=no, 1=yes)' },
{ key: 'radio_auto_play_delay', comment: 'Auto-play delay in seconds' },
{ key: 'radio_mute_on_start', comment: 'Muted on start (0=no, 1=yes)' },
{ key: 'radio_volume', comment: 'Default volume (0-100)' },
{ key: 'radio_language', comment: 'Default language (nl/en/de/fr/es/pt/it/br)' },
{ key: 'radio_multi_language_enabled', comment: 'Multi-language support (0=no, 1=yes)' },
{ key: 'radio_player_color_primary', comment: 'Player primary color' },
{ key: 'radio_player_color_secondary', comment: 'Player secondary color' },
{ key: 'radio_player_color_text', comment: 'Player text color' },
{ key: 'radio_player_color_accent', comment: 'Player accent color' },
],
},
{
title: 'Now playing & history',
fields: [
{ key: 'radio_now_playing_enabled', comment: 'Show now playing info (0=no, 1=yes)' },
{ key: 'radio_now_playing_api_url', comment: 'Now playing API URL' },
{ key: 'radio_now_playing_cache_duration', comment: 'Now playing cache duration in seconds' },
{ key: 'radio_show_song_history', comment: 'Show song history (0=no, 1=yes)' },
{ key: 'radio_song_history_count', comment: 'Number of songs in history' },
],
},
{
title: 'Listeners',
fields: [
{ key: 'radio_listeners_enabled', comment: 'Show listeners count (0=no, 1=yes)' },
{ key: 'radio_listeners_api_url', comment: 'Listeners API URL' },
{ key: 'radio_listeners_update_interval', comment: 'Listeners update interval in seconds' },
{ key: 'radio_show_listener_peak', comment: 'Show listener peak (0=no, 1=yes)' },
{ key: 'radio_peak_reset_time', comment: 'Peak reset time (HH:MM)' },
{ key: 'radio_store_listener_stats', comment: 'Store listener statistics (0=no, 1=yes)' },
{ key: 'radio_stats_retention_days', comment: 'Statistics retention in days' },
{ key: 'radio_show_listener_graph', comment: 'Show listener graph (0=no, 1=yes)' },
{ key: 'radio_listener_graph_period', comment: 'Listener graph period (1h/6h/12h/24h/7d)' },
{ key: 'radio_show_average_listeners', comment: 'Show average listeners (0=no, 1=yes)' },
{ key: 'radio_show_unique_listeners', comment: 'Show unique listeners (0=no, 1=yes)' },
{ key: 'radio_listener_alerts_enabled', comment: 'Enable listener alerts (0=no, 1=yes)' },
{ key: 'radio_min_listeners_threshold', comment: 'Minimum listeners threshold' },
{ key: 'radio_listener_alert_threshold_high', comment: 'High listener alert threshold' },
{ key: 'radio_listener_alert_threshold_low', comment: 'Low listener alert threshold' },
],
},
{
title: 'DJ',
fields: [
{ key: 'radio_show_current_dj', comment: 'Show current DJ (0=no, 1=yes)' },
{ key: 'radio_current_dj_id', comment: 'Manual DJ user ID' },
{ key: 'radio_dj_avatar_size', comment: 'DJ avatar size in pixels' },
{ key: 'radio_show_dj_schedule', comment: 'Show DJ schedule (0=no, 1=yes)' },
{ key: 'radio_auto_dj_detection', comment: 'Enable auto DJ detection (0=no, 1=yes)' },
{ key: 'radio_dj_detection_interval', comment: 'DJ detection interval in seconds' },
{ key: 'auto_dj_detected_action', comment: 'Action on DJ detection (set_dj/notify/both)' },
],
},
{
title: 'Shouts & chat',
fields: [
{ key: 'radio_shouts_enabled', comment: 'Enable shouts (0=no, 1=yes)' },
{ key: 'radio_shouts_max_length', comment: 'Maximum shout length in characters' },
{ key: 'radio_shouts_cooldown', comment: 'Shout cooldown in seconds' },
{ key: 'radio_shouts_require_moderation', comment: 'Require shout moderation (0=no, 1=yes)' },
{ key: 'radio_chat_enabled', comment: 'Enable live chat (0=no, 1=yes)' },
{ key: 'radio_chat_position', comment: 'Chat position (left/right/bottom)' },
{ key: 'radio_chat_width', comment: 'Chat width in pixels' },
{ key: 'radio_chat_height', comment: 'Chat height in pixels' },
{ key: 'radio_chat_messages_count', comment: 'Number of chat messages to show' },
{ key: 'radio_chat_show_timestamps', comment: 'Show chat timestamps (0=no, 1=yes)' },
{ key: 'radio_chat_allow_links', comment: 'Allow links in chat (0=no, 1=yes)' },
{ key: 'radio_chat_allow_emojis', comment: 'Allow emojis in chat (0=no, 1=yes)' },
],
},
{
title: 'Requests & widget',
fields: [
{ key: 'radio_request_form_enabled', comment: 'Enable song requests (0=no, 1=yes)' },
{ key: 'radio_request_max_per_user', comment: 'Maximum requests per user' },
{ key: 'radio_request_cooldown', comment: 'Request cooldown in seconds' },
{ key: 'radio_voting_enabled', comment: 'Enable request voting (0=no, 1=yes)' },
{ key: 'radio_voting_show_song_requests', comment: 'Show requests with votes (0=no, 1=yes)' },
{ key: 'radio_widget_enabled', comment: 'Enable radio widget (0=no, 1=yes)' },
{ key: 'radio_widget_show_globally', comment: 'Show widget globally (0=no, 1=yes)' },
{ key: 'radio_widget_position', comment: 'Widget position' },
],
},
{
title: 'Offline & branding',
fields: [
{ key: 'radio_show_offline_message', comment: 'Show offline message (0=no, 1=yes)' },
{ key: 'radio_offline_message', comment: 'Offline message text' },
{ key: 'radio_offline_image', comment: 'Offline page image URL' },
{ key: 'radio_background_image', comment: 'Background image URL' },
{ key: 'radio_logo_url', comment: 'Radio logo URL' },
],
},
{
title: 'Applications',
fields: [
{ key: 'radio_applications_enabled', comment: 'Enable DJ applications (0=no, 1=yes)' },
{ key: 'radio_applications_max_per_day', comment: 'Maximum applications per day' },
{ key: 'radio_applications_require_approval', comment: 'Require application approval (0=no, 1=yes)' },
],
},
{
title: 'Stats display',
fields: [
{ key: 'radio_stats_show_daily', comment: 'Show daily statistics (0=no, 1=yes)' },
{ key: 'radio_stats_show_weekly', comment: 'Show weekly statistics (0=no, 1=yes)' },
{ key: 'radio_stats_show_monthly', comment: 'Show monthly statistics (0=no, 1=yes)' },
{ key: 'radio_stats_show_top_djs', comment: 'Show top DJs (0=no, 1=yes)' },
{ key: 'radio_stats_show_top_songs', comment: 'Show top songs (0=no, 1=yes)' },
],
},
{
title: 'Social & contact',
fields: [
{ key: 'radio_social_facebook', comment: 'Facebook URL' },
{ key: 'radio_social_twitter', comment: 'Twitter URL' },
{ key: 'radio_social_instagram', comment: 'Instagram URL' },
{ key: 'radio_social_discord', comment: 'Discord URL' },
{ key: 'radio_social_youtube', comment: 'YouTube URL' },
{ key: 'radio_social_twitch', comment: 'Twitch URL' },
{ key: 'radio_contact_email', comment: 'Contact email' },
{ key: 'radio_contact_phone', comment: 'Contact phone' },
{ key: 'radio_about_text', comment: 'About radio text' },
],
},
{
title: 'Discord webhook & custom code',
fields: [
{ key: 'radio_discord_webhook_url', comment: 'Discord webhook URL' },
{ key: 'radio_discord_enabled', comment: 'Enable Discord notifications (0=no, 1=yes)' },
{ key: 'radio_discord_dj_live', comment: 'Discord DJ live notification (0=no, 1=yes)' },
{ key: 'radio_discord_song_changes', comment: 'Discord song change notification (0=no, 1=yes)' },
{ key: 'radio_discord_custom_message', comment: 'Custom Discord message template' },
{ key: 'radio_custom_css', comment: 'Custom CSS code' },
{ key: 'radio_custom_js', comment: 'Custom JavaScript code' },
],
},
];
const CURATED_KEYS = new Set(GROUPS.flatMap((g) => g.fields.map((f) => f.key)));
export default async function AdminRadioSettingsPage() {
let values: Map<string, string> = new Map();
let dbError = false;
let extra: Field[] = [];
try {
const rows = await prisma.websiteSetting.findMany({
select: { key: true, value: true, comment: true },
});
values = new Map(rows.map((r) => [r.key, r.value]));
extra = rows
.filter(
(r) =>
(r.key.startsWith('radio_') || r.key.startsWith('auto_dj_')) &&
!CURATED_KEYS.has(r.key),
)
.map((r) => ({ key: r.key, comment: r.comment ?? '' }));
} catch {
dbError = true;
}
const groups: Group[] = extra.length
? [...GROUPS, { title: 'Other radio settings', fields: extra }]
: GROUPS;
const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(',');
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link>
</nav>
<h1>Radio Settings</h1>
<p className="muted">
Edits write to <code>website_settings</code> (radio_* keys) and bust the
runtime settings cache. Booleans use <code>0</code> / <code>1</code>.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load settings (database unavailable). Defaults will be
created on save.
</p>
</div>
) : null}
<form action={saveRadioSettings}>
<input type="hidden" name="__keys" value={allKeys} />
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '1rem',
}}
>
<button type="submit" className="btn btn-primary">
Save all
</button>
</div>
{groups.map((group) => (
<section key={group.title} className="card" style={{ marginBottom: '1.25rem' }}>
<h2 style={{ marginTop: 0 }}>{group.title}</h2>
<div className="grid cols-2">
{group.fields.map((field) => (
<div key={field.key}>
<label
htmlFor={`f_${field.key}`}
style={{ display: 'block', fontWeight: 700, fontSize: '0.85rem' }}
>
{field.key}
</label>
<input
id={`f_${field.key}`}
name={field.key}
defaultValue={values.get(field.key) ?? ''}
style={{ width: '100%' }}
/>
{field.comment ? (
<p className="muted" style={{ margin: '0.2rem 0 0' }}>
{field.comment}
</p>
) : null}
</div>
))}
</div>
</section>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="submit" className="btn btn-primary">
Save all
</button>
</div>
</form>
<section className="card" style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite a single key</h3>
<form
action={saveRadioSetting}
style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}
>
<input name="key" placeholder="radio_custom_key" required />
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
<input name="comment" placeholder="comment (optional)" />
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
</section>
</main>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
captureOrder,
creditsPerUnit,
isPayPalConfigured,
} from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
import { env } from "@/env";
export const dynamic = "force-dynamic";
/**
* POST /api/paypal/capture — capture an approved order and credit the buyer.
* Body: { orderId: string } (PayPal order id from /api/paypal/create).
*
* On a COMPLETED capture we:
* 1. record the payment in website_paypal_transactions (idempotent on
* transaction_id so a double-submit can't double-credit), and
* 2. credit the buyer's `credits` wallet via sendCurrency (RCON-first, DB
* fallback). NOTE: the schema has no dedicated website-balance column —
* User.credits is the website/in-game wallet, so the top-up lands there,
* consistent with the voucher redeem flow.
*
* Auth-gated via auth(); the crediting user id is the session user, not a body
* field, so a captured order always credits the person who is signed in.
*/
export async function POST(req: Request): Promise<Response> {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) {
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
}
if (!isPayPalConfigured()) {
return NextResponse.json(
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
const orderId = String((body as { orderId?: unknown })?.orderId ?? "").trim();
if (!orderId) {
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
}
// Idempotency: if this order was already recorded, don't capture/credit again.
try {
const existing = await prisma.websitePaypalTransactions.findFirst({
where: { transactionId: orderId },
select: { id: true, status: true },
});
if (existing) {
return NextResponse.json({
ok: existing.status === "COMPLETED",
alreadyProcessed: true,
status: existing.status,
});
}
} catch {
// If the lookup fails we fall through; the capture call itself is the source
// of truth and PayPal rejects a second capture of the same order.
}
let result;
try {
result = await captureOrder(orderId);
} catch (e) {
console.error("[paypal/capture]", (e as Error).message);
return NextResponse.json(
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
{ status: 502 },
);
}
if (result.status !== "COMPLETED") {
// Record the non-completed attempt so support can trace it.
try {
await prisma.websitePaypalTransactions.create({
data: {
userId,
transactionId: result.id || orderId,
status: result.status,
description: `${env.HOTEL_NAME} top-up (not completed)`,
amount: result.amount,
currency: result.currency,
createdAt: new Date(),
updatedAt: new Date(),
},
});
} catch {
/* best-effort logging */
}
return NextResponse.json(
{ ok: false, status: result.status, error: "Payment was not completed." },
{ status: 402 },
);
}
const credits = Math.floor(result.amount * creditsPerUnit());
// Record the transaction BEFORE crediting so a crash mid-grant can't be
// reprocessed into a double credit (the idempotency check above keys on this).
try {
await prisma.websitePaypalTransactions.create({
data: {
userId,
transactionId: result.captureId ?? result.id,
status: "COMPLETED",
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
amount: result.amount,
currency: result.currency,
createdAt: new Date(),
updatedAt: new Date(),
},
});
} catch (e) {
console.error("[paypal/capture] record failed", (e as Error).message);
return NextResponse.json(
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
{ status: 500 },
);
}
// Credit the buyer's website credits wallet (RCON-first, DB fallback).
try {
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
} catch (e) {
console.error("[paypal/capture] credit failed", (e as Error).message);
return NextResponse.json(
{
ok: false,
error: "Payment recorded but credits could not be delivered. Contact staff.",
},
{ status: 500 },
);
}
return NextResponse.json({
ok: true,
status: "COMPLETED",
amount: result.amount,
currency: result.currency,
credits,
});
}
+84
View File
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import {
createOrder,
creditsPerUnit,
isPayPalConfigured,
PAYPAL_CURRENCY,
} from "@/lib/services/paypal";
import { env } from "@/env";
export const dynamic = "force-dynamic";
const MIN_AMOUNT = 1;
const MAX_AMOUNT = 500;
/**
* POST /api/paypal/create — create a PayPal CAPTURE order for the signed-in user.
* Body: { amount: number } (in the configured currency, default USD).
* Returns { id, approveUrl } on success; a clear JSON error otherwise.
*
* Auth-gated via auth(): the order is tied to the session, never to a body field.
*/
export async function POST(req: Request): Promise<Response> {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
}
// Fail fast (and clearly) when the sandbox/live keys aren't set.
if (!isPayPalConfigured()) {
return NextResponse.json(
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
const raw = (body as { amount?: unknown })?.amount;
const amount = Math.round(Number(raw) * 100) / 100;
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
return NextResponse.json(
{ error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.` },
{ status: 422 },
);
}
const credits = Math.floor(amount * creditsPerUnit());
const base = env.APP_URL.replace(/\/+$/, "");
try {
const order = await createOrder(amount, {
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
returnUrl: `${base}/shop/topup?status=success`,
cancelUrl: `${base}/shop/topup?status=cancel`,
});
if (!order.approveUrl) {
return NextResponse.json(
{ error: "PayPal did not return an approval link. Try again." },
{ status: 502 },
);
}
return NextResponse.json({
id: order.id,
approveUrl: order.approveUrl,
amount,
currency: PAYPAL_CURRENCY,
credits,
});
} catch (e) {
console.error("[paypal/create]", (e as Error).message);
return NextResponse.json(
{ error: "Could not start the PayPal checkout. Please try again." },
{ status: 502 },
);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { applyStaff } from "@/actions/applications";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
function formatDeadline(d: Date | null | undefined): string {
if (!d) return "No deadline set";
return d.toISOString().slice(0, 16).replace("T", " ");
}
export default async function ApplyStaffPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
// ── Open positions ──────────────────────────────────────────
// Each query is isolated so a DB hiccup degrades that section to empty
// rather than 500-ing the whole page.
const positions = await prisma.websiteOpenPositions
.findMany({ orderBy: { createdAt: "desc" } })
.catch(() => []);
// Resolve display info for each position's rank. The rich rank fields
// (rankName/badge/jobDescription/staffColor) live in website_teams, keyed by
// the same rank id the position's permission_id points at.
const rankIds = Array.from(new Set(positions.map((p) => p.permissionId)));
const teams = rankIds.length
? await prisma.websiteTeams
.findMany({
where: { id: { in: rankIds.map((r) => BigInt(r)) } },
})
.catch(() => [])
: [];
const teamByRankId = new Map(teams.map((t) => [Number(t.id), t]));
// Ranks this user has already applied for (block re-applying).
const myApps = await prisma.websiteStaffApplications
.findMany({ where: { userId }, select: { rankId: true } })
.catch(() => []);
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
return (
<main>
<div className="hero">
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for {hotelName} staff</h1>
<p className="muted" style={{ margin: 0 }}>
We open staff applications every now and then. If you come across a position you feel you
would fit perfectly into, do not hesitate to apply for it.
</p>
</div>
{positions.length === 0 ? (
<div className="card">
<h3 style={{ marginTop: 0 }}>No positions open</h3>
<p className="muted" style={{ margin: 0 }}>
There are currently no positions open. Please come back at a later time to check if we
have any openings by then. Thank you for your interest.
</p>
</div>
) : (
<div className="grid cols-2">
{positions.map((position) => {
const team = teamByRankId.get(position.permissionId);
const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
const alreadyApplied = appliedRankIds.has(position.permissionId);
return (
<article key={String(position.id)} className="card">
<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
{team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
alt={rankName}
width={40}
height={40}
/>
) : null}
<div>
<h3 style={{ margin: 0, color: team?.staffColor || undefined }}>{rankName}</h3>
{team?.jobDescription ? (
<p className="muted" style={{ margin: 0 }}>
{team.jobDescription}
</p>
) : null}
</div>
</div>
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
Application deadline: {formatDeadline(position.applyTo)}
</p>
{alreadyApplied ? (
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
You have already applied for {rankName}
</button>
) : (
<form action={applyStaff}>
{/* rank_id is the position's permission id; the applicant is
re-read from the session inside the action. */}
<input type="hidden" name="rankId" value={String(position.permissionId)} />
<label htmlFor={`content-${position.id}`} className="muted">
About you
</label>
<textarea
id={`content-${position.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={`Tell us why you'd be a great ${rankName}…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-secondary" style={{ width: "100%" }}>
Apply for {rankName}
</button>
</form>
)}
</article>
);
})}
</div>
)}
</main>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { applyTeam } from "@/actions/applications";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyTeamPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
// ── Teams open for application ──────────────────────────────
// The Prisma slice exposes teams directly (no separate team-position table
// with position_kind), so the team application concept lists website_teams.
// Hidden ranks are excluded from the public apply page.
const teams = await prisma.websiteTeams
.findMany({
where: { hiddenRank: false },
orderBy: { rankName: "asc" },
})
.catch(() => []);
// Teams this user has already applied to. Team applications reuse the staff
// applications table with rank_id carrying the team id (the team flag).
const myApps = await prisma.websiteStaffApplications
.findMany({ where: { userId }, select: { rankId: true } })
.catch(() => []);
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
return (
<main>
<div className="hero">
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for the {hotelName} team</h1>
<p className="muted" style={{ margin: 0 }}>
We open team applications periodically. If you see a team you fit, do not hesitate to
apply!
</p>
</div>
{teams.length === 0 ? (
<div className="card">
<h3 style={{ marginTop: 0 }}>No team positions open</h3>
<p className="muted" style={{ margin: 0 }}>
There are currently no open team positions. Please come back later to check for new
openings. Thank you!
</p>
</div>
) : (
<div className="grid cols-2">
{teams.map((team) => {
const teamId = Number(team.id);
const alreadyApplied = appliedTeamIds.has(teamId);
return (
<article key={String(team.id)} className="card">
<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
{team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
alt={team.rankName}
width={40}
height={40}
/>
) : null}
<div>
<h3 style={{ margin: 0, color: team.staffColor || undefined }}>
{team.rankName}
</h3>
{team.jobDescription ? (
<p className="muted" style={{ margin: 0 }}>
{team.jobDescription}
</p>
) : null}
</div>
</div>
{alreadyApplied ? (
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
Your application is pending
</button>
) : (
<form action={applyTeam}>
{/* The team id is the application's rank flag; the applicant
is re-read from the session inside the action. */}
<input type="hidden" name="teamId" value={String(team.id)} />
<label htmlFor={`content-${team.id}`} className="muted">
About you
</label>
<textarea
id={`content-${team.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={`Tell us why you'd be a great fit for ${team.rankName}…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
Apply for {team.rankName}
</button>
</form>
)}
</article>
);
})}
</div>
)}
</main>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600;
export default async function BannedPage() {
const session = await auth();
let reason = "";
let expire = 0;
if (session?.user?.id) {
try {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } },
orderBy: { banExpire: "desc" },
select: { banReason: true, banExpire: true },
});
if (ban) {
reason = ban.banReason;
expire = ban.banExpire;
}
} catch {
// show generic message
}
}
const expiryText =
expire === 0
? ""
: expire > FAR_FUTURE
? "This ban is permanent."
: `Expires ${new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " ")}.`;
return (
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
<h1 style={{ marginTop: 0, color: "var(--color-danger)" }}>You are banned</h1>
<p>Your account has been suspended from the hotel.</p>
{reason ? (
<p>
<strong>Reason:</strong> {reason}
</p>
) : null}
{expiryText ? <p className="muted">{expiryText}</p> : null}
<p className="muted">If you believe this is a mistake, contact the staff team.</p>
</div>
</main>
);
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { ThemeVars } from "@/components/theme-vars";
import { TopHeader } from "@/components/top-header";
import { enforceSiteAccess } from "@/lib/access-guard";
import "./globals.css";
const nunito = Nunito({
@@ -20,7 +21,8 @@ export const metadata: Metadata = {
description: "AtomCMS — retro hotel CMS (Next.js conversion)",
};
export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
await enforceSiteAccess();
return (
<html lang="en" className={`app ${nunito.variable}`}>
<body
+23
View File
@@ -0,0 +1,23 @@
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function MaintenancePage() {
const [hotel, message] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
siteSettings.get(
"maintenance_message",
"We are currently performing maintenance. Please check back soon.",
),
]);
return (
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
<h1 style={{ marginTop: 0 }}>{hotel} is under maintenance 🛠️</h1>
<p>{message}</p>
<p className="muted">Staff can still log in.</p>
</div>
</main>
);
}
+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>
);
}
+133
View File
@@ -0,0 +1,133 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
export const dynamic = "force-dynamic";
// Social providers we can link. `available` reflects whether the OAuth app
// credentials are configured (mirrors src/lib/auth.ts, which only registers a
// provider when both its id + secret env vars are set). When unavailable the
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
const PROVIDERS = [
{
key: "discord",
label: "Discord",
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
},
{
key: "google",
label: "Google",
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
},
] as const;
export default async function ConnectionsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
// social_accounts is a CMS-only table that may not exist in every install
// (it has no bundled migration). Fall back to "nothing linked" on any error so
// the page still renders the generic connect buttons.
let linked: Set<string> = new Set();
let lookupFailed = false;
try {
const rows = await prisma.socialAccounts.findMany({
where: { userId: BigInt(id) },
select: { provider: true },
});
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
} catch {
lookupFailed = true;
}
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
const callbackUrl = "/settings/connections";
return (
<main style={{ maxWidth: 560 }}>
<p className="muted">
<Link href="/settings">← Settings</Link>
</p>
<h1>Connected accounts</h1>
<p className="muted" style={{ marginTop: 0 }}>
Link a social account so you can sign in with one click. We match it to
your hotel account by e-mail, so use the address on file:{" "}
<strong>{session.user.name}</strong>.
</p>
{lookupFailed ? (
<div className="card" style={{ marginBottom: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
We couldn't read your linked accounts right now. You can still connect
below — your status will show once it's available.
</p>
</div>
) : null}
<div className="grid" style={{ gap: "1rem" }}>
{PROVIDERS.map((p) => {
const isLinked = linked.has(p.key);
return (
<div
key={p.key}
className="card"
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
>
<div style={{ flex: 1 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3>
<p className="muted" style={{ margin: 0 }}>
{!p.available
? "Not available on this hotel."
: isLinked
? "Connected — you can sign in with this account."
: "Not connected."}
</p>
</div>
<div>
{isLinked ? (
<span
className="btn btn-outline"
style={{ cursor: "default", pointerEvents: "none" }}
aria-disabled="true"
>
Connected
</span>
) : p.available ? (
// Plain link into NextAuth's built-in sign-in handler. Re-using
// the same OAuth flow as /login binds the provider to the
// matching hotel account (see auth.ts signIn callback).
<a
className="btn btn-primary"
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
callbackUrl,
)}`}
>
Connect {p.label}
</a>
) : (
<span
className="btn"
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
aria-disabled="true"
>
Unavailable
</span>
)}
</div>
</div>
);
})}
</div>
<p className="muted" style={{ marginTop: "1.5rem", marginBottom: 0 }}>
Connecting takes you to the provider to authorise, then back here. If your
social account's e-mail doesn't match a hotel account, you'll be returned
to the login screen with a note.
</p>
</main>
);
}
+10
View File
@@ -92,6 +92,16 @@ export default async function SettingsPage() {
Two-factor authentication
</Link>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Connected accounts</h3>
<p className="muted" style={{ marginTop: 0 }}>
Link Discord or Google to sign in with one click.
</p>
<Link href="/settings/connections" className="btn btn-outline">
Manage connections
</Link>
</div>
</main>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
/**
* Client form for /shop/topup. Posts the chosen amount to /api/paypal/create,
* then redirects the browser to PayPal's approval URL. After PayPal returns to
* /shop/topup?token=<orderId>&status=success, it captures via /api/paypal/capture.
*/
export default function TopUpForm({
currency,
creditsPerUnit,
}: {
currency: string;
creditsPerUnit: number;
}) {
const [amount, setAmount] = useState("5.00");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const numeric = Number(amount);
const credits = Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric * creditsPerUnit) : 0;
async function startCheckout(e: React.FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
const res = await fetch("/api/paypal/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: numeric }),
});
const data = (await res.json().catch(() => ({}))) as {
approveUrl?: string;
error?: string;
};
if (!res.ok || !data.approveUrl) {
setError(data.error ?? "Could not start the PayPal checkout.");
setPending(false);
return;
}
// Hand off to PayPal for approval.
window.location.href = data.approveUrl;
} catch {
setError("Network error — please try again.");
setPending(false);
}
}
async function capture(orderId: string) {
setPending(true);
setError(null);
try {
const res = await fetch("/api/paypal/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId }),
});
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
credits?: number;
error?: string;
};
if (res.ok && data.ok) {
setSuccess(
`Payment complete! ${(data.credits ?? 0).toLocaleString()} credits were added.`,
);
} else {
setError(data.error ?? "We couldn't confirm your payment. Contact staff if charged.");
}
} catch {
setError("Network error confirming payment — contact staff if you were charged.");
} finally {
setPending(false);
// Clean the token/status query so a refresh doesn't re-capture.
window.history.replaceState(null, "", "/shop/topup");
}
}
// On return from PayPal (?token=<orderId>&status=success), auto-capture once.
if (typeof window !== "undefined" && !pending && !success) {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token && params.get("status") === "success") {
// Defer to avoid setState during render.
queueMicrotask(() => capture(token));
}
}
if (success) {
return (
<p
role="status"
aria-live="polite"
style={{ margin: 0, fontWeight: 700, color: "var(--color-accent)" }}
>
{success}
</p>
);
}
return (
<form
onSubmit={startCheckout}
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
>
<label htmlFor="amount" style={{ fontWeight: 700 }}>
Amount ({currency})
</label>
<input
id="amount"
name="amount"
type="number"
min={1}
max={500}
step={0.01}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-label={`Amount in ${currency}`}
required
/>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
{["2.00", "5.00", "10.00", "25.00"].map((v) => (
<button
key={v}
type="button"
className={`btn ${amount === v ? "btn-primary" : "btn-outline"}`}
onClick={() => setAmount(v)}
>
{currency} {v}
</button>
))}
</div>
<p className="muted" style={{ margin: 0 }}>
You&apos;ll receive{" "}
<strong>{credits.toLocaleString()}</strong> credits.
</p>
<div>
<button type="submit" className="btn btn-primary" disabled={pending || credits <= 0}>
{pending ? "Redirecting…" : "Pay with PayPal"}
</button>
</div>
{error ? (
<p
role="alert"
aria-live="polite"
style={{ margin: 0, fontWeight: 700, color: "var(--color-danger)" }}
>
{error}
</p>
) : null}
</form>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { redirect } from "next/navigation";
import TopUpForm from "./TopUpForm";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
export const dynamic = "force-dynamic";
export default async function TopUpPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const sp = await searchParams;
const id = Number(session.user.id);
// Current credits balance for context (fail soft).
let user: { username: string; credits: number } | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { username: true, credits: true },
});
} catch {
user = null;
}
if (!user) redirect("/login");
const configured = isPayPalConfigured();
const rate = creditsPerUnit();
return (
<main>
<div className="hero">
<h1 style={{ marginBottom: "0.25rem" }}>Top up with PayPal</h1>
<p className="muted" style={{ margin: 0 }}>
Pay securely with PayPal and your credits are added the moment the payment clears.
</p>
</div>
{sp.status === "cancel" ? (
<div className="card" style={{ marginBottom: "1.5rem", borderColor: "var(--color-danger)" }}>
<p style={{ margin: 0 }}>Checkout was cancelled — no payment was taken.</p>
</div>
) : null}
<div className="grid cols-2">
<div className="card">
<h3 style={{ marginTop: 0 }}>Choose an amount</h3>
{configured ? (
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
) : (
<p className="muted" style={{ margin: 0 }}>
PayPal isn&apos;t configured yet. An administrator needs to set{" "}
<code>PAYPAL_CLIENT_ID</code> and <code>PAYPAL_SECRET</code> (and optionally{" "}
<code>PAYPAL_API</code> for sandbox vs. live).
</p>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Your balance</h3>
<p style={{ margin: "0 0 0.6rem" }}>
Signed in as <strong>{user.username}</strong>.
</p>
<span className="currency">
<span className="coin credits">cr</span>
{user.credits.toLocaleString()}
</span>
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}.
Credits appear in-game the next time you log in.
</p>
</div>
</div>
</main>
);
}
+3
View File
@@ -78,6 +78,9 @@ export async function Navigation() {
</div>
</details>
<Link href="/radio" className="nav-item">
Radio
</Link>
{session?.user ? (
<Link href="/friends" className="nav-item">
Friends
+49
View File
@@ -0,0 +1,49 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Paths that must never be gated (otherwise banned/maintenance loop forever).
const EXEMPT = ["/banned", "/maintenance", "/login", "/register", "/forgot", "/reset", "/api"];
function isExempt(path: string): boolean {
return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`));
}
/**
* Site-wide access enforcement (called from the root layout): routes non-staff
* to /maintenance when maintenance mode is on, and banned users to /banned.
* Runs in the Node runtime so it can query the DB. The redirect decision is
* computed inside try/catch and the redirect() (which throws NEXT_REDIRECT) is
* issued OUTSIDE it.
*/
export async function enforceSiteAccess(): Promise<void> {
const h = await headers();
const path = h.get("x-pathname") ?? "/";
if (isExempt(path)) return;
let target: string | null = null;
try {
const session = await auth();
const rank = session?.user?.rank ?? 0;
if (await siteSettings.getBool("maintenance_enabled", false)) {
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
if (rank < minLogin) target = "/maintenance";
}
if (!target && session?.user?.id) {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } },
select: { id: true },
});
if (ban) target = "/banned";
}
} catch {
// On any failure, fail open (don't lock the whole site out on a DB hiccup).
}
if (target) redirect(target);
}
+230
View File
@@ -0,0 +1,230 @@
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/services/email";
import { env } from "@/env";
// === Alert service (AtomCMS → Next.js) ===========================================
//
// A pure, dependency-free server module for raising operational alerts. A single
// sendAlert() call (a) persists a row in alert_logs (prisma.alertLogs delegate),
// (b) pushes a Discord embed to DISCORD_WEBHOOK_URL when set, and (c) optionally
// emails staff via sendMail() when ALERT_EMAIL is set.
//
// Every external side-effect is wrapped in try/catch so a failing webhook, dead
// SMTP host, or unreachable DB never throws into the caller (which is usually a
// background path: emulator health checks, DDoS detection, etc.). Uses the global
// fetch (Node 18+/Next 16) — no extra packages.
//
// NOTE: DISCORD_WEBHOOK_URL and ALERT_EMAIL are NOT declared in src/env.ts, so we
// read them from process.env directly with safe fallbacks (the features simply
// no-op when their env var is unset). Add them to env.ts later if you want them
// validated at boot.
export type AlertSeverity = "info" | "notice" | "warning" | "error" | "critical";
export interface SendAlertInput {
/** Machine-readable category, e.g. "emulator", "security", "ddos". */
type: string;
/** Free-text severity; drives Discord embed colour + email subject prefix. */
severity: AlertSeverity | string;
/** Human-readable message body. */
message: string;
/** Optional structured payload stored verbatim in alert_logs.context (JSON). */
context?: Record<string, unknown>;
}
export interface SendAlertResult {
logged: boolean;
sentViaDiscord: boolean;
sentViaEmail: boolean;
}
// Discord embed sidebar colours (decimal RGB) keyed by normalised severity.
const DISCORD_COLORS: Record<string, number> = {
critical: 0xc0392b,
error: 0xe74c3c,
danger: 0xe74c3c,
warning: 0xf39c12,
warn: 0xf39c12,
success: 0x2ecc71,
info: 0x3498db,
notice: 0x9b59b6,
};
function severityColor(severity: string): number {
return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6;
}
function discordWebhookUrl(): string | undefined {
const url = process.env.DISCORD_WEBHOOK_URL?.trim();
return url ? url : undefined;
}
function alertEmail(): string | undefined {
const addr = process.env.ALERT_EMAIL?.trim();
return addr ? addr : undefined;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Post a Discord embed to DISCORD_WEBHOOK_URL. Returns false (without throwing)
* when the webhook is unset, the request fails, or Discord returns non-2xx.
*/
async function postDiscord(input: SendAlertInput): Promise<boolean> {
const url = discordWebhookUrl();
if (!url) return false;
const fields = input.context
? Object.entries(input.context)
.slice(0, 10)
.map(([name, value]) => ({
name: String(name).slice(0, 256) || "​",
value: String(value ?? "").slice(0, 1024) || "​",
inline: true,
}))
: undefined;
const body = {
username: `${env.HOTEL_NAME} Alerts`,
embeds: [
{
title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice(0, 256),
description: input.message.slice(0, 4096),
color: severityColor(input.severity),
timestamp: new Date().toISOString(),
...(fields && fields.length ? { fields } : {}),
footer: { text: env.HOTEL_NAME },
},
],
};
try {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
console.error("[alert] Discord webhook returned", res.status);
return false;
}
return true;
} catch (e) {
console.error("[alert] Discord webhook failed:", (e as Error).message);
return false;
}
}
/**
* Email the alert to ALERT_EMAIL via sendMail(). Returns false (without throwing)
* when ALERT_EMAIL is unset, SMTP isn't configured, or sending fails. sendMail
* already swallows its own errors, but we guard defensively anyway.
*/
async function emailStaff(input: SendAlertInput): Promise<boolean> {
const to = alertEmail();
if (!to) return false;
const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`;
const contextRows = input.context
? Object.entries(input.context)
.map(
([k, v]) =>
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
)
.join("")
: "";
const html =
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
(contextRows
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
: "") +
`<p style="margin-top:16px;color:#888;font-size:12px">` +
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
try {
return await sendMail(to, subject, html);
} catch (e) {
console.error("[alert] staff email failed:", (e as Error).message);
return false;
}
}
/**
* Raise an operational alert: persist to alert_logs and fan out to Discord +
* email. Never throws — each side-effect is isolated; a failure in one channel
* does not block the others. The returned result reports which channels
* succeeded (also reflected in the alert_logs row's sent_via_* flags).
*/
export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult> {
// Fan out Discord + email first so we can record their outcome on the row.
const [sentViaDiscord, sentViaEmail] = await Promise.all([
postDiscord(input),
emailStaff(input),
]);
let logged = false;
try {
await prisma.alertLogs.create({
data: {
type: input.type.slice(0, 255),
severity: String(input.severity).slice(0, 255),
message: input.message,
context: input.context ? (input.context as object) : undefined,
sentViaDiscord,
sentViaEmail,
isRead: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
logged = true;
} catch (e) {
// DB unreachable / schema drift: keep the alert best-effort. We already
// notified Discord/email above, so the alert isn't lost.
console.error("[alert] failed to persist alert_logs row:", (e as Error).message);
}
return { logged, sentViaDiscord, sentViaEmail };
}
// === Helpers =====================================================================
/**
* Raise a CRITICAL alert that the Arcturus emulator appears to be offline
* (e.g. raised by a health-check cron when the RCON socket can't connect).
*/
export function emulatorOffline(detail?: string): Promise<SendAlertResult> {
return sendAlert({
type: "emulator",
severity: "critical",
message: detail
? `Emulator appears offline: ${detail}`
: "Emulator appears offline — RCON connection could not be established.",
context: {
rconHost: env.RCON_HOST,
rconPort: env.RCON_PORT,
...(detail ? { detail } : {}),
},
});
}
/**
* Raise a WARNING alert that a possible DDoS / abusive request pattern was
* detected from a single IP (count = requests seen in the sampling window).
*/
export function ddosDetected(ip: string, count: number): Promise<SendAlertResult> {
return sendAlert({
type: "ddos",
severity: count >= 1000 ? "critical" : "warning",
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
context: { ip, count },
});
}
+171
View File
@@ -0,0 +1,171 @@
// PayPal v2 REST helper (Orders API) for the top-up flow. SDK-free: uses the
// global fetch only. Credentials and base URL come from process.env because they
// are not declared in src/env.ts:
// PAYPAL_CLIENT_ID – REST app client id
// PAYPAL_SECRET – REST app secret
// PAYPAL_API – API base, defaults to the sandbox host
// PAYPAL_CURRENCY – ISO currency for orders, defaults to USD
// PAYPAL_CREDITS_PER_USD – credits granted per 1.00 unit, defaults to 100
//
// The website has no dedicated balance column (see prisma/schema.prisma — User
// carries `credits`, the in-game wallet), so a top-up credits the buyer's
// `credits` wallet via sendCurrency(), exactly like the voucher flow.
export const PAYPAL_API =
process.env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
export const PAYPAL_CURRENCY = (process.env.PAYPAL_CURRENCY ?? "USD").toUpperCase();
/** Credits granted per 1.00 of the order currency (configurable, sane default). */
export function creditsPerUnit(): number {
const n = Number(process.env.PAYPAL_CREDITS_PER_USD ?? "100");
return Number.isFinite(n) && n > 0 ? n : 100;
}
export class PayPalConfigError extends Error {}
function credentials(): { clientId: string; secret: string } {
const clientId = process.env.PAYPAL_CLIENT_ID;
const secret = process.env.PAYPAL_SECRET;
if (!clientId || !secret) {
throw new PayPalConfigError(
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
);
}
return { clientId, secret };
}
/** True when both PayPal credentials are present. */
export function isPayPalConfigured(): boolean {
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET);
}
/** OAuth2 client-credentials token (short-lived; we fetch one per request). */
async function getAccessToken(): Promise<string> {
const { clientId, secret } = credentials();
const basic = Buffer.from(`${clientId}:${secret}`).toString("base64");
const res = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
cache: "no-store",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`PayPal auth failed (${res.status}): ${body.slice(0, 300)}`);
}
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("PayPal auth returned no access_token.");
return json.access_token;
}
export interface CreatedOrder {
id: string;
approveUrl: string | null;
}
/**
* Create a CAPTURE order for `amount` of the configured currency. Returns the
* order id and the payer approval URL (rel === "approve") to redirect to.
*/
export async function createOrder(
amount: number,
opts: { description?: string; returnUrl?: string; cancelUrl?: string } = {},
): Promise<CreatedOrder> {
const token = await getAccessToken();
const value = amount.toFixed(2);
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
cache: "no-store",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: PAYPAL_CURRENCY, value },
description: opts.description?.slice(0, 127),
},
],
application_context: {
shipping_preference: "NO_SHIPPING",
user_action: "PAY_NOW",
...(opts.returnUrl ? { return_url: opts.returnUrl } : {}),
...(opts.cancelUrl ? { cancel_url: opts.cancelUrl } : {}),
},
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`PayPal create order failed (${res.status}): ${body.slice(0, 300)}`);
}
const json = (await res.json()) as {
id: string;
links?: { rel: string; href: string }[];
};
const approveUrl =
json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href ?? null;
return { id: json.id, approveUrl };
}
export interface CaptureResult {
id: string;
status: string;
amount: number;
currency: string;
captureId: string | null;
payerEmail: string | null;
}
/** Capture a previously-approved order id. */
export async function captureOrder(orderId: string): Promise<CaptureResult> {
const token = await getAccessToken();
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`PayPal capture failed (${res.status}): ${body.slice(0, 300)}`);
}
const json = (await res.json()) as {
id: string;
status: string;
payer?: { email_address?: string };
purchase_units?: {
payments?: {
captures?: {
id: string;
amount?: { value?: string; currency_code?: string };
}[];
};
}[];
};
const capture = json.purchase_units?.[0]?.payments?.captures?.[0];
const amount = capture?.amount?.value ? Number(capture.amount.value) : 0;
const currency = capture?.amount?.currency_code ?? PAYPAL_CURRENCY;
return {
id: json.id,
status: json.status,
amount,
currency,
captureId: capture?.id ?? null,
payerEmail: json.payer?.email_address ?? null,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
/**
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
* logging must not block the action it records.
*/
export async function logStaffActivity(opts: {
staffId: number;
action: string;
description: string;
targetType?: string;
targetId?: number;
}): Promise<void> {
try {
let ip: string | null = null;
try {
const h = await headers();
ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
} catch {
ip = null;
}
await prisma.staffActivities.create({
data: {
userId: BigInt(opts.staffId),
action: opts.action.slice(0, 50),
description: opts.description,
targetType: opts.targetType ?? null,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: ip,
createdAt: new Date(),
},
});
} catch {
// swallow — audit logging is best-effort
}
}
+21
View File
@@ -0,0 +1,21 @@
import { type NextRequest, NextResponse } from "next/server";
// Edge middleware: Prisma can't run here, so we only forward the request path
// (so server components / the access guard can read it via headers()) and
// normalize the real client IP. The DB-backed banned/maintenance checks happen
// in src/lib/access-guard.ts (Node runtime) from the root layout.
export function middleware(req: NextRequest) {
const headers = new Headers(req.headers);
headers.set("x-pathname", req.nextUrl.pathname);
const ip =
req.headers.get("cf-connecting-ip") ??
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
req.headers.get("x-real-ip") ??
"";
if (ip) headers.set("x-real-client-ip", ip);
return NextResponse.next({ request: { headers } });
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico).*)"],
};