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 },
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user