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

+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");
}