Minecraft 26.2 Fabric server stack with CraftDeck management panel

- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist
  enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard
- Gated auth for offline mode: approve username -> whitelisted with a short
  registration window -> EasyAuth /register + /login; auto-revoke on expiry
- CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players
  & stats, lag diagnostics (server TPS vs player ping via spark), performance
  graphs, Modrinth mod search/manager, backups & restore, sandboxed file
  browser/editor, .env config editor with compose apply, cron schedules,
  Discord alerts, scrypt-hashed panel login
- scripts/setup.sh generates secrets; docs/ covers auth, mods, updating,
  backups, networking (Tailscale), troubleshooting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-10 11:07:07 -05:00
commit 279d137ce5
98 files changed
+9925

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
import { NextRequest } from "next/server";
import { db } from "@/server/db";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return handle(async () => {
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
const type = req.nextUrl.searchParams.get("type");
const rows = type
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
return ok(rows);
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest } from "next/server";
import {
listApproved,
approvePlayer,
revokePlayer,
reopenWindow,
resetPassword,
forceReregister,
importFromWhitelist,
} from "@/server/authflow";
import { whitelistList, easyauthList } from "@/server/mc";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
let whitelist: string[] | null = null;
let registered: string[] | null = null;
try {
whitelist = await whitelistList();
} catch {}
try {
registered = await easyauthList();
} catch {}
return ok({ approved: listApproved(), whitelist, registered });
});
}
type Body = {
op: "approve" | "revoke" | "reopen" | "reset-password" | "force-reregister" | "import-whitelist";
username?: string;
note?: string;
};
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
switch (body.op) {
case "approve": {
if (!body.username) return fail("Username required");
const player = await approvePlayer(body.username.trim(), body.note);
return ok(player);
}
case "revoke": {
if (!body.username) return fail("Username required");
await revokePlayer(body.username);
return ok({ revoked: true });
}
case "reopen": {
if (!body.username) return fail("Username required");
await reopenWindow(body.username);
return ok({ reopened: true });
}
case "reset-password": {
if (!body.username) return fail("Username required");
const temp = await resetPassword(body.username);
return ok({ tempPassword: temp });
}
case "force-reregister": {
if (!body.username) return fail("Username required");
await forceReregister(body.username);
return ok({ pending: true });
}
case "import-whitelist": {
const added = await importFromWhitelist();
return ok({ imported: added });
}
default:
return fail("Unknown operation");
}
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/server/db";
import { verifyPassword } from "@/server/passwords";
import { createSessionToken, SESSION_COOKIE, sessionCookieAttributes } from "@/server/session";
import { env } from "@/server/env";
import { fail, readJson, handle } from "@/server/api";
/** Simple in-memory login rate limit: 10 attempts / 15 min per IP. */
const attempts = new Map<string, { count: number; resetAt: number }>();
function rateLimited(ip: string): boolean {
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || entry.resetAt < now) {
attempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
return false;
}
entry.count++;
return entry.count > 10;
}
export async function POST(req: NextRequest) {
return handle(async () => {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "local";
if (rateLimited(ip)) return fail("Too many attempts — try again in 15 minutes", 429);
const { username, password } = await readJson<{ username?: string; password?: string }>(req);
if (!username || !password) return fail("Username and password required");
const user = db()
.prepare("SELECT * FROM panel_users WHERE username = ?")
.get(username.toLowerCase()) as { username: string; pass_hash: string; must_change: number } | undefined;
if (!user || !verifyPassword(password, user.pass_hash)) {
return fail("Wrong username or password", 401);
}
attempts.delete(ip);
const token = await createSessionToken(env.sessionSecret, user.username);
const res = NextResponse.json({ ok: true, data: { mustChange: user.must_change === 1 } });
res.headers.set("Set-Cookie", `${SESSION_COOKIE}=${token}; ${sessionCookieAttributes()}`);
return res;
});
}
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/server/session";
export function POST() {
const res = NextResponse.json({ ok: true });
res.headers.set("Set-Cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
return res;
}
+17
View File
@@ -0,0 +1,17 @@
import { NextRequest } from "next/server";
import { db } from "@/server/db";
import { verifySessionToken, SESSION_COOKIE } from "@/server/session";
import { env } from "@/server/env";
import { ok, fail, handle } from "@/server/api";
export async function GET(req: NextRequest) {
return handle(async () => {
const session = await verifySessionToken(env.sessionSecret, req.cookies.get(SESSION_COOKIE)?.value);
if (!session) return fail("Unauthorized", 401);
const user = db()
.prepare("SELECT username, must_change FROM panel_users WHERE username = ?")
.get(session.u) as { username: string; must_change: number } | undefined;
if (!user) return fail("Unauthorized", 401);
return ok({ username: user.username, mustChange: user.must_change === 1 });
});
}
@@ -0,0 +1,28 @@
import { NextRequest } from "next/server";
import { db } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/passwords";
import { verifySessionToken, SESSION_COOKIE } from "@/server/session";
import { env } from "@/server/env";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
return handle(async () => {
const session = await verifySessionToken(env.sessionSecret, req.cookies.get(SESSION_COOKIE)?.value);
if (!session) return fail("Unauthorized", 401);
const { current, next } = await readJson<{ current?: string; next?: string }>(req);
if (!current || !next) return fail("Current and new password required");
if (next.length < 8) return fail("New password must be at least 8 characters");
const user = db()
.prepare("SELECT pass_hash FROM panel_users WHERE username = ?")
.get(session.u) as { pass_hash: string } | undefined;
if (!user || !verifyPassword(current, user.pass_hash)) {
return fail("Current password is wrong", 401);
}
db()
.prepare("UPDATE panel_users SET pass_hash = ?, must_change = 0 WHERE username = ?")
.run(hashPassword(next), session.u);
return ok({ changed: true });
});
}
@@ -0,0 +1,32 @@
import { NextRequest } from "next/server";
import { Readable } from "node:stream";
import { getBackupStream, deleteBackup } from "@/server/backups";
import { ok, fail, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const name = req.nextUrl.searchParams.get("name");
if (!name) return fail("Missing backup name");
try {
const { stream, size } = getBackupStream(name);
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: {
"Content-Type": "application/gzip",
"Content-Length": String(size),
"Content-Disposition": `attachment; filename="${name}"`,
},
});
} catch (err) {
return fail((err as Error).message, 404);
}
}
export async function DELETE(req: NextRequest) {
return handle(async () => {
const name = req.nextUrl.searchParams.get("name");
if (!name) return fail("Missing backup name");
deleteBackup(name);
return ok({ deleted: name });
});
}
@@ -0,0 +1,13 @@
import { NextRequest } from "next/server";
import { restoreBackup } from "@/server/backups";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
return handle(async () => {
const { name, confirm } = await readJson<{ name?: string; confirm?: string }>(req);
if (!name) return fail("Missing backup name");
if (confirm !== "restore") return fail('Type "restore" to confirm');
await restoreBackup(name);
return ok({ restored: name });
});
}
+24
View File
@@ -0,0 +1,24 @@
import { listBackups, triggerBackup, backupsDirSize } from "@/server/backups";
import { readEnvFile } from "@/server/envfile";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
const envValues = readEnvFile();
return ok({
backups: listBackups(),
totalSize: backupsDirSize(),
interval: envValues["BACKUP_INTERVAL"] ?? "24h",
retentionDays: envValues["BACKUP_RETENTION_DAYS"] ?? "7",
});
});
}
export async function POST() {
return handle(async () => {
const name = await triggerBackup();
return ok({ created: name });
});
}
@@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
import { safetyBackup } from "@/server/backups";
import { notify } from "@/server/notify";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
return handle(async () => {
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
if (backupFirst) await safetyBackup("config apply");
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
if (!res.ok) {
notify("error", "Config apply failed", res.output.slice(-800));
return fail(`compose failed: ${res.output.slice(-400)}`, 500);
}
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
return ok({ output: res.output.slice(-1000) });
});
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest } from "next/server";
import { readEnvFile, writeEnvFile, EDITABLE_ENV_KEYS } from "@/server/envfile";
import { logActivity } from "@/server/db";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
const all = readEnvFile();
const values: Record<string, string> = {};
for (const key of EDITABLE_ENV_KEYS) values[key] = all[key] ?? "";
return ok(values);
});
}
export async function POST(req: NextRequest) {
return handle(async () => {
const { updates } = await readJson<{ updates?: Record<string, string> }>(req);
if (!updates || typeof updates !== "object") return fail("Missing updates");
const allowed = new Set<string>(EDITABLE_ENV_KEYS);
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(updates)) {
if (!allowed.has(key)) return fail(`Key not editable: ${key}`);
if (/[\r\n]/.test(value)) return fail(`Invalid value for ${key}`);
filtered[key] = value;
}
writeEnvFile(filtered);
logActivity("config", null, Object.keys(filtered).join(", "));
return ok({ updated: Object.keys(filtered), needsApply: true });
});
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { rcon, stripColors } from "@/server/rcon";
import { logActivity } from "@/server/db";
import { pushLogLine } from "@/server/bus";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
return handle(async () => {
const { command } = await readJson<{ command?: string }>(req);
if (!command?.trim()) return fail("Empty command");
const cmd = command.trim().replace(/^\//, "");
const output = stripColors(await rcon().exec(cmd));
logActivity("console", null, cmd);
// Echo the exchange into the live console stream so it reads like a terminal.
pushLogLine(`> ${cmd}`);
if (output.trim()) for (const line of output.split("\n")) pushLogLine(line);
return ok({ output });
});
}
@@ -0,0 +1,23 @@
import { NextRequest } from "next/server";
import { Readable } from "node:stream";
import { fileStream } from "@/server/files";
import { fail } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams.get("path");
if (!p) return fail("Missing path");
try {
const { stream, size, name } = fileStream(p);
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(size),
"Content-Disposition": `attachment; filename="${name.replace(/"/g, "")}"`,
},
});
} catch (err) {
return fail((err as Error).message, 404);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest } from "next/server";
import { listDir, readTextFile, writeTextFile, deletePath, makeDir } from "@/server/files";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return handle(async () => {
const dir = req.nextUrl.searchParams.get("dir");
const file = req.nextUrl.searchParams.get("file");
if (file !== null) return ok(readTextFile(file));
return ok(listDir(dir ?? ""));
});
}
type Body =
| { op: "write"; path: string; content: string }
| { op: "mkdir"; path: string }
| { op: "delete"; path: string };
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
if (!body.path && body.path !== "") return fail("Missing path");
switch (body.op) {
case "write":
writeTextFile(body.path, body.content ?? "");
return ok({ written: body.path });
case "mkdir":
makeDir(body.path);
return ok({ created: body.path });
case "delete":
deletePath(body.path);
return ok({ deleted: body.path });
default:
return fail("Unknown operation");
}
});
}
@@ -0,0 +1,21 @@
import { NextRequest } from "next/server";
import path from "node:path";
import { writeBinaryFile } from "@/server/files";
import { ok, fail, handle } from "@/server/api";
const MAX_UPLOAD = 256 * 1024 * 1024; // mod jars and datapacks, not world dumps
export async function POST(req: NextRequest) {
return handle(async () => {
const form = await req.formData();
const dir = (form.get("dir") as string) ?? "";
const file = form.get("file");
if (!(file instanceof File)) return fail("Missing file");
if (file.size > MAX_UPLOAD) return fail("File too large (256 MB max)");
const name = path.basename(file.name);
if (!name || name.startsWith(".")) return fail("Invalid file name");
const buf = Buffer.from(await file.arrayBuffer());
writeBinaryFile(path.join(dir, name), buf);
return ok({ uploaded: name, size: file.size });
});
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export function GET() {
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,9 @@
import { sparkHealthReport } from "@/server/mc";
import { ok, handle } from "@/server/api";
export async function POST() {
return handle(async () => {
const report = await sparkHealthReport();
return ok({ report });
});
}
+72
View File
@@ -0,0 +1,72 @@
import { db } from "@/server/db";
import { sparkPings, listOnlinePlayers } from "@/server/mc";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
/**
* Lag diagnostics: separates "the server is lagging" (low TPS / high MSPT /
* high CPU) from "a player's connection is lagging" (high ping) and renders
* a verdict the admin can read at a glance.
*/
export async function GET() {
return handle(async () => {
const recent = db()
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
.all(Date.now() - 3 * 60 * 1000) as Array<{ tps: number | null; mspt: number | null; cpu: number | null }>;
const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
const tps = avg(recent.map((r) => r.tps).filter((v): v is number => v !== null));
const mspt = avg(recent.map((r) => r.mspt).filter((v): v is number => v !== null));
const cpu = avg(recent.map((r) => r.cpu).filter((v): v is number => v !== null));
let livePings: Record<string, number> = {};
let online: string[] = [];
try {
[livePings, online] = await Promise.all([
sparkPings(),
listOnlinePlayers().then((l) => l.names),
]);
} catch {}
// ping history per player (last hour) for sparklines
const history = db()
.prepare("SELECT player, ts, ping FROM ping_samples WHERE ts > ? ORDER BY ts ASC")
.all(Date.now() - 60 * 60 * 1000) as Array<{ player: string; ts: number; ping: number }>;
const historyByPlayer: Record<string, Array<{ ts: number; ping: number }>> = {};
for (const h of history) {
(historyByPlayer[h.player] ??= []).push({ ts: h.ts, ping: h.ping });
}
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
const players = online.map((name) => {
const ping = livePings[name] ?? null;
return {
name,
ping,
history: historyByPlayer[name] ?? [],
verdict:
ping === null
? "unknown"
: ping > 250
? "bad-connection"
: ping > 120
? "weak-connection"
: "good",
};
});
let verdict: string;
if (tps === null && mspt === null) {
verdict = "no-data";
} else if (!serverHealthy) {
verdict = "server-lagging";
} else if (players.some((p) => p.verdict === "bad-connection" || p.verdict === "weak-connection")) {
verdict = "player-connections";
} else {
verdict = "all-good";
}
return ok({ tps, mspt, cpu, serverHealthy, verdict, players });
});
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest } from "next/server";
import { db } from "@/server/db";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
const RANGES: Record<string, { ms: number; bucketMs: number }> = {
"1h": { ms: 60 * 60 * 1000, bucketMs: 10 * 1000 },
"6h": { ms: 6 * 60 * 60 * 1000, bucketMs: 60 * 1000 },
"24h": { ms: 24 * 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 },
"7d": { ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
};
export async function GET(req: NextRequest) {
return handle(async () => {
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
const since = Date.now() - range.ms;
// Bucket-average so long ranges stay light on the wire.
const rows = db()
.prepare(
`SELECT (ts / ?) * ? AS bucket,
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
MAX(net_rx) AS net_rx, MAX(net_tx) AS net_tx,
AVG(tps) AS tps, AVG(mspt) AS mspt, MAX(players) AS players,
AVG(disk_used) AS disk_used, AVG(disk_free) AS disk_free
FROM metrics WHERE ts > ?
GROUP BY bucket ORDER BY bucket ASC`
)
.all(range.bucketMs, range.bucketMs, since);
return ok(rows);
});
}
+16
View File
@@ -0,0 +1,16 @@
import { applyMcConfig } from "@/server/compose";
import { notify } from "@/server/notify";
import { ok, fail, handle } from "@/server/api";
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
export async function POST() {
return handle(async () => {
const res = await applyMcConfig();
if (!res.ok) {
notify("error", "Mod apply failed", res.output.slice(-800));
return fail(`compose failed: ${res.output.slice(-400)}`, 500);
}
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
return ok({ output: res.output.slice(-1000) });
});
}
@@ -0,0 +1,43 @@
import { NextRequest } from "next/server";
import { addManagedMod, removeManagedMod, toggleMod, deleteModFile } from "@/server/modrinth";
import { logActivity } from "@/server/db";
import { ok, fail, readJson, handle } from "@/server/api";
type Body = {
op: "add" | "remove" | "toggle" | "delete";
slug?: string;
file?: string;
};
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
switch (body.op) {
case "add": {
if (!body.slug) return fail("Missing slug");
const managed = addManagedMod(body.slug);
logActivity("mod_add", null, body.slug);
return ok({ managed, needsApply: true });
}
case "remove": {
if (!body.slug) return fail("Missing slug");
const managed = removeManagedMod(body.slug);
logActivity("mod_remove", null, body.slug);
return ok({ managed, needsApply: true });
}
case "toggle": {
if (!body.file) return fail("Missing file");
toggleMod(body.file);
return ok({ needsRestart: true });
}
case "delete": {
if (!body.file) return fail("Missing file");
deleteModFile(body.file);
logActivity("mod_remove", null, body.file);
return ok({ needsRestart: true });
}
default:
return fail("Unknown operation");
}
});
}
+14
View File
@@ -0,0 +1,14 @@
import { listInstalledMods, managedSlugs, currentMcVersion } from "@/server/modrinth";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
return ok({
installed: await listInstalledMods(),
managed: managedSlugs(),
mcVersion: currentMcVersion(),
});
});
}
@@ -0,0 +1,11 @@
import { NextRequest } from "next/server";
import { searchMods } from "@/server/modrinth";
import { ok, fail, handle } from "@/server/api";
export async function GET(req: NextRequest) {
return handle(async () => {
const q = req.nextUrl.searchParams.get("q")?.trim();
if (!q) return fail("Missing query");
return ok(await searchMods(q));
});
}
@@ -0,0 +1,35 @@
import { NextRequest } from "next/server";
import { db } from "@/server/db";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return handle(async () => {
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 200);
const rows = db().prepare("SELECT * FROM notifications ORDER BY ts DESC LIMIT ?").all(limit);
const unread = (db().prepare("SELECT COUNT(*) AS n FROM notifications WHERE read = 0").get() as { n: number }).n;
return ok({ notifications: rows, unread });
});
}
type Body = { op: "read"; id: number } | { op: "read-all" } | { op: "clear" };
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
switch (body.op) {
case "read":
db().prepare("UPDATE notifications SET read = 1 WHERE id = ?").run(body.id);
return ok({});
case "read-all":
db().prepare("UPDATE notifications SET read = 1").run();
return ok({});
case "clear":
db().prepare("DELETE FROM notifications").run();
return ok({});
default:
return fail("Unknown operation");
}
});
}
+46
View File
@@ -0,0 +1,46 @@
import { listOnlinePlayers } from "@/server/mc";
import { readAllPlayerStats } from "@/server/playerstats";
import { db } from "@/server/db";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
let online: string[] = [];
let max = 0;
try {
const list = await listOnlinePlayers();
online = list.names;
max = list.max;
} catch {}
const stats = readAllPlayerStats();
// latest ping per player (last 10 minutes)
const pings = db()
.prepare(
`SELECT player, ping FROM ping_samples
WHERE ts > ? AND id IN (SELECT MAX(id) FROM ping_samples GROUP BY player)`
)
.all(Date.now() - 10 * 60 * 1000) as Array<{ player: string; ping: number }>;
const pingMap = Object.fromEntries(pings.map((p) => [p.player.toLowerCase(), p.ping]));
const approved = db()
.prepare("SELECT username, status, last_seen_at FROM approved_players")
.all() as Array<{ username: string; status: string; last_seen_at: number | null }>;
const approvedMap = Object.fromEntries(approved.map((a) => [a.username.toLowerCase(), a]));
return ok({
online,
max,
players: stats.map((s) => ({
...s,
isOnline: online.some((n) => n.toLowerCase() === s.name.toLowerCase()),
ping: pingMap[s.name.toLowerCase()] ?? null,
authStatus: approvedMap[s.name.toLowerCase()]?.status ?? null,
lastSeenAt: approvedMap[s.name.toLowerCase()]?.last_seen_at ?? null,
})),
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest } from "next/server";
import { db } from "@/server/db";
import { validateCron, reloadSchedules, runScheduleAction, type Schedule } from "@/server/scheduler";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
const ACTIONS = ["restart", "backup", "command", "announce"] as const;
export async function GET() {
return handle(async () => {
return ok(db().prepare("SELECT * FROM schedules ORDER BY id ASC").all());
});
}
type Body =
| { op: "create"; name: string; cron: string; action: Schedule["action"]; payload?: string }
| { op: "update"; id: number; name?: string; cron?: string; action?: Schedule["action"]; payload?: string }
| { op: "toggle"; id: number }
| { op: "delete"; id: number }
| { op: "run"; id: number };
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Body>(req);
switch (body.op) {
case "create": {
if (!body.name?.trim()) return fail("Name required");
if (!validateCron(body.cron)) return fail("Invalid cron expression");
if (!ACTIONS.includes(body.action)) return fail("Invalid action");
db()
.prepare("INSERT INTO schedules(name, cron, action, payload, created_at) VALUES(?, ?, ?, ?, ?)")
.run(body.name.trim(), body.cron, body.action, body.payload ?? null, Date.now());
reloadSchedules();
return ok({ created: true });
}
case "update": {
const row = db().prepare("SELECT * FROM schedules WHERE id = ?").get(body.id) as Schedule | undefined;
if (!row) return fail("Schedule not found", 404);
const cronExpr = body.cron ?? row.cron;
if (!validateCron(cronExpr)) return fail("Invalid cron expression");
const action = body.action ?? row.action;
if (!ACTIONS.includes(action)) return fail("Invalid action");
db()
.prepare("UPDATE schedules SET name = ?, cron = ?, action = ?, payload = ? WHERE id = ?")
.run(body.name ?? row.name, cronExpr, action, body.payload ?? row.payload, body.id);
reloadSchedules();
return ok({ updated: true });
}
case "toggle": {
db().prepare("UPDATE schedules SET enabled = 1 - enabled WHERE id = ?").run(body.id);
reloadSchedules();
return ok({ toggled: true });
}
case "delete": {
db().prepare("DELETE FROM schedules WHERE id = ?").run(body.id);
reloadSchedules();
return ok({ deleted: true });
}
case "run": {
const row = db().prepare("SELECT * FROM schedules WHERE id = ?").get(body.id) as Schedule | undefined;
if (!row) return fail("Schedule not found", 404);
const result = await runScheduleAction(row.action, row.payload);
db()
.prepare("UPDATE schedules SET last_run_at = ?, last_result = ? WHERE id = ?")
.run(Date.now(), result.slice(0, 1000), row.id);
return ok({ result });
}
default:
return fail("Unknown operation");
}
});
}
@@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { powerContainer, type PowerAction } from "@/server/docker";
import { logActivity } from "@/server/db";
import { notify } from "@/server/notify";
import { env } from "@/server/env";
import { ok, fail, readJson, handle } from "@/server/api";
const ACTIONS: PowerAction[] = ["start", "stop", "restart", "kill"];
export async function POST(req: NextRequest) {
return handle(async () => {
const { action } = await readJson<{ action?: PowerAction }>(req);
if (!action || !ACTIONS.includes(action)) return fail("Invalid power action");
await powerContainer(env.mcContainer, action);
logActivity("power", null, action);
notify("info", `Server ${action} requested from the panel`, "", { discord: false });
return ok({ action });
});
}
+59
View File
@@ -0,0 +1,59 @@
import { inspectContainer, containerEnv } from "@/server/docker";
import { listOnlinePlayers } from "@/server/mc";
import { db } from "@/server/db";
import { env } from "@/server/env";
import { getWorldSizeBytes } from "@/server/sampler";
import { readWorldInfo } from "@/server/playerstats";
import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
let container;
let dockerAvailable = true;
try {
container = await inspectContainer(env.mcContainer);
} catch {
dockerAvailable = false;
container = { exists: false, running: false, status: "unknown" as const };
}
let players: { online: number; max: number; names: string[] } = { online: 0, max: 0, names: [] };
let version: string | null = null;
let serverType: string | null = null;
if (container.running) {
try {
players = await listOnlinePlayers();
} catch {}
try {
const cenv = await containerEnv(env.mcContainer);
version = cenv["VERSION"] ?? null;
serverType = cenv["TYPE"] ?? null;
} catch {}
}
const latest = db()
.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1")
.get() as Record<string, number | null> | undefined;
const pendingCount = (
db().prepare("SELECT COUNT(*) AS n FROM approved_players WHERE status = 'pending'").get() as { n: number }
).n;
return ok({
dockerAvailable,
container,
players,
version,
serverType,
metrics: latest ?? null,
pendingApprovals: pendingCount,
world: readWorldInfo(getWorldSizeBytes()),
uptimeMs:
container.running && "startedAt" in container && container.startedAt
? Date.now() - new Date(container.startedAt).getTime()
: null,
});
});
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest } from "next/server";
import { getSetting, setSetting, getBoolSetting } from "@/server/db";
import { env } from "@/server/env";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => {
return ok({
registrationWindowMinutes: Number(
getSetting("registration_window_minutes", String(env.registrationWindowMinutes))
),
notifyJoins: getBoolSetting("notify_joins", true),
discordEnabled: getBoolSetting("discord_enabled", true),
discordJoins: getBoolSetting("discord_joins", true),
discordWebhookUrl: getSetting("discord_webhook_url", env.discordWebhookUrl),
});
});
}
export async function POST(req: NextRequest) {
return handle(async () => {
const body = await readJson<Record<string, unknown>>(req);
if (body.registrationWindowMinutes !== undefined) {
const n = Number(body.registrationWindowMinutes);
if (!Number.isFinite(n) || n < 0.5 || n > 120) return fail("Window must be between 0.5 and 120 minutes");
setSetting("registration_window_minutes", String(n));
}
if (body.notifyJoins !== undefined) setSetting("notify_joins", body.notifyJoins ? "1" : "0");
if (body.discordEnabled !== undefined) setSetting("discord_enabled", body.discordEnabled ? "1" : "0");
if (body.discordJoins !== undefined) setSetting("discord_joins", body.discordJoins ? "1" : "0");
if (body.discordWebhookUrl !== undefined) {
const url = String(body.discordWebhookUrl);
if (url && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(url)) {
return fail("Not a Discord webhook URL");
}
setSetting("discord_webhook_url", url);
}
return ok({ saved: true });
});
}
@@ -0,0 +1,9 @@
import { notify } from "@/server/notify";
import { ok, handle } from "@/server/api";
export async function POST() {
return handle(async () => {
notify("info", "Test notification", "If you can read this in Discord, the webhook works. 🎉");
return ok({ sent: true });
});
}
@@ -0,0 +1,59 @@
import { NextRequest } from "next/server";
import { bus } from "@/server/bus";
export const dynamic = "force-dynamic";
/**
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
* parsed game events, and new notifications.
*/
export async function GET(req: NextRequest) {
const encoder = new TextEncoder();
let closed = false;
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
closed = true;
}
};
const onMetrics = (m: unknown) => send("metrics", m);
const onEvent = (e: unknown) => send("mc-event", e);
const onNotification = (n: unknown) => send("notification", n);
bus().on("metrics", onMetrics);
bus().on("mc-event", onEvent);
bus().on("notification", onNotification);
const keepalive = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
closed = true;
}
}, 15000);
req.signal.addEventListener("abort", () => {
closed = true;
bus().off("metrics", onMetrics);
bus().off("mc-event", onEvent);
bus().off("notification", onNotification);
clearInterval(keepalive);
try {
controller.close();
} catch {}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
@@ -0,0 +1,51 @@
import { NextRequest } from "next/server";
import { bus, logRing } from "@/server/bus";
export const dynamic = "force-dynamic";
/** Live console stream (SSE). Backfills recent lines, then follows. */
export async function GET(req: NextRequest) {
const encoder = new TextEncoder();
let closed = false;
const stream = new ReadableStream({
start(controller) {
const send = (line: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
} catch {
closed = true;
}
};
for (const line of logRing()) send(line);
const onLog = (line: string) => send(line);
bus().on("log", onLog);
const keepalive = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
closed = true;
}
}, 15000);
req.signal.addEventListener("abort", () => {
closed = true;
bus().off("log", onLog);
clearInterval(keepalive);
try {
controller.close();
} catch {}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { readWorldInfo } from "@/server/playerstats";
import { getWorldSizeBytes } from "@/server/sampler";
import { inspectContainer, powerContainer } from "@/server/docker";
import { safetyBackup } from "@/server/backups";
import { logActivity } from "@/server/db";
import { notify } from "@/server/notify";
import { env } from "@/server/env";
import { ok, fail, readJson, handle } from "@/server/api";
export const dynamic = "force-dynamic";
export async function GET() {
return handle(async () => ok(readWorldInfo(getWorldSizeBytes())));
}
/** Danger zone: archive the current world and generate a fresh one. */
export async function POST(req: NextRequest) {
return handle(async () => {
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
if (op !== "reset") return fail("Unknown operation");
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
const worldDir = path.join(env.mcDataDir, "world");
if (!fs.existsSync(worldDir)) return fail("No world folder found");
await safetyBackup("world reset");
const state = await inspectContainer(env.mcContainer);
if (state.running) await powerContainer(env.mcContainer, "stop");
const archived = `world.old-${new Date().toISOString().replace(/[:.]/g, "-")}`;
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
await powerContainer(env.mcContainer, "start");
logActivity("world_reset", null, `old world kept as ${archived}`);
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
return ok({ archived });
});
}