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

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