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:
1 parent
e19debb795
commit
22d53d0e9c
40 files changed
+3781
-6
No files matched your search
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user