Repair uploads and let the console follow any registered server

This commit is contained in:
Aryan Singh committed 2026-10-16 20:34:12 -05:00
1 parent 3e24d4a7bb
commit 683cdc4016
15 files changed
+193 -84

No files matched your search

+16 -3
View File
@@ -1,15 +1,28 @@
import { consoleTarget } from "@/server/console-target";
import { inspectContainer } from "@/server/docker";
import { readNetwork, NetworkInputError } from "@/server/network-config";
import { profileConsole } from "@/server/network";
import { NextRequest } from "next/server";
import { rcon, stripColors } from "@/server/rcon";
import { logActivity } from "@/server/db";
import { pushLogLine } from "@/server/bus";
import { ok, fail, readJson, handle } from "@/server/api";
export async function GET() {
return handle(async () => {
const targets = [{ id: "primary", name: "Primary server" }, ...readNetwork().profiles.map(({ id, name }) => ({ id, name }))];
return ok(await Promise.all(targets.map(async (target) => ({ ...target, ...await inspectContainer(consoleTarget(target.id)) }))));
});
}
export async function POST(req: NextRequest) {
return handle(async () => {
const { command } = await readJson<{ command?: string }>(req);
if (!command?.trim()) return fail("Empty command");
const { command, server = "primary" } = await readJson<{ command?: string; server?: string }>(req);
if (typeof command !== "string" || !command.trim() || command.length > 2000 || /[\r\n\0]/.test(command)) return fail("Enter one console command");
try { consoleTarget(server); } catch (e) { if (e instanceof NetworkInputError) return fail(e.message); throw e; }
if (!(await inspectContainer(consoleTarget(server))).running) return fail("This server is stopped. Start it from Overview or Servers first.", 409);
const cmd = command.trim().replace(/^\//, "");
const output = stripColors(await rcon().exec(cmd));
const output = stripColors(server === "primary" ? await rcon().exec(cmd) : await profileConsole(server, cmd));
logActivity("console", null, cmd);
// Echo the exchange into the live console stream so it reads like a terminal.
pushLogLine(`> ${cmd}`);
@@ -7,6 +7,7 @@ const MAX_UPLOAD = 256 * 1024 * 1024; // mod jars and datapacks, not world dumps
export async function POST(req: NextRequest) {
return handle(async () => {
if (Number(req.headers.get("content-length") || 0) > MAX_UPLOAD + 1024 * 1024) return fail("File too large (256 MB max)", 413);
const form = await req.formData();
const dir = (form.get("dir") as string) ?? "";
const file = form.get("file");
+3 -3
View File
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
const isUpload = req.headers
.get("content-type")
?.includes("multipart/form-data");
if (Number(req.headers.get("content-length") || 0) > 100 * 1024 * 1024)
if (Number(req.headers.get("content-length") || 0) > 101 * 1024 * 1024)
return fail("Upload limit is 100 MB");
const form = isUpload ? await req.formData() : null;
const body = form
@@ -59,8 +59,8 @@ export async function POST(req: NextRequest) {
return fail("Choose a file under 100 MB");
const rel = path.relative(networkFilePath(p.id, ""), file);
if (
!/^(mods|plugins)\/[^/]+\.jar$/.test(rel) &&
!/^world\/datapacks\/[^/]+\.zip$/.test(rel)
!/^(mods|plugins)\/[^/]+\.jar$/i.test(rel) &&
!/^world\/datapacks\/[^/]+\.zip$/i.test(rel)
)
return fail(
"Upload JARs into mods/plugins or ZIPs into world/datapacks",
+45 -32
View File
@@ -1,51 +1,64 @@
import { NextRequest } from "next/server";
import { bus, logRing } from "@/server/bus";
import { streamContainerLogs, inspectContainer, type LogStreamHandle } from "@/server/docker";
import { consoleTarget } from "@/server/console-target";
import { env } from "@/server/env";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/** Live console stream (SSE). Backfills recent lines, then follows. */
/** Each viewer follows its selected container, independent of the primary watcher. */
export async function GET(req: NextRequest) {
let name: string;
try { name = consoleTarget(req.nextUrl.searchParams.get("server")); }
catch { return Response.json({ error: "Unknown server" }, { status: 400 }); }
const encoder = new TextEncoder();
let closed = false;
let cleanup = () => {};
const stream = new ReadableStream({
start(controller) {
const send = (line: string) => {
let closed = false;
let logs: LogStreamHandle | undefined;
let retry: ReturnType<typeof setTimeout> | undefined;
const send = (event: string, value: string) => {
if (closed) return;
try { controller.enqueue(encoder.encode(`${event === "message" ? "" : `event: ${event}\n`}data: ${JSON.stringify(value.split(env.rconPassword).join("[redacted]"))}\n\n`)); }
catch { cleanup(); }
};
const follow = async (tail: number) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
} catch {
closed = true;
const state = await inspectContainer(name);
if (closed) return;
send("status", state.running ? "Connected" : "Server stopped — showing its last logs. Start it from Overview or Servers.");
if (!state.exists) throw new Error("Container has not been deployed");
logs = streamContainerLogs(name, { tail, follow: true }, line => send("message", line), err => {
if (closed) return;
if (err) send("status", err.message);
retry = setTimeout(() => void follow(0), 5000);
});
} catch (e) {
send("status", (e as Error).message);
if (!closed) retry = setTimeout(() => void follow(tail), 5000);
}
};
for (const line of logRing()) send(line);
const onLog = (line: string) => send(line);
bus().on("log", onLog);
const keepalive = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
closed = true;
}
if (!closed) { try { controller.enqueue(encoder.encode(": keepalive\n\n")); } catch { cleanup(); } }
}, 15000);
req.signal.addEventListener("abort", () => {
cleanup = () => {
if (closed) return;
closed = true;
bus().off("log", onLog);
clearInterval(keepalive);
try {
controller.close();
} catch {}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
clearTimeout(retry);
logs?.stop();
req.signal.removeEventListener("abort", cleanup);
try { controller.close(); } catch {}
};
req.signal.addEventListener("abort", cleanup, { once: true });
if (req.signal.aborted) cleanup(); else void follow(200);
},
cancel() { cleanup(); },
});
return new Response(stream, { headers: {
"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
Connection: "keep-alive", "X-Accel-Buffering": "no",
} });
}