diff --git a/.env.example b/.env.example index 85b2a611..c7568ff8 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,12 @@ PASSWORD_HASH=bcrypt # Leave unset to disable badge uploads. 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_HOST=127.0.0.1 RCON_PORT=3001 diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..c11a0f21 --- /dev/null +++ b/public/sw.js @@ -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("/"))), + ); + } +}); diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 79ae56a8..4c7afa7f 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -20,6 +20,8 @@ * worker is the first consumer. */ import "dotenv/config"; +import { copyFile, mkdir, readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; import { Cron } from "croner"; import { prisma } from "@/lib/prisma"; import { emulatorOffline } from "@/lib/services/alert"; @@ -245,6 +247,40 @@ async function githubUpdateCheck(): Promise { } } +// --- (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 { + 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 ------------------------------------------------------ const jobs: Cron[] = [ @@ -254,6 +290,7 @@ const jobs: Cron[] = [ new Cron("*/30 * * * * *", { name: "radio-record-songs", protect: true }, recordSongPlay), new Cron("* * * * *", { name: "radio-auto-dj", protect: true }, autoDj), 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(", ")}`); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 00000000..4eae72c1 --- /dev/null +++ b/src/app/api/health/route.ts @@ -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(), + }); +} diff --git a/src/app/developers/page.tsx b/src/app/developers/page.tsx new file mode 100644 index 00000000..b13c4482 --- /dev/null +++ b/src/app/developers/page.tsx @@ -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 `. */ + 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 = { + GET: "ok", + POST: "", + DELETE: "danger", +}; + +function AuthTag({ endpoint }: { endpoint: Endpoint }) { + if (endpoint.bearer) { + return ( + + 🔒 Bearer + + ); + } + if (endpoint.session) { + return ( + + 🔒 Session + + ); + } + return ( + + Public + + ); +} + +function EndpointRow({ endpoint }: { endpoint: Endpoint }) { + return ( +
+ + {endpoint.method} + {endpoint.path} + + + {endpoint.description} + + +
+ ); +} + +export default function DevelopersPage() { + const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0); + + return ( +
+ +

+ All endpoints live under /api 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. +

+
+ + +
+

+ Most reads are open and need no credentials. Endpoints marked{" "} + 🔒 Bearer require a personal access token sent in + the request header: +

+ + Authorization: Bearer <your-token> + +

+ To get a token, sign in to the website and{" "} + POST to /api/tokens — the plaintext token is returned{" "} + once and never shown again, so store it safely. You can list and revoke + your tokens at /api/me/tokens. These token-management endpoints are marked{" "} + 🔒 Session because they use your signed-in web + session rather than a Bearer token. +

+

+ Tokens are tied to your account: Bearer endpoints only ever return or modify your own + data (your tickets, your shouts, your DJ points). +

+
+
+ + {GROUPS.map((group) => ( + +
+ {group.endpoints.map((endpoint) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2d61204f..4ea02b81 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { getLocale, getMessages } from "next-intl/server"; import { Nunito } from "next/font/google"; import type { ReactNode } from "react"; import { Navigation } from "@/components/navigation"; +import { PwaRegister } from "@/components/pwa-register"; import RadioPlayerGate from "@/components/public/radio-player-gate"; import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; @@ -61,6 +62,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) + diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 00000000..ff1f0293 --- /dev/null +++ b/src/app/manifest.ts @@ -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 { + 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" }, + ], + }; +} diff --git a/src/components/pwa-register.tsx b/src/components/pwa-register.tsx new file mode 100644 index 00000000..2c36fc21 --- /dev/null +++ b/src/components/pwa-register.tsx @@ -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; +} diff --git a/src/env.ts b/src/env.ts index a79f842e..92167e2a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -46,6 +46,10 @@ const schema = z.object({ // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // when unset. 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). OPENAI_API_KEY: z.string().optional(), // Optional alerting (jobs worker / alert service). diff --git a/src/lib/access-guard.ts b/src/lib/access-guard.ts index 2bf42fbd..df94c190 100644 --- a/src/lib/access-guard.ts +++ b/src/lib/access-guard.ts @@ -1,6 +1,7 @@ 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"; @@ -21,14 +22,24 @@ function isExempt(path: string): boolean { export async function enforceSiteAccess(): Promise { 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 (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; if (rank < minLogin) target = "/maintenance"; } diff --git a/src/lib/services/abuse-guard.ts b/src/lib/services/abuse-guard.ts new file mode 100644 index 00000000..302ce48f --- /dev/null +++ b/src/lib/services/abuse-guard.ts @@ -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(); +const recentlyBlocked = new Set(); + +let blacklist = new Set(); +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 { + 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 { + 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 */ + } + } +}