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:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
56
app/api/analytics/route.ts
Normal file
56
app/api/analytics/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
38
app/api/backups/download/route.ts
Normal file
38
app/api/backups/download/route.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
66
app/api/backups/restore/route.ts
Normal file
66
app/api/backups/restore/route.ts
Normal 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
75
app/api/backups/route.ts
Normal 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
124
app/api/chat/route.ts
Normal 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
30
app/api/logs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
180
app/api/mods/batch-install/route.ts
Normal file
180
app/api/mods/batch-install/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
101
app/api/mods/remove/route.ts
Normal file
101
app/api/mods/remove/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
28
app/api/mods/resolve/route.ts
Normal file
28
app/api/mods/resolve/route.ts
Normal 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
16
app/api/mods/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/api/mods/search/route.ts
Normal file
21
app/api/mods/search/route.ts
Normal 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
98
app/api/players/route.ts
Normal 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
76
app/api/schedule/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/server/[action]/route.ts
Normal file
32
app/api/server/[action]/route.ts
Normal 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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
47
app/api/snapshots/restore/route.ts
Normal file
47
app/api/snapshots/restore/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/api/snapshots/route.ts
Normal file
48
app/api/snapshots/route.ts
Normal 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
50
app/api/status/route.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue