Initial commit: Minecraft dashboard

Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded
Forge 1.20.1 server. Includes server controls, player management, mod
manager with Modrinth search and dependency resolution, world backups,
snapshots, analytics, logs, and chat bridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 00:46:58 -06:00
commit dd69c17c3b
77 changed files with 7007 additions and 0 deletions

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const ANALYTICS_FILE = "/home/minecraft/server/analytics.jsonl";
type MetricEntry = {
ts: string;
tps: number;
ramUsedMB: number;
ramTotalMB: number;
cpuPercent: number;
playersOnline: number;
players: string[];
};
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const hours = Math.min(
parseInt(req.nextUrl.searchParams.get("hours") || "6"),
48
);
if (!existsSync(ANALYTICS_FILE)) {
return NextResponse.json([]);
}
try {
const lines = readFileSync(ANALYTICS_FILE, "utf8")
.split("\n")
.filter(Boolean);
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
const entries: MetricEntry[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line) as MetricEntry;
if (entry.ts >= cutoff) entries.push(entry);
} catch {}
}
return NextResponse.json(entries);
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { existsSync, createReadStream, statSync } from "fs";
import { join } from "path";
import { auth } from "@/lib/auth";
import { Readable } from "stream";
export const dynamic = "force-dynamic";
const BACKUP_DIR = "/home/minecraft/server/backups";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const name = req.nextUrl.searchParams.get("name");
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
}
const filePath = join(BACKUP_DIR, name);
if (!existsSync(filePath)) {
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
}
const stat = statSync(filePath);
const stream = createReadStream(filePath);
const webStream = Readable.toWeb(stream) as ReadableStream;
return new Response(webStream, {
headers: {
"Content-Type": "application/gzip",
"Content-Disposition": `attachment; filename="${name}"`,
"Content-Length": stat.size.toString(),
},
});
}

View file

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { existsSync } from "fs";
import { join } from "path";
import { execSync, exec } from "child_process";
import { auth } from "@/lib/auth";
import { waitForServer } from "@/lib/mods";
export const dynamic = "force-dynamic";
const BACKUP_DIR = "/home/minecraft/server/backups";
const WORLD_DIR = "/home/minecraft/server/world";
const SERVER_DIR = "/home/minecraft/server";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { name } = await req.json();
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
}
const filePath = join(BACKUP_DIR, name);
if (!existsSync(filePath)) {
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
}
try {
// Stop server
execSync("sudo systemctl stop minecraft.service", { timeout: 30000 });
// Wait for it to stop
await new Promise((r) => setTimeout(r, 5000));
// Remove current world
execSync(`rm -rf ${WORLD_DIR}`);
// Extract backup
execSync(`tar xzf ${filePath} -C ${SERVER_DIR}`);
// Start server
await new Promise<void>((resolve, reject) => {
exec("sudo systemctl start minecraft.service", (err) => {
if (err) reject(err);
else resolve();
});
});
const online = await waitForServer(90000);
return NextResponse.json({
success: true,
online,
message: online
? `World restored from "${name}". Server is online.`
: `World restored from "${name}". Server is starting...`,
});
} catch (e) {
return NextResponse.json(
{ success: false, message: (e as Error).message },
{ status: 500 }
);
}
}

75
app/api/backups/route.ts Normal file
View file

@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { readdirSync, statSync, unlinkSync, existsSync } from "fs";
import { join } from "path";
import { execSync, exec } from "child_process";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const BACKUP_DIR = "/home/minecraft/server/backups";
const BACKUP_SCRIPT = "/home/minecraft/dashboard/scripts/backup-world.sh";
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (!existsSync(BACKUP_DIR)) {
return NextResponse.json([]);
}
const files = readdirSync(BACKUP_DIR)
.filter((f) => f.endsWith(".tar.gz"))
.map((f) => {
const stat = statSync(join(BACKUP_DIR, f));
return {
name: f,
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
sizeBytes: stat.size,
createdAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return NextResponse.json(files);
}
// Create backup now
export async function POST() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
try {
execSync(`bash ${BACKUP_SCRIPT}`, { encoding: "utf8", timeout: 60000 });
return NextResponse.json({ ok: true, message: "Backup created" });
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}
// Delete backup
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { name } = await req.json();
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
}
const filePath = join(BACKUP_DIR, name);
if (!existsSync(filePath)) {
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
}
unlinkSync(filePath);
return NextResponse.json({ ok: true });
}

