Beyond parity — the web-feasible versions of the "host-only" items plus extras AtomCMS doesn't have: - App-level abuse/DDoS guard (src/lib/services/abuse-guard.ts): counts requests per IP and auto-adds flooders to website_ip_blacklist (enforced by the access guard) + fires ddosDetected(). OFF by default, tunable via settings. The iptables layer stays host-only; this is the real app-tier mitigation. Access guard now also enforces the IP blacklist (cached). - PWA: a themeable web manifest (src/app/manifest.ts) + a service worker (public/sw.js, cache-first assets / network-first pages) registered after hydration — the hotel is now installable. - /api/health: DB + emulator(RCON) + runtime status probe. - /developers: a public API documentation page covering every REST endpoint with its method, path and auth requirement. - jobs-worker: daily emulator JAR backup (runs host-side in the worker, like AtomCMS's backup command) — copies + prunes; no-ops unless EMULATOR_JAR_PATH + EMULATOR_BACKUP_DIR are set. Verified live (prod, amx_test): /api/health ok, manifest + sw served, docs page renders, normal pages unaffected by the guard. tsc 0, vitest 49/49, next build 0.
61 lines
2.3 KiB
TypeScript
61 lines
2.3 KiB
TypeScript
import { headers } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import { auth } from "@/lib/auth";
|
|
import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
// Paths that must never be gated (otherwise banned/maintenance loop forever).
|
|
const EXEMPT = ["/banned", "/maintenance", "/login", "/register", "/forgot", "/reset", "/api"];
|
|
|
|
function isExempt(path: string): boolean {
|
|
return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`));
|
|
}
|
|
|
|
/**
|
|
* Site-wide access enforcement (called from the root layout): routes non-staff
|
|
* to /maintenance when maintenance mode is on, and banned users to /banned.
|
|
* Runs in the Node runtime so it can query the DB. The redirect decision is
|
|
* computed inside try/catch and the redirect() (which throws NEXT_REDIRECT) is
|
|
* issued OUTSIDE it.
|
|
*/
|
|
export async function enforceSiteAccess(): Promise<void> {
|
|
const h = await headers();
|
|
const path = h.get("x-pathname") ?? "/";
|
|
const ip =
|
|
h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
|
|
|
|
// Abuse/DDoS guard: count this request and block flooding IPs (no-op unless
|
|
// enabled in settings). Best-effort — never let it throw past the guard.
|
|
void recordRequest(ip).catch(() => {});
|
|
|
|
if (isExempt(path)) return;
|
|
|
|
let target: string | null = null;
|
|
try {
|
|
// App-level IP blacklist (auto-populated by the abuse guard + /admin/ip).
|
|
if (await isIpBlacklisted(ip)) target = "/banned";
|
|
|
|
const session = await auth();
|
|
const rank = session?.user?.rank ?? 0;
|
|
|
|
if (!target && (await siteSettings.getBool("maintenance_enabled", false))) {
|
|
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
|
|
if (rank < minLogin) target = "/maintenance";
|
|
}
|
|
|
|
if (!target && session?.user?.id) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const ban = await prisma.ban.findFirst({
|
|
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
|
select: { id: true },
|
|
});
|
|
if (ban) target = "/banned";
|
|
}
|
|
} catch {
|
|
// On any failure, fail open (don't lock the whole site out on a DB hiccup).
|
|
}
|
|
|
|
if (target) redirect(target);
|
|
}
|