101%: app-level DDoS guard, PWA, /api/health, API docs, worker JAR backup

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.
This commit is contained in:
Simo committed 2026-06-29 18:39:23 +02:00
1 parent 4dfe698009
commit 54ec99de6d
11 files changed
+702 -1

No files matched your search

+6
View File
@@ -27,6 +27,12 @@ PASSWORD_HASH=bcrypt
# Leave unset to disable badge uploads. # Leave unset to disable badge uploads.
BADGE_UPLOAD_DIR= BADGE_UPLOAD_DIR=
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
# worker copies the JAR daily into the backup dir, keeping the newest N.
EMULATOR_JAR_PATH=
EMULATOR_BACKUP_DIR=
EMULATOR_BACKUP_KEEP=7
# RCON link to the Arcturus emulator # RCON link to the Arcturus emulator
RCON_HOST=127.0.0.1 RCON_HOST=127.0.0.1
RCON_PORT=3001 RCON_PORT=3001
+54
View File
@@ -0,0 +1,54 @@
// Minimal service worker for the AtomCMS-Next PWA. Cache-first for immutable
// static assets, network-first for navigations (with a cached fallback so the
// shell still loads offline). Bump CACHE to invalidate.
const CACHE = "atom-v1";
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Cache-first for immutable static assets.
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/_next/static/")) {
event.respondWith(
caches.open(CACHE).then((cache) =>
cache.match(req).then(
(hit) =>
hit ||
fetch(req).then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
}),
),
),
);
return;
}
// Network-first for page navigations; fall back to cache, then the shell.
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
return res;
})
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
);
}
});
+37
View File
@@ -20,6 +20,8 @@
* worker is the first consumer. * worker is the first consumer.
*/ */
import "dotenv/config"; import "dotenv/config";
import { copyFile, mkdir, readdir, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import { Cron } from "croner"; import { Cron } from "croner";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { emulatorOffline } from "@/lib/services/alert"; import { emulatorOffline } from "@/lib/services/alert";
@@ -245,6 +247,40 @@ async function githubUpdateCheck(): Promise<void> {
} }
} }
// --- (g) emulator: JAR backup — daily --------------------------------------
//
// AtomCMS's backup tooling lives in the scheduler (host-side), which is exactly
// what this worker is — so it CAN do the filesystem copy the Next request tier
// cannot. Copies EMULATOR_JAR_PATH into EMULATOR_BACKUP_DIR with a timestamped
// name, keeping the newest EMULATOR_BACKUP_KEEP (default 7). No-ops cleanly when
// the env paths aren't set (e.g. on a dev box).
async function emulatorBackup(): Promise<void> {
const jar = process.env.EMULATOR_JAR_PATH;
const dir = process.env.EMULATOR_BACKUP_DIR;
if (!jar || !dir) return; // not configured — nothing to do
try {
await stat(jar); // ensure the source exists
await mkdir(dir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
await copyFile(jar, join(dir, `emulator-${stamp}.jar`));
// Prune to the newest N backups.
const keep = Number(process.env.EMULATOR_BACKUP_KEEP ?? "7") || 7;
const files = (await readdir(dir))
.filter((f) => f.startsWith("emulator-") && f.endsWith(".jar"))
.sort()
.reverse();
for (const old of files.slice(keep)) {
await unlink(join(dir, old)).catch(() => {});
}
log("backup", `emulator JAR backed up (${files.length + 1} kept, pruning to ${keep})`);
} catch (e) {
logErr("backup", "emulator backup failed", e);
}
}
// --- scheduler wiring ------------------------------------------------------ // --- scheduler wiring ------------------------------------------------------
const jobs: Cron[] = [ const jobs: Cron[] = [
@@ -254,6 +290,7 @@ const jobs: Cron[] = [
new Cron("*/30 * * * * *", { name: "radio-record-songs", protect: true }, recordSongPlay), new Cron("*/30 * * * * *", { name: "radio-record-songs", protect: true }, recordSongPlay),
new Cron("* * * * *", { name: "radio-auto-dj", protect: true }, autoDj), new Cron("* * * * *", { name: "radio-auto-dj", protect: true }, autoDj),
new Cron("0 * * * *", { name: "github-update-check", protect: true }, githubUpdateCheck), new Cron("0 * * * *", { name: "github-update-check", protect: true }, githubUpdateCheck),
new Cron("0 4 * * *", { name: "emulator-backup", protect: true }, emulatorBackup),
]; ];
log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`); log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`);
+26
View File
@@ -0,0 +1,26 @@
import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
export const dynamic = "force-dynamic";
/**
* Ops health probe: database reachability, emulator RCON reachability, and
* runtime info. Returns HTTP 200 always (read the `status`/`database` fields),
* so it's safe for uptime monitors that only care about reachability.
*/
export async function GET() {
const database = await prisma
.$queryRaw`SELECT 1`.then(() => true)
.catch(() => false);
const emulator = await rcon.send("ping", null).catch(() => false);
return apiJson({
status: database ? "ok" : "degraded",
database,
emulator,
node: process.version,
uptime: Math.round(process.uptime()),
time: new Date().toISOString(),
});
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -4,6 +4,7 @@ import { getLocale, getMessages } from "next-intl/server";
import { Nunito } from "next/font/google"; import { Nunito } from "next/font/google";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Navigation } from "@/components/navigation"; import { Navigation } from "@/components/navigation";
import { PwaRegister } from "@/components/pwa-register";
import RadioPlayerGate from "@/components/public/radio-player-gate"; import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
@@ -61,6 +62,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
</main> </main>
<SiteFooter /> <SiteFooter />
<RadioPlayerGate /> <RadioPlayerGate />
<PwaRegister />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>
+23
View File
@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";
import { siteSettings } from "@/lib/services/site-settings";
// Web app manifest — makes the hotel installable as a PWA (AtomCMS exposed PWA
// settings but the rewrite ships a real, themeable manifest). Name + theme
// colour follow the live website_settings; falls back to defaults with no DB.
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const name = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
const theme = (await siteSettings.get("color_primary", "#f59e0b")) ?? "#f59e0b";
return {
name,
short_name: name,
description: `${name} — a Habbo retro hotel.`,
start_url: "/",
display: "standalone",
background_color: "#0f1117",
theme_color: theme,
icons: [
{ src: "/assets/images/home_icon.gif", sizes: "any", type: "image/gif", purpose: "any" },
],
};
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
/** Registers the service worker (PWA) once, after hydration. No-op on failure. */
export function PwaRegister() {
useEffect(() => {
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}, []);
return null;
}
+4
View File
@@ -46,6 +46,10 @@ const schema = z.object({
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset. // when unset.
BADGE_UPLOAD_DIR: z.string().optional(), BADGE_UPLOAD_DIR: z.string().optional(),
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
EMULATOR_JAR_PATH: z.string().optional(),
EMULATOR_BACKUP_DIR: z.string().optional(),
EMULATOR_BACKUP_KEEP: z.coerce.number().int().positive().optional(),
// Optional AI content moderation (comments / guestbook). // Optional AI content moderation (comments / guestbook).
OPENAI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(),
// Optional alerting (jobs worker / alert service). // Optional alerting (jobs worker / alert service).
+12 -1
View File
@@ -1,6 +1,7 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -21,14 +22,24 @@ function isExempt(path: string): boolean {
export async function enforceSiteAccess(): Promise<void> { export async function enforceSiteAccess(): Promise<void> {
const h = await headers(); const h = await headers();
const path = h.get("x-pathname") ?? "/"; 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; if (isExempt(path)) return;
let target: string | null = null; let target: string | null = null;
try { try {
// App-level IP blacklist (auto-populated by the abuse guard + /admin/ip).
if (await isIpBlacklisted(ip)) target = "/banned";
const session = await auth(); const session = await auth();
const rank = session?.user?.rank ?? 0; const rank = session?.user?.rank ?? 0;
if (await siteSettings.getBool("maintenance_enabled", false)) { if (!target && (await siteSettings.getBool("maintenance_enabled", false))) {
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7; const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
if (rank < minLogin) target = "/maintenance"; if (rank < minLogin) target = "/maintenance";
} }
+85
View File
@@ -0,0 +1,85 @@
import { ddosDetected } from "@/lib/services/alert";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
/**
* App-level abuse / DDoS guard — the web-tier-feasible half of AtomCMS's DDoS
* protection. It can't touch iptables (that's a host-only optimisation), but it
* DOES the actual mitigation a CMS needs: count requests per IP and, when one
* floods past the threshold, add it to website_ip_blacklist (which the access
* guard then enforces) and fire the existing ddosDetected() alert.
*
* OFF by default; staff enable + tune it via website_settings:
* abuse_guard_enabled ("1"), abuse_guard_threshold (req, default 200),
* abuse_guard_window_seconds (default 10).
*/
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
const recentlyBlocked = new Set<string>();
let blacklist = new Set<string>();
let blacklistLoadedAt = 0;
const BLACKLIST_TTL = 30_000;
function isPrivate(ip: string): boolean {
return (
!ip ||
ip === "0.0.0.0" ||
ip === "::1" ||
ip.startsWith("127.") ||
ip.startsWith("10.") ||
ip.startsWith("192.168.")
);
}
/** Cached blacklist lookup (refreshed every 30s — no DB hit per request). */
export async function isIpBlacklisted(ip: string): Promise<boolean> {
if (isPrivate(ip)) return false;
const now = Date.now();
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
try {
const rows = await prisma.websiteIpBlacklist.findMany({ select: { ipAddress: true } });
blacklist = new Set(rows.map((r) => r.ipAddress));
blacklistLoadedAt = now;
} catch {
/* keep stale set on DB error */
}
}
return blacklist.has(ip);
}
/** Count a request; auto-blacklist + alert the IP if it floods (when enabled). */
export async function recordRequest(ip: string): Promise<void> {
if (isPrivate(ip)) return;
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
const limit = Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
const windowMs =
(Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
const now = Date.now();
if (buckets.size > 10_000) {
for (const [k, v] of buckets) if (now >= v.resetAt) buckets.delete(k);
}
const b = buckets.get(ip);
if (!b || now >= b.resetAt) {
buckets.set(ip, { count: 1, resetAt: now + windowMs });
return;
}
b.count += 1;
if (b.count >= limit && !recentlyBlocked.has(ip)) {
recentlyBlocked.add(ip);
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
try {
await prisma.websiteIpBlacklist.create({
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
});
blacklistLoadedAt = 0; // force a refresh so the block takes effect at once
await ddosDetected(ip, b.count);
} catch {
/* ignore — alert/blacklist best-effort */
}
}
}