124
app/api/chat/route.ts Normal file
View file

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import { auth } from "@/lib/auth";
import { sendCommand } from "@/lib/rcon";
export const dynamic = "force-dynamic";
const LOG_FILE = "/home/minecraft/server/logs/latest.log";
type ChatMessage = {
time: string;
type: "chat" | "join" | "leave" | "death" | "server";
player: string;
message: string;
};
function parseLogLine(line: string): ChatMessage | null {
// [HH:MM:SS] [Server thread/INFO] [minecraft/DedicatedServer]: <Player> message
const chatMatch = line.match(
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*<(\w+)>\s*(.*)/
);
if (chatMatch) {
return { time: chatMatch[1], type: "chat", player: chatMatch[2], message: chatMatch[3] };
}
// Player joins
const joinMatch = line.match(
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+joined the game/
);
if (joinMatch) {
return { time: joinMatch[1], type: "join", player: joinMatch[2], message: "joined the game" };
}
// Player leaves
const leaveMatch = line.match(
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+left the game/
);
if (leaveMatch) {
return { time: leaveMatch[1], type: "leave", player: leaveMatch[2], message: "left the game" };
}
// Deaths
const deathMatch = line.match(
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*(\w+)\s+(was |died|drowned|burned|fell|starved|suffocated|hit|blew|withered|tried|experienced|went|walked|froze|was prick|was stung|was impaled|was squashed|was skewered|was squished|was pummeled|discovered)(.*)/
);
if (deathMatch) {
return {
time: deathMatch[1],
type: "death",
player: deathMatch[2],
message: deathMatch[3] + (deathMatch[4] || ""),
};
}
// Server say command
const sayMatch = line.match(
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*\[Server\]\s*(.*)/
);
if (sayMatch) {
return { time: sayMatch[1], type: "server", player: "Server", message: sayMatch[2] };
}
return null;
}
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (!existsSync(LOG_FILE)) {
return NextResponse.json([]);
}
const maxLines = parseInt(req.nextUrl.searchParams.get("lines") || "100");
try {
const content = readFileSync(LOG_FILE, "utf8");
const lines = content.split("\n");
const messages: ChatMessage[] = [];
// Parse from the end, collect up to maxLines relevant messages
for (let i = lines.length - 1; i >= 0 && messages.length < maxLines; i--) {
const msg = parseLogLine(lines[i]);
if (msg) messages.unshift(msg);
}
return NextResponse.json(messages);
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { message } = await req.json();
if (!message || typeof message !== "string" || message.length > 256) {
return NextResponse.json({ error: "Invalid message" }, { status: 400 });
}
// Sanitize: strip newlines/carriage returns to prevent RCON command injection
const sanitized = message.replace(/[\r\n]/g, "").trim();
if (!sanitized) {
return NextResponse.json({ error: "Empty message" }, { status: 400 });
}
try {
const response = await sendCommand(`say ${sanitized}`);
return NextResponse.json({ ok: true, response });
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

30
app/api/logs/route.ts Normal file
View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { execSync } from "child_process";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const lines = Math.min(
parseInt(req.nextUrl.searchParams.get("lines") || "50"),
200
);
try {
const logs = execSync(
`sudo journalctl -u minecraft.service --no-pager -n ${lines}`,
{ encoding: "utf8", timeout: 5000 }
);
return NextResponse.json({ logs });
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,180 @@
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { downloadMod } from "@/lib/modrinth";
import type { ModSide } from "@/lib/modrinth";
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
import { waitForServerAdaptive, rebuildModpack, addModMetadata } from "@/lib/mods";
import { exec } from "child_process";
export const dynamic = "force-dynamic";
type ModToInstall = {
projectId: string;
versionId: string;
filename: string;
url: string;
title: string;
side: ModSide;
};
function restartServer(): Promise<void> {
return new Promise((resolve, reject) => {
exec("sudo systemctl restart minecraft.service", (err) => {
if (err) reject(err);
else resolve();
});
});
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const { mods, snapshotName } = (await req.json()) as {
mods: ModToInstall[];
snapshotName: string;
};
if (!Array.isArray(mods) || mods.length === 0) {
return new Response(JSON.stringify({ error: "No mods to install" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
async function send(event: string, data: object) {
try {
await writer.write(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {}
}
// Run the pipeline in the background, streaming events as we go
(async () => {
const name = snapshotName || `before-install-${Date.now()}`;
try {
// 1. Create snapshot
await send("step", { id: "snapshot", status: "active", message: "Creating safety snapshot..." });
try {
createSnapshot(name);
} catch (e) {
await send("step", { id: "snapshot", status: "error", message: (e as Error).message });
await send("done", { success: false, message: `Failed to create snapshot: ${(e as Error).message}` });
return;
}
await send("step", { id: "snapshot", status: "done", message: "Snapshot created" });
// 2. Download mods
await send("step", { id: "download", status: "active", message: `Downloading ${mods.length} mod(s)...` });
try {
for (const mod of mods) {
await downloadMod(mod.url, mod.filename, mod.side);
addModMetadata(mod.filename, { projectId: mod.projectId, side: mod.side });
}
} catch (e) {
await send("step", { id: "download", status: "error", message: (e as Error).message });
await send("step", { id: "rollback", status: "active", message: "Rolling back..." });
try {
const snaps = listSnapshots();
if (snaps.length > 0) restoreSnapshot(snaps[0].dirName);
} catch {}
await send("step", { id: "rollback", status: "done", message: "Rolled back to snapshot" });
await send("done", { success: false, message: `Download failed: ${(e as Error).message}`, rolledBack: true });
return;
}
await send("step", { id: "download", status: "done", message: "All mods downloaded" });
// 3. Check if server-side mods need a restart
const hasServerMods = mods.some((m) => m.side !== "client");
if (!hasServerMods) {
await send("step", { id: "modpack", status: "active", message: "Rebuilding modpack..." });
try { rebuildModpack(); } catch {}
await send("step", { id: "modpack", status: "done", message: "Modpack updated" });
await send("done", {
success: true,
message: `Installed ${mods.length} client-only mod(s). No server restart needed.`,
installed: mods.map((m) => m.filename),
});
return;
}
// 4. Restart server
await send("step", { id: "restart", status: "active", message: "Restarting server..." });
try {
await restartServer();
} catch (e) {
await send("step", { id: "restart", status: "error", message: (e as Error).message });
await send("step", { id: "rollback", status: "active", message: "Rolling back..." });
try {
const snaps = listSnapshots();
if (snaps.length > 0) restoreSnapshot(snaps[0].dirName);
} catch {}
await send("step", { id: "rollback", status: "done", message: "Rolled back to snapshot" });
await send("done", { success: false, message: `Server restart failed: ${(e as Error).message}`, rolledBack: true });
return;
}
await send("step", { id: "restart", status: "done", message: "Restart command sent" });
// 5. Wait for server with progress reporting
await send("step", { id: "health", status: "active", message: "Waiting for server..." });
const online = await waitForServerAdaptive(async (progress) => {
await send("step", { id: "health", status: "active", message: progress.message });
});
if (online) {
await send("step", { id: "health", status: "done", message: "Server is online" });
await send("step", { id: "modpack", status: "active", message: "Rebuilding modpack..." });
try { rebuildModpack(); } catch {}
await send("step", { id: "modpack", status: "done", message: "Modpack updated" });
await send("done", {
success: true,
message: `Installed ${mods.length} mod(s). Server is online.`,
installed: mods.map((m) => m.filename),
});
} else {
await send("step", { id: "health", status: "error", message: "Server failed to start" });
await send("step", { id: "rollback", status: "active", message: "Rolling back to snapshot..." });
try {
const snaps = listSnapshots();
if (snaps.length > 0) {
restoreSnapshot(snaps[0].dirName);
await restartServer();
await waitForServerAdaptive(async (progress) => {
await send("step", { id: "rollback", status: "active", message: `Rollback: ${progress.message}` });
});
try { rebuildModpack(); } catch {}
}
} catch {}
await send("step", { id: "rollback", status: "done", message: "Rolled back and server restarted" });
await send("done", {
success: false,
message: `Server failed to start after installing ${mods.length} mod(s). Rolled back to snapshot.`,
rolledBack: true,
});
}
} catch (e) {
await send("done", { success: false, message: (e as Error).message });
} finally {
try { writer.close(); } catch {}
}
})();
return new Response(stream.readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View file

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { existsSync } from "fs";
import { join } from "path";
import { exec } from "child_process";
import { auth } from "@/lib/auth";
import { removeMod, isClientOnlyMod, waitForServer, rebuildModpack } from "@/lib/mods";
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
import { MODS_DIR, CLIENT_MODS_DIR } from "@/lib/constants";
function restartServer(): Promise<void> {
return new Promise((resolve, reject) => {
exec("sudo systemctl restart minecraft.service", (err) => {
if (err) reject(err);
else resolve();
});
});
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { filename } = await req.json();
if (!filename || !filename.endsWith(".jar")) {
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
}
if (filename.includes("/") || filename.includes("\\")) {
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
}
const inServerDir = existsSync(join(MODS_DIR, filename));
const inClientDir = existsSync(join(CLIENT_MODS_DIR, filename));
if (!inServerDir && !inClientDir) {
return NextResponse.json({ error: "Mod not found" }, { status: 404 });
}
// Create snapshot before removing
const snapName = `before-remove-${filename.replace(".jar", "")}`;
createSnapshot(snapName);
const clientOnly = !inServerDir && inClientDir;
// Remove the mod
removeMod(filename);
if (clientOnly) {
// Client-only mod — no restart needed
try { rebuildModpack(); } catch {}
return NextResponse.json({
success: true,
message: `Removed client-only mod "${filename}". No server restart needed.`,
});
}
// Server mod — restart and verify
try {
await restartServer();
} catch (e) {
const snaps = listSnapshots();
const snap = snaps[0];
if (snap) restoreSnapshot(snap.dirName);
return NextResponse.json({
success: false,
message: `Restart failed: ${(e as Error).message}`,
rolledBack: true,
});
}
const online = await waitForServer(90000);
if (online) {
try { rebuildModpack(); } catch {}
return NextResponse.json({
success: true,
message: `Removed "${filename}". Server is online.`,
});
} else {
// Rollback
const snaps = listSnapshots();
const snap = snaps[0];
if (snap) {
restoreSnapshot(snap.dirName);
try {
await restartServer();
await waitForServer(90000);
try { rebuildModpack(); } catch {}
} catch {}
}
return NextResponse.json({
success: false,
message: `Server failed after removing "${filename}". Rolled back.`,
rolledBack: true,
});
}
}

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { resolveDependencies } from "@/lib/modrinth";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { projectIds, titles } = await req.json();
if (!Array.isArray(projectIds) || projectIds.length === 0) {
return NextResponse.json(
{ error: "No mods selected" },
{ status: 400 }
);
}
try {
const result = await resolveDependencies(projectIds, titles || {});
return NextResponse.json(result);
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

16
app/api/mods/route.ts Normal file
View file

@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { getModDetails } from "@/lib/mods";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const mods = getModDetails();
return NextResponse.json(mods);
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { searchMods } from "@/lib/modrinth";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const query = req.nextUrl.searchParams.get("q");
if (!query || query.length < 2) {
return NextResponse.json([]);
}
try {
const results = await searchMods(query);
return NextResponse.json(results);
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

98
app/api/players/route.ts Normal file
View file

@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { readFileSync } from "fs";
import { auth } from "@/lib/auth";
import { sendCommand } from "@/lib/rcon";
export const dynamic = "force-dynamic";
const OPS_FILE = "/home/minecraft/server/ops.json";
const WHITELIST_FILE = "/home/minecraft/server/whitelist.json";
const BANNED_FILE = "/home/minecraft/server/banned-players.json";
type OpsEntry = { uuid: string; name: string; level: number };
type WhitelistEntry = { uuid: string; name: string };
type BannedEntry = { uuid: string; name: string; reason: string; created: string; expires: string };
function readJson<T>(path: string): T[] {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return [];
}
}
// GET — list ops, whitelist, banned players
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const ops = readJson<OpsEntry>(OPS_FILE).map((e) => ({
name: e.name,
uuid: e.uuid,
level: e.level,
}));
const whitelist = readJson<WhitelistEntry>(WHITELIST_FILE).map((e) => ({
name: e.name,
uuid: e.uuid,
}));
const banned = readJson<BannedEntry>(BANNED_FILE).map((e) => ({
name: e.name,
uuid: e.uuid,
reason: e.reason,
}));
return NextResponse.json({ ops, whitelist, banned });
}
// POST — execute a player management command
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { action, player, reason } = await req.json();
if (!player || !/^[a-zA-Z0-9_]{1,16}$/.test(player)) {
return NextResponse.json(
{ error: "Invalid player name" },
{ status: 400 }
);
}
const allowedActions = ["op", "deop", "whitelist add", "whitelist remove", "ban", "pardon"];
if (!allowedActions.includes(action)) {
return NextResponse.json(
{ error: "Invalid action" },
{ status: 400 }
);
}
let command = `${action} ${player}`;
if (action === "ban" && reason) {
command += ` ${reason}`;
}
try {
const response = await sendCommand(command);
// Force server to sync JSON files
if (action.startsWith("whitelist")) {
await sendCommand("whitelist reload");
}
// Wait for server to write JSON files to disk
await new Promise((r) => setTimeout(r, 500));
return NextResponse.json({ ok: true, response });
} catch (e) {
return NextResponse.json(
{ error: `RCON failed: ${(e as Error).message}. Is the server online?` },
{ status: 500 }
);
}
}

76
app/api/schedule/route.ts Normal file
View file

@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { execSync } from "child_process";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const SCRIPT = "/home/minecraft/dashboard/scripts/scheduled-restart.sh";
const CRON_MARKER = "# mc-scheduled-restart";
function getCurrentSchedule(): { enabled: boolean; hour: number; minute: number } | null {
try {
const crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
const match = crontab.match(
new RegExp(`^(\\d+)\\s+(\\d+)\\s+\\*\\s+\\*\\s+\\*\\s+.*${CRON_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, "m")
);
if (match) {
return { enabled: true, minute: parseInt(match[1]), hour: parseInt(match[2]) };
}
} catch {}
return null;
}
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const schedule = getCurrentSchedule();
return NextResponse.json(schedule || { enabled: false, hour: 4, minute: 0 });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { enabled, hour, minute } = await req.json();
try {
// Get existing crontab without our entry
let crontab = "";
try {
crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
} catch {}
// Remove old entry
const lines = crontab.split("\n").filter((l) => !l.includes(CRON_MARKER));
if (enabled) {
const h = Math.min(Math.max(parseInt(hour) || 4, 0), 23);
const m = Math.min(Math.max(parseInt(minute) || 0, 0), 59);
lines.push(`${m} ${h} * * * bash ${SCRIPT} ${CRON_MARKER}`);
}
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
const { writeFileSync, unlinkSync } = require("fs");
const tmpFile = `/tmp/crontab-${Date.now()}.tmp`;
writeFileSync(tmpFile, newCrontab, { mode: 0o600 });
execSync(`crontab ${tmpFile}`, { encoding: "utf8" });
unlinkSync(tmpFile);
return NextResponse.json({
ok: true,
enabled,
hour: parseInt(hour) || 4,
minute: parseInt(minute) || 0,
});
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { exec } from "child_process";
import { auth } from "@/lib/auth";
const ALLOWED_ACTIONS = ["start", "stop", "restart"];
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ action: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { action } = await params;
if (!ALLOWED_ACTIONS.includes(action)) {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
return new Promise<NextResponse>((resolve) => {
exec(`sudo systemctl ${action} minecraft.service`, (err) => {
if (err) {
resolve(
NextResponse.json({ error: err.message }, { status: 500 })
);
} else {
resolve(NextResponse.json({ ok: true, action }));
}
});
});
}

View file

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { exec } from "child_process";
import { auth } from "@/lib/auth";
import { restoreSnapshot } from "@/lib/snapshots";
import { rebuildModpack, waitForServer } from "@/lib/mods";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { dirName } = await req.json();
if (!dirName || dirName.includes("/") || dirName.includes("..")) {
return NextResponse.json({ error: "Invalid snapshot" }, { status: 400 });
}
try {
restoreSnapshot(dirName);
await new Promise<void>((resolve, reject) => {
exec("sudo systemctl restart minecraft.service", (err) => {
if (err) reject(err);
else resolve();
});
});
const online = await waitForServer(90000);
try {
rebuildModpack();
} catch {}
return NextResponse.json({
success: true,
online,
message: online
? "Snapshot restored. Server is online."
: "Snapshot restored. Server is starting...",
});
} catch (e) {
return NextResponse.json(
{ success: false, message: (e as Error).message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { listSnapshots, deleteSnapshot } from "@/lib/snapshots";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
try {
return NextResponse.json(listSnapshots());
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}
export async function DELETE(req: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { dirName } = await req.json();
if (!dirName) {
return NextResponse.json({ error: "Missing dirName" }, { status: 400 });
}
// Prevent path traversal
if (dirName.includes("/") || dirName.includes("\\") || dirName.includes("..")) {
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
}
try {
deleteSnapshot(dirName);
return NextResponse.json({ ok: true });
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

50
app/api/status/route.ts Normal file
View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { status } from "minecraft-server-util";
import { execSync } from "child_process";
import { MC_SERVER_IP, MC_SERVER_PORT } from "@/lib/constants";
import { sendCommand } from "@/lib/rcon";
export const dynamic = "force-dynamic";
export async function GET() {
// Tier 1: MC protocol ping (fastest, gives player/version info)
try {
const result = await status(MC_SERVER_IP, MC_SERVER_PORT, {
timeout: 5000,
});
return NextResponse.json({
online: true,
players: { online: result.players.online, max: result.players.max },
version: result.version.name,
motd: result.motd.clean,
});
} catch {}
// Tier 2: RCON (server is online but protocol ping failed)
try {
const response = await sendCommand("list");
const match = response.match(/There are (\d+) of a max of (\d+) players/);
return NextResponse.json({
online: true,
players: {
online: match ? parseInt(match[1], 10) : 0,
max: match ? parseInt(match[2], 10) : 20,
},
});
} catch {}
// Tier 3: systemctl (process alive but not yet accepting connections)
let starting = false;
try {
const out = execSync("systemctl is-active minecraft.service", {
encoding: "utf8",
}).trim();
starting = out === "active" || out === "activating";
} catch {}
return NextResponse.json({
online: false,
starting,
players: { online: 0, max: 0 },
});
}