mc-dashboard/app/api/mods/update/route.ts

401 lines
11 KiB
TypeScript
Raw Normal View History

2026-04-13 05:30:23 -06:00
import { NextRequest } from "next/server";
import { existsSync, readFileSync, unlinkSync } from "fs";
import { join } from "path";
import { exec } from "child_process";
import { auth } from "@/lib/auth";
import { downloadMod, getLatestVersion } from "@/lib/modrinth";
import type { ModSide } from "@/lib/modrinth";
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
import {
waitForServerAdaptive,
rebuildModpack,
addModMetadata,
removeModMetadata,
invalidateModsCache,
} from "@/lib/mods";
import { invalidate as invalidateCache } from "@/lib/cache";
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "@/lib/constants";
export const dynamic = "force-dynamic";
type ModMetadataEntry = { projectId: 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();
});
});
}
function locateMod(filename: string): { dir: string; side: ModSide } | null {
if (existsSync(join(MODS_DIR, filename))) {
return { dir: MODS_DIR, side: "both" };
}
if (existsSync(join(CLIENT_MODS_DIR, filename))) {
return { dir: CLIENT_MODS_DIR, side: "client" };
}
return null;
}
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 { filenames } = (await req.json()) as { filenames: string[] };
if (!Array.isArray(filenames) || filenames.length === 0) {
return new Response(JSON.stringify({ error: "No mods to update" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Validate filenames
for (const f of filenames) {
if (!f || !f.endsWith(".jar") || f.includes("/") || f.includes("\\")) {
return new Response(JSON.stringify({ error: `Invalid filename: ${f}` }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
let metadata: Record<string, ModMetadataEntry>;
try {
metadata = JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
} catch {
return new Response(
JSON.stringify({ error: "Mod metadata unavailable" }),
{ status: 500, 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 {}
}
(async () => {
const snapName = `before-update-${Date.now()}`;
try {
// 1. Resolve targets
await send("step", {
id: "resolve",
status: "active",
message: `Resolving latest versions for ${filenames.length} mod(s)...`,
});
type Target = {
oldFilename: string;
dir: string;
side: ModSide;
projectId: string;
newFilename: string;
url: string;
};
const targets: Target[] = [];
for (const f of filenames) {
const entry = metadata[f];
if (!entry?.projectId) {
await send("step", {
id: "resolve",
status: "error",
message: `${f}: unknown origin (not installed via dashboard)`,
});
await send("done", {
success: false,
message: `Cannot update ${f} — no Modrinth project linked.`,
});
return;
}
const loc = locateMod(f);
if (!loc) {
await send("step", {
id: "resolve",
status: "error",
message: `${f}: file missing on disk`,
});
await send("done", { success: false, message: `Mod file missing: ${f}` });
return;
}
const side: ModSide = entry.side || loc.side;
const latest = await getLatestVersion(entry.projectId);
if (!latest || !latest.files?.[0]) {
await send("step", {
id: "resolve",
status: "error",
message: `${f}: no compatible Modrinth version`,
});
await send("done", {
success: false,
message: `No update available for ${f}.`,
});
return;
}
const file = latest.files[0];
if (file.filename === f) {
// Already up to date
continue;
}
targets.push({
oldFilename: f,
dir: side === "client" ? CLIENT_MODS_DIR : MODS_DIR,
side,
projectId: entry.projectId,
newFilename: file.filename,
url: file.url,
});
}
if (targets.length === 0) {
await send("step", {
id: "resolve",
status: "done",
message: "All selected mods already current",
});
await send("done", {
success: true,
message: "No updates needed — everything is up to date.",
installed: [],
});
return;
}
await send("step", {
id: "resolve",
status: "done",
message: `${targets.length} update(s) to apply`,
});
// 2. Snapshot
await send("step", {
id: "snapshot",
status: "active",
message: "Creating safety snapshot...",
});
try {
createSnapshot(snapName);
} catch (e) {
await send("step", {
id: "snapshot",
status: "error",
message: (e as Error).message,
});
await send("done", {
success: false,
message: `Snapshot failed: ${(e as Error).message}`,
});
return;
}
await send("step", { id: "snapshot", status: "done", message: "Snapshot created" });
// 3. Download + swap
await send("step", {
id: "download",
status: "active",
message: `Downloading ${targets.length} mod(s)...`,
});
try {
for (const t of targets) {
await downloadMod(t.url, t.newFilename, t.side);
const oldPath = join(t.dir, t.oldFilename);
if (existsSync(oldPath) && t.oldFilename !== t.newFilename) {
unlinkSync(oldPath);
}
removeModMetadata(t.oldFilename);
addModMetadata(t.newFilename, { projectId: t.projectId, side: t.side });
}
invalidateModsCache();
invalidateCache("mods:updates");
} 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",
});
// 4. Restart if any server-side updates
const hasServerMods = targets.some((t) => t.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: `Updated ${targets.length} client-only mod(s).`,
installed: targets.map((t) => t.newFilename),
});
return;
}
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: `Restart failed: ${(e as Error).message}`,
rolledBack: true,
});
return;
}
await send("step", {
id: "restart",
status: "done",
message: "Restart command sent",
});
// 5. Verify
await send("step", {
id: "health",
status: "active",
message: "Waiting for server...",
});
const online = await waitForServerAdaptive(async (p) => {
await send("step", { id: "health", status: "active", message: p.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: `Updated ${targets.length} mod(s). Server is online.`,
installed: targets.map((t) => t.newFilename),
});
} 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 (p) => {
await send("step", {
id: "rollback",
status: "active",
message: `Rollback: ${p.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 after updating ${targets.length} mod(s). Rolled back.`,
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",
},
});
}