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

53
app/admin/page.tsx Normal file
View file

@ -0,0 +1,53 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Navbar } from "@/components/Navbar";
import { ClientOnly } from "@/components/ClientOnly";
import { ServerControls } from "@/components/ServerControls";
import { Analytics } from "@/components/Analytics";
import { PlayerManager } from "@/components/PlayerManager";
import { ChatBridge } from "@/components/ChatBridge";
import { ModManager } from "@/components/ModManager";
import { BackupManager } from "@/components/BackupManager";
import { LogViewer } from "@/components/LogViewer";
import Link from "next/link";
export default async function AdminPage() {
const session = await auth();
if (!session) redirect("/login");
return (
<>
<Navbar />
<div className="border-b border-border bg-card py-4 sm:py-6">
<div className="max-w-5xl mx-auto px-3 sm:px-6">
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">Admin Panel</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your Minecraft server
</p>
</div>
</div>
<ClientOnly>
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6 w-full overflow-x-hidden">
<ServerControls />
<Analytics />
<PlayerManager />
<ChatBridge />
<ModManager />
<BackupManager />
<LogViewer />
<div className="text-center">
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition"
>
Back to dashboard
</Link>
</div>
</div>
</ClientOnly>
</>
);
}

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 },
});
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

106
app/globals.css Normal file
View file

@ -0,0 +1,106 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--radius: 0.625rem;
}
.dark {
--background: oklch(0.13 0.005 285);
--foreground: oklch(0.93 0.005 285);
--card: oklch(0.18 0.008 285);
--card-foreground: oklch(0.93 0.005 285);
--popover: oklch(0.18 0.008 285);
--popover-foreground: oklch(0.93 0.005 285);
--primary: oklch(0.65 0.18 280);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.24 0.01 285);
--secondary-foreground: oklch(0.88 0.005 285);
--muted: oklch(0.22 0.008 285);
--muted-foreground: oklch(0.65 0.01 285);
--accent: oklch(0.24 0.015 285);
--accent-foreground: oklch(0.93 0.005 285);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.28 0.01 285);
--input: oklch(0.28 0.01 285);
--ring: oklch(0.65 0.18 280);
--chart-1: oklch(0.65 0.18 280);
--chart-2: oklch(0.7 0.15 160);
--chart-3: oklch(0.75 0.15 60);
--chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.65 0.2 25);
--sidebar: oklch(0.16 0.008 285);
--sidebar-foreground: oklch(0.93 0.005 285);
--sidebar-primary: oklch(0.65 0.18 280);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.24 0.015 285);
--sidebar-accent-foreground: oklch(0.93 0.005 285);
--sidebar-border: oklch(0.28 0.01 285);
--sidebar-ring: oklch(0.65 0.18 280);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply font-sans;
overflow-x: hidden;
}
body {
@apply bg-background text-foreground;
overflow-x: hidden;
}
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: oklch(0.15 0.005 285); border-radius: 3px; }
::-webkit-scrollbar-thumb { background: oklch(0.35 0.01 285); border-radius: 3px; }

57
app/layout.tsx Normal file
View file

@ -0,0 +1,57 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "./providers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "HurkiCorgi MC",
description: "Create & Engineering | Raids | Survival - Minecraft Server",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "HurkiCorgi MC",
},
icons: {
icon: "/icon.svg",
apple: "/icon.svg",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
themeColor: "#1a1a2e",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`dark ${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col overflow-x-hidden">
<Providers>
<div className="flex flex-col flex-1 w-full overflow-x-hidden">
{children}
</div>
</Providers>
</body>
</html>
);
}

76
app/login/page.tsx Normal file
View file

@ -0,0 +1,76 @@
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const res = await signIn("credentials", {
username: formData.get("username"),
password: formData.get("password"),
redirect: false,
});
if (res?.error) {
setError("Invalid credentials");
setLoading(false);
} else {
router.push("/admin");
}
}
return (
<div className="flex-1 flex items-center justify-center p-3 sm:p-6">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-xl">Admin Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input id="username" name="username" type="text" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
</div>
{error && (
<p className="text-destructive text-sm text-center">{error}</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<div className="mt-4 text-center">
<a
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition"
>
Back to dashboard
</a>
</div>
</CardContent>
</Card>
</div>
);
}

38
app/page.tsx Normal file
View file

@ -0,0 +1,38 @@
import { Navbar } from "@/components/Navbar";
import { StatusCard } from "@/components/StatusCard";
import { DownloadCard } from "@/components/DownloadCard";
import { ModList } from "@/components/ModList";
import { ClientOnly } from "@/components/ClientOnly";
export default function Home() {
return (
<>
<Navbar />
{/* Header */}
<div className="border-b border-border bg-card py-8 sm:py-12 text-center px-4">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-primary">
HurkiCorgi MC
</h1>
<p className="mt-2 text-muted-foreground text-sm sm:text-base">
Create & Engineering | Raids | Survival
</p>
</div>
{/* Content */}
<ClientOnly>
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<StatusCard />
<DownloadCard />
</div>
<ModList />
</div>
</ClientOnly>
<footer className="text-center py-6 sm:py-8 text-muted-foreground text-xs">
HurkiCorgi MC Forge 1.20.1
</footer>
</>
);
}

24
app/providers.tsx Normal file
View file

@ -0,0 +1,24 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SessionProvider } from "next-auth/react";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</SessionProvider>
);
}