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

30
lib/auth.ts Normal file
View file

@ -0,0 +1,30 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
name: "Admin Login",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (
credentials?.username === process.env.ADMIN_USERNAME &&
credentials?.password === process.env.ADMIN_PASSWORD
) {
return { id: "1", name: "Admin" };
}
return null;
},
}),
],
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60,
},
});

9
lib/constants.ts Normal file
View file

@ -0,0 +1,9 @@
export const MC_SERVER_IP = "127.0.0.1";
export const MC_SERVER_PORT = 25565;
export const MODS_DIR = "/home/minecraft/server/mods";
export const CLIENT_MODS_DIR = "/home/minecraft/server/client-mods";
export const MOD_METADATA_FILE = "/home/minecraft/server/mod-metadata.json";
export const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
export const INSTALLER_BAT = "/var/www/minecraft/install-modpack.bat";
export const RCON_PORT = 25575;
export const RCON_PASSWORD = process.env.RCON_PASSWORD || "";

229
lib/modrinth.ts Normal file
View file

@ -0,0 +1,229 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { MODS_DIR, CLIENT_MODS_DIR } from "./constants";
import { getModDetails } from "./mods";
const API = "https://api.modrinth.com/v2";
const GAME_VERSION = "1.20.1";
const LOADER = "forge";
export type ModSide = "client" | "server" | "both";
export type SearchResult = {
slug: string;
title: string;
description: string;
icon_url: string;
downloads: number;
project_id: string;
};
export type ModDownload = {
projectId: string;
versionId: string;
title: string;
filename: string;
url: string;
isDependency: boolean;
alreadyInstalled: boolean;
side: ModSide;
};
export type ResolveResult = {
toInstall: ModDownload[];
skipped: ModDownload[];
conflicts: string[];
};
// ── Search ──────────────────────────────────────────────────
export async function searchMods(query: string): Promise<SearchResult[]> {
const facets = JSON.stringify([
[`categories:${LOADER}`],
[`versions:${GAME_VERSION}`],
["project_type:mod"],
]);
const url = `${API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=12`;
const res = await fetch(url, {
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
});
if (!res.ok) throw new Error(`Modrinth search failed: ${res.status}`);
const data = await res.json();
return data.hits.map((h: Record<string, unknown>) => ({
slug: h.slug,
title: h.title,
description: h.description,
icon_url: h.icon_url || "",
downloads: h.downloads,
project_id: h.project_id,
}));
}
// ── Get project details (title + side info) ─────────────────
type ProjectDetails = {
title: string;
client_side: string;
server_side: string;
};
async function getProjectDetails(
projectId: string
): Promise<ProjectDetails | null> {
try {
const res = await fetch(`${API}/project/${projectId}`, {
headers: {
"User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)",
},
});
if (!res.ok) return null;
const data = await res.json();
return {
title: data.title || projectId,
client_side: data.client_side || "optional",
server_side: data.server_side || "optional",
};
} catch {
return null;
}
}
export function classifySide(
client_side: string,
server_side: string
): ModSide {
if (server_side === "unsupported") return "client";
if (client_side === "unsupported") return "server";
return "both";
}
// ── Get latest version for a project ────────────────────────
type VersionFile = { url: string; filename: string };
type VersionDep = {
project_id: string | null;
dependency_type: string;
};
type VersionData = {
id: string;
name: string;
files: VersionFile[];
dependencies: VersionDep[];
};
async function getLatestVersion(
projectId: string
): Promise<VersionData | null> {
const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
const res = await fetch(url, {
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
});
if (!res.ok) return null;
const versions: VersionData[] = await res.json();
return versions.length > 0 ? versions[0] : null;
}
// ── Resolve dependencies ────────────────────────────────────
export async function resolveDependencies(
projectIds: string[],
titles: Record<string, string>
): Promise<ResolveResult> {
// Cache installed mods once for the entire resolution
const installed = getModDetails();
const toInstall: ModDownload[] = [];
const skipped: ModDownload[] = [];
const conflicts: string[] = [];
const visited = new Set<string>();
const queue = projectIds.map((id) => ({ id, isDep: false }));
while (queue.length > 0) {
const item = queue.shift()!;
if (visited.has(item.id)) continue;
visited.add(item.id);
const [version, details] = await Promise.all([
getLatestVersion(item.id),
getProjectDetails(item.id),
]);
if (!version || version.files.length === 0) {
if (!item.isDep) {
conflicts.push(
`"${titles[item.id] || item.id}" has no Forge 1.20.1 version available`
);
}
continue;
}
const title = titles[item.id] || details?.title || item.id;
const side = details
? classifySide(details.client_side, details.server_side)
: "both";
const file = version.files[0];
const mod: ModDownload = {
projectId: item.id,
versionId: version.id,
title,
filename: file.filename,
url: file.url,
isDependency: item.isDep,
alreadyInstalled: false,
side,
};
// Check if already installed (by filename as rough proxy)
const filenameBase = file.filename.replace(/-[\d.]+.*\.jar$/, "").toLowerCase();
const isInstalled = installed.some(
(m) =>
m.filename === file.filename ||
m.filename.toLowerCase().startsWith(filenameBase)
);
if (isInstalled) {
mod.alreadyInstalled = true;
skipped.push(mod);
} else {
toInstall.push(mod);
}
// Queue required dependencies
for (const dep of version.dependencies) {
if (
dep.dependency_type === "required" &&
dep.project_id &&
!visited.has(dep.project_id)
) {
queue.push({ id: dep.project_id, isDep: true });
}
}
}
return { toInstall, skipped, conflicts };
}
// ── Download a mod ──────────────────────────────────────────
export async function downloadMod(
url: string,
filename: string,
side: ModSide = "both"
): Promise<void> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download ${filename}: ${res.status}`);
const targetDir = side === "client" ? CLIENT_MODS_DIR : MODS_DIR;
if (side === "client" && !existsSync(CLIENT_MODS_DIR)) {
mkdirSync(CLIENT_MODS_DIR, { recursive: true });
}
const buffer = Buffer.from(await res.arrayBuffer());
writeFileSync(join(targetDir, filename), buffer);
}

277
lib/mods.ts Normal file
View file

@ -0,0 +1,277 @@
import AdmZip from "adm-zip";
import { execSync } from "child_process";
import {
existsSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { status } from "minecraft-server-util";
import {
MODS_DIR,
CLIENT_MODS_DIR,
MOD_METADATA_FILE,
MC_SERVER_IP,
MC_SERVER_PORT,
} from "./constants";
import { sendCommand } from "./rcon";
import type { ModSide } from "./modrinth";
const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
const MODPACK_MODS = "/var/www/minecraft/mods";
export type ModMeta = {
modId: string;
displayName: string;
version: string;
filename: string;
size: string;
side: ModSide;
};
// ── Mod metadata persistence ───────────────────────────────
type ModMetadataEntry = { projectId: string; side: ModSide };
type ModMetadataMap = Record<string, ModMetadataEntry>;
function loadModMetadata(): ModMetadataMap {
try {
return JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
} catch {
return {};
}
}
function saveModMetadata(metadata: ModMetadataMap): void {
writeFileSync(MOD_METADATA_FILE, JSON.stringify(metadata, null, 2));
}
export function addModMetadata(
filename: string,
entry: ModMetadataEntry
): void {
const metadata = loadModMetadata();
metadata[filename] = entry;
saveModMetadata(metadata);
}
export function removeModMetadata(filename: string): void {
const metadata = loadModMetadata();
delete metadata[filename];
saveModMetadata(metadata);
}
// ── Read mod metadata from JAR files ────────────────────────
function extractToml(content: string, key: string): string | null {
const regex = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
const match = content.match(regex);
return match ? match[1] : null;
}
function extractModMeta(
dir: string,
filename: string
): Omit<ModMeta, "side"> {
const filePath = join(dir, filename);
const stat = statSync(filePath);
let modId = "unknown";
let displayName = filename
.replace(/-(\d)/, " $1")
.replace(".jar", "")
.replace(/-/g, " ");
let version = "";
try {
const zip = new AdmZip(filePath);
const toml = zip.getEntry("META-INF/mods.toml");
if (toml) {
const content = toml.getData().toString("utf8");
modId = extractToml(content, "modId") || modId;
displayName = extractToml(content, "displayName") || displayName;
version = extractToml(content, "version") || "";
}
} catch {
// Use filename-based defaults
}
return {
modId,
displayName,
version,
filename,
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
};
}
export function getModDetails(): ModMeta[] {
const metadata = loadModMetadata();
// Server mods
const serverFiles = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
const serverMods: ModMeta[] = serverFiles.map((f) => {
const meta = extractModMeta(MODS_DIR, f);
const stored = metadata[f];
const side: ModSide = stored?.side === "server" ? "server" : stored?.side || "both";
return { ...meta, side };
});
// Client mods
let clientMods: ModMeta[] = [];
if (existsSync(CLIENT_MODS_DIR)) {
const clientFiles = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
clientMods = clientFiles.map((f) => ({
...extractModMeta(CLIENT_MODS_DIR, f),
side: "client" as ModSide,
}));
}
return [...serverMods, ...clientMods];
}
// ── Remove a single mod ─────────────────────────────────────
export function removeMod(filename: string): void {
const serverPath = join(MODS_DIR, filename);
const clientPath = join(CLIENT_MODS_DIR, filename);
if (existsSync(serverPath)) {
unlinkSync(serverPath);
} else if (existsSync(clientPath)) {
unlinkSync(clientPath);
} else {
throw new Error(`Mod "${filename}" not found`);
}
removeModMetadata(filename);
}
export function isClientOnlyMod(filename: string): boolean {
return (
!existsSync(join(MODS_DIR, filename)) &&
existsSync(join(CLIENT_MODS_DIR, filename))
);
}
// ── Server health check ─────────────────────────────────────
export type HealthProgress = {
phase: "waiting" | "process-alive" | "port-open" | "rcon-ready" | "failed" | "process-died";
elapsed: number;
message: string;
};
export async function waitForServerAdaptive(
onProgress?: (p: HealthProgress) => void | Promise<void>,
options?: { initialDelayMs?: number; maxWaitMs?: number }
): Promise<boolean> {
const { initialDelayMs = 8000, maxWaitMs = 600000 } = options ?? {};
const start = Date.now();
const elapsed = () => Date.now() - start;
const elapsedSec = () => Math.round(elapsed() / 1000);
const report = async (phase: HealthProgress["phase"], message: string) => {
await onProgress?.({ phase, elapsed: elapsed(), message });
};
await report("waiting", "Waiting for server process...");
await sleep(initialDelayMs);
let rconFailsWhileActive = 0;
while (elapsed() < maxWaitMs) {
// Tier 1: is the process alive?
let serviceState: string;
try {
serviceState = execSync("systemctl is-active minecraft.service", {
encoding: "utf8",
}).trim();
} catch {
await report("process-died", "Server process is not running");
return false;
}
if (serviceState !== "active" && serviceState !== "activating") {
await report("process-died", `Server process state: ${serviceState}`);
return false;
}
await report("process-alive", `Server process alive (${elapsedSec()}s)`);
// Tier 2: try RCON directly — this is the definitive "server is ready" signal
// RCON only starts after the server is fully loaded ("Done!")
try {
await sendCommand("list");
await report("rcon-ready", "Server is fully online");
return true;
} catch {
rconFailsWhileActive++;
}
// Tier 3: if RCON keeps failing, try MC protocol ping as fallback
// (handles case where RCON is misconfigured but server is actually ready)
if (rconFailsWhileActive >= 6) {
try {
await status(MC_SERVER_IP, MC_SERVER_PORT, { timeout: 3000 });
await report("port-open", "Server is online (port verified, RCON unavailable)");
return true;
} catch {}
}
await sleep(3000);
}
await report("failed", `Server did not respond within ${Math.round(maxWaitMs / 1000)}s`);
return false;
}
export async function waitForServer(timeoutMs = 600000): Promise<boolean> {
return waitForServerAdaptive(undefined, { maxWaitMs: timeoutMs });
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ── Modpack rebuild ─────────────────────────────────────────
export function rebuildModpack(): void {
// Zip server mods
execSync(
`cd ${MODS_DIR} && rm -f /tmp/modpack-rebuild.zip && zip -j /tmp/modpack-rebuild.zip *.jar`,
{ encoding: "utf8" }
);
// Add client mods to the same zip
if (existsSync(CLIENT_MODS_DIR)) {
try {
execSync(
`cd ${CLIENT_MODS_DIR} && zip -j /tmp/modpack-rebuild.zip *.jar 2>/dev/null || true`,
{ encoding: "utf8" }
);
} catch {}
}
// Copy to web root
execSync(`sudo cp /tmp/modpack-rebuild.zip ${MODPACK_ZIP}`);
execSync(`sudo rm -f ${MODPACK_MODS}/*.jar`);
execSync(`sudo cp ${MODS_DIR}/*.jar ${MODPACK_MODS}/`);
if (existsSync(CLIENT_MODS_DIR)) {
try {
execSync(
`sudo cp ${CLIENT_MODS_DIR}/*.jar ${MODPACK_MODS}/ 2>/dev/null || true`
);
} catch {}
}
// Generate modlist from both directories
execSync(
`(ls ${MODS_DIR}/*.jar -1 2>/dev/null; ls ${CLIENT_MODS_DIR}/*.jar -1 2>/dev/null) | xargs -I{} basename {} | sort | sudo tee /var/www/minecraft/modlist.txt > /dev/null`
);
}

18
lib/rcon.ts Normal file
View file

@ -0,0 +1,18 @@
import { Rcon } from "rcon-client";
import { MC_SERVER_IP, RCON_PORT, RCON_PASSWORD } from "./constants";
export async function sendCommand(command: string): Promise<string> {
const rcon = await Rcon.connect({
host: MC_SERVER_IP,
port: RCON_PORT,
password: RCON_PASSWORD,
timeout: 5000,
});
try {
const response = await rcon.send(command);
return response;
} finally {
rcon.end();
}
}

160
lib/snapshots.ts Normal file
View file

@ -0,0 +1,160 @@
import {
existsSync,
mkdirSync,
readdirSync,
copyFileSync,
rmSync,
writeFileSync,
readFileSync,
unlinkSync,
} from "fs";
import { join } from "path";
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants";
const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots";
const MAX_SNAPSHOTS = 10;
export type SnapshotMeta = {
name: string;
createdAt: string;
modCount: number;
mods: string[];
};
function ensureDir(dir: string) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
export function createSnapshot(name: string): SnapshotMeta {
ensureDir(SNAPSHOTS_DIR);
// Sanitize name
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
const dirName = `${safeName}_${Date.now()}`;
const snapDir = join(SNAPSHOTS_DIR, dirName);
mkdirSync(snapDir);
// Back up server mods
const mods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
for (const file of mods) {
copyFileSync(join(MODS_DIR, file), join(snapDir, file));
}
// Back up client mods
if (existsSync(CLIENT_MODS_DIR)) {
const clientMods = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
if (clientMods.length > 0) {
const clientSnapDir = join(snapDir, "client-mods");
mkdirSync(clientSnapDir);
for (const file of clientMods) {
copyFileSync(join(CLIENT_MODS_DIR, file), join(clientSnapDir, file));
}
}
}
// Back up mod metadata
if (existsSync(MOD_METADATA_FILE)) {
copyFileSync(MOD_METADATA_FILE, join(snapDir, "mod-metadata.json"));
}
const meta: SnapshotMeta = {
name: safeName,
createdAt: new Date().toISOString(),
modCount: mods.length,
mods,
};
writeFileSync(join(snapDir, "meta.json"), JSON.stringify(meta, null, 2));
// Enforce max snapshots
pruneOldSnapshots();
return meta;
}
export function restoreSnapshot(dirName: string): void {
const snapDir = join(SNAPSHOTS_DIR, dirName);
if (!existsSync(snapDir)) {
throw new Error(`Snapshot "${dirName}" not found`);
}
// Clear current server mods
const currentMods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
for (const file of currentMods) {
unlinkSync(join(MODS_DIR, file));
}
// Copy snapshot server mods back
const snapFiles = readdirSync(snapDir).filter((f) => f.endsWith(".jar"));
for (const file of snapFiles) {
copyFileSync(join(snapDir, file), join(MODS_DIR, file));
}
// Restore client mods
if (existsSync(CLIENT_MODS_DIR)) {
const currentClient = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
for (const file of currentClient) {
unlinkSync(join(CLIENT_MODS_DIR, file));
}
}
const clientSnapDir = join(snapDir, "client-mods");
if (existsSync(clientSnapDir)) {
ensureDir(CLIENT_MODS_DIR);
const snapClientFiles = readdirSync(clientSnapDir).filter((f) =>
f.endsWith(".jar")
);
for (const file of snapClientFiles) {
copyFileSync(join(clientSnapDir, file), join(CLIENT_MODS_DIR, file));
}
}
// Restore mod metadata
const metaBackup = join(snapDir, "mod-metadata.json");
if (existsSync(metaBackup)) {
copyFileSync(metaBackup, MOD_METADATA_FILE);
}
}
export function listSnapshots(): (SnapshotMeta & { dirName: string })[] {
ensureDir(SNAPSHOTS_DIR);
const dirs = readdirSync(SNAPSHOTS_DIR).filter((d) => {
const metaPath = join(SNAPSHOTS_DIR, d, "meta.json");
return existsSync(metaPath);
});
return dirs
.map((dirName) => {
const meta = JSON.parse(
readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8")
) as SnapshotMeta;
return { ...meta, dirName };
})
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
export function deleteSnapshot(dirName: string): void {
const snapDir = join(SNAPSHOTS_DIR, dirName);
if (!existsSync(snapDir)) {
throw new Error(`Snapshot "${dirName}" not found`);
}
rmSync(snapDir, { recursive: true });
}
function pruneOldSnapshots(): void {
const snapshots = listSnapshots();
if (snapshots.length > MAX_SNAPSHOTS) {
const toDelete = snapshots.slice(MAX_SNAPSHOTS);
for (const snap of toDelete) {
deleteSnapshot(snap.dirName);
}
}
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}