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:
commit
279d137ce5
98 files changed
+9925
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user