Repair uploads and let the console follow any registered server
This commit is contained in:
1 parent
3e24d4a7bb
commit
683cdc4016
15 files changed
+193
-84
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
} });
|
||||
}
|
||||
Reference in new issue
Block a user