257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
import { env } from "@/env";
|
||
import { logger } from "@/lib/logger";
|
||
import { prisma } from "@/lib/prisma";
|
||
import { sendMail } from "@/lib/services/email";
|
||
|
||
// === 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?.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) {
|
||
logger.error("Discord webhook returned non-OK status", {
|
||
module: "alert",
|
||
status: res.status,
|
||
});
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
logger.error("Discord webhook failed", {
|
||
module: "alert",
|
||
error: (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) {
|
||
logger.error("Staff email failed", {
|
||
module: "alert",
|
||
error: (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.
|
||
logger.error("Failed to persist alert_logs row", {
|
||
module: "alert",
|
||
error: (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 },
|
||
});
|
||
}
|