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:
1 parent
4dfe698009
commit
54ec99de6d
11 files changed
+702
-1
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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("/"))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(", ")}`);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
|
||||||
|
// Public API documentation. Static, hand-maintained from the routes that
|
||||||
|
// actually exist under src/app/api — keep this in sync when endpoints change.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = { title: "API" };
|
||||||
|
|
||||||
|
type Method = "GET" | "POST" | "DELETE";
|
||||||
|
|
||||||
|
type Endpoint = {
|
||||||
|
method: Method;
|
||||||
|
path: string;
|
||||||
|
description: string;
|
||||||
|
/** Requires `Authorization: Bearer <token>`. */
|
||||||
|
bearer?: boolean;
|
||||||
|
/** Requires a signed-in web session (NextAuth), not a Bearer token. */
|
||||||
|
session?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
endpoints: Endpoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors the route.ts files under src/app/api. Only documents endpoints that
|
||||||
|
// really exist; auth flags reflect bearerUserId() / auth() usage in each route.
|
||||||
|
const GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
icon: "👤",
|
||||||
|
title: "Users",
|
||||||
|
subtitle: "Profiles and the signed-in account.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/users/{username}",
|
||||||
|
description: "Public profile for a user by username (look, motto, rank, badges).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/me",
|
||||||
|
description: "The currently signed-in user, or { user: null } when not authenticated.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📰",
|
||||||
|
title: "Content",
|
||||||
|
subtitle: "News articles, comments and photos.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/articles",
|
||||||
|
description: "List published news articles (paginated via query params).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/articles/{slug}",
|
||||||
|
description: "A single article by slug, with its comments.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/articles/{slug}/comment",
|
||||||
|
description: "Post a comment on an article.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/photos",
|
||||||
|
description: "Recent in-game camera photos.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🏙️",
|
||||||
|
title: "Community",
|
||||||
|
subtitle: "Hotel population, guilds, staff and teams.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/home",
|
||||||
|
description: "Aggregated home-page payload (settings, news, online stats).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/online",
|
||||||
|
description: "Users currently online.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/online/count",
|
||||||
|
description: "Just the online-user count.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/leaderboard",
|
||||||
|
description: "Player leaderboard (ranked by the requested metric).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/guilds",
|
||||||
|
description: "List guilds.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/guilds/{id}",
|
||||||
|
description: "A single guild with its members.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/staff",
|
||||||
|
description: "Staff members above the configured minimum rank.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/teams",
|
||||||
|
description: "Public staff teams / rank groups.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "💰",
|
||||||
|
title: "Economy",
|
||||||
|
subtitle: "Shop catalogue and rare-furniture values.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/shop",
|
||||||
|
description: "Shop products (filter by category via query params).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/shop/categories",
|
||||||
|
description: "Shop categories.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/values",
|
||||||
|
description: "Rare-value catalogue.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/values/{id}",
|
||||||
|
description: "A single rare value entry.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/values/categories",
|
||||||
|
description: "Rare-value categories.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📻",
|
||||||
|
title: "Radio",
|
||||||
|
subtitle: "Now-playing, listeners, DJ points and shouts.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/now-playing",
|
||||||
|
description: "The track currently on air.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/current-dj",
|
||||||
|
description: "The DJ currently live.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/listeners",
|
||||||
|
description: "Current listener count.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/config",
|
||||||
|
description: "Public radio configuration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/embed-config",
|
||||||
|
description: "Configuration for the embeddable radio player.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/auto-play",
|
||||||
|
description: "AutoDJ playback state.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/stream",
|
||||||
|
description: "Stream metadata / proxy details.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/points/leaderboard",
|
||||||
|
description: "DJ points leaderboard.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/points",
|
||||||
|
description: "The signed-in user's own DJ points.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/radio/shouts",
|
||||||
|
description: "Recent radio shout-outs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/radio/shouts",
|
||||||
|
description: "Submit a shout-out to the current DJ.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "⚙️",
|
||||||
|
title: "Settings",
|
||||||
|
subtitle: "Public site configuration.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/settings",
|
||||||
|
description: "Public, non-sensitive site settings (name, theme, links).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🔑",
|
||||||
|
title: "Tokens",
|
||||||
|
subtitle: "Issue and manage personal access tokens.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/tokens",
|
||||||
|
description: "Mint a new personal access token (the plaintext is shown once).",
|
||||||
|
session: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/me/tokens",
|
||||||
|
description: "List your personal access tokens (id, name, last used).",
|
||||||
|
session: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/api/me/tokens?id={id}",
|
||||||
|
description: "Revoke one of your tokens by id.",
|
||||||
|
session: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🎫",
|
||||||
|
title: "Tickets",
|
||||||
|
subtitle: "Help-center tickets and replies.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/tickets",
|
||||||
|
description: "List your own help-center tickets.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/tickets",
|
||||||
|
description: "Open a new help-center ticket.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/tickets/{id}",
|
||||||
|
description: "A single ticket you own, with its replies.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/tickets/{id}/reply",
|
||||||
|
description: "Reply to one of your tickets.",
|
||||||
|
bearer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "❤️",
|
||||||
|
title: "Health",
|
||||||
|
subtitle: "Service status.",
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/health",
|
||||||
|
description: "Liveness probe — reports app and database status.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const METHOD_CLASS: Record<Method, string> = {
|
||||||
|
GET: "ok",
|
||||||
|
POST: "",
|
||||||
|
DELETE: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AuthTag({ endpoint }: { endpoint: Endpoint }) {
|
||||||
|
if (endpoint.bearer) {
|
||||||
|
return (
|
||||||
|
<span className="admin-badge" title="Requires Authorization: Bearer <token>">
|
||||||
|
🔒 Bearer
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (endpoint.session) {
|
||||||
|
return (
|
||||||
|
<span className="admin-badge" title="Requires a signed-in web session">
|
||||||
|
🔒 Session
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="admin-badge ok" title="No authentication required">
|
||||||
|
Public
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem 0.75rem",
|
||||||
|
padding: "0.7rem 0",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
background: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: "0.3rem 0.55rem",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`admin-badge ${METHOD_CLASS[endpoint.method]}`}>{endpoint.method}</span>
|
||||||
|
<span>{endpoint.path}</span>
|
||||||
|
</code>
|
||||||
|
<span className="muted" style={{ flex: "1 1 14rem", minWidth: "12rem" }}>
|
||||||
|
{endpoint.description}
|
||||||
|
</span>
|
||||||
|
<AuthTag endpoint={endpoint} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevelopersPage() {
|
||||||
|
const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
<ContentCard
|
||||||
|
icon="🧩"
|
||||||
|
title="Developer API"
|
||||||
|
subtitle={`A public REST API over the hotel — ${totalEndpoints} endpoints across ${GROUPS.length} groups.`}
|
||||||
|
>
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
All endpoints live under <code>/api</code> and return JSON. Most read endpoints are open;
|
||||||
|
a handful that touch your account need a token. Browse the groups below — each row shows
|
||||||
|
the method, path, what it does and whether it needs authentication.
|
||||||
|
</p>
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
<ContentCard icon="🔐" title="Authentication" subtitle="How to call protected endpoints.">
|
||||||
|
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
Most reads are <strong>open</strong> and need no credentials. Endpoints marked{" "}
|
||||||
|
<span className="admin-badge">🔒 Bearer</span> require a personal access token sent in
|
||||||
|
the request header:
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
background: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: "0.6rem 0.75rem",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Authorization: Bearer <your-token>
|
||||||
|
</code>
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
To get a token, sign in to the website and{" "}
|
||||||
|
<code>POST</code> to <code>/api/tokens</code> — the plaintext token is returned{" "}
|
||||||
|
<strong>once</strong> and never shown again, so store it safely. You can list and revoke
|
||||||
|
your tokens at <code>/api/me/tokens</code>. These token-management endpoints are marked{" "}
|
||||||
|
<span className="admin-badge">🔒 Session</span> because they use your signed-in web
|
||||||
|
session rather than a Bearer token.
|
||||||
|
</p>
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
Tokens are tied to your account: Bearer endpoints only ever return or modify your own
|
||||||
|
data (your tickets, your shouts, your DJ points).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
{GROUPS.map((group) => (
|
||||||
|
<ContentCard
|
||||||
|
key={group.title}
|
||||||
|
icon={group.icon}
|
||||||
|
title={group.title}
|
||||||
|
subtitle={group.subtitle}
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid" }}>
|
||||||
|
{group.endpoints.map((endpoint) => (
|
||||||
|
<EndpointRow key={`${endpoint.method} ${endpoint.path}`} endpoint={endpoint} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContentCard>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user