401 lines
11 KiB
TypeScript
401 lines
11 KiB
TypeScript
|
|
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",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|