new upload system
This commit is contained in:
@@ -20,7 +20,7 @@ import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
||||
import { handleBuildRoutes } from "./server/routes/build-routes";
|
||||
import { handleAssetsRoutes } from "./server/routes/assets-routes";
|
||||
import { handleDeployRoutes } from "./server/routes/deploy-routes";
|
||||
import { handleFileDownloadRoutes } from "./server/routes/file-download-routes";
|
||||
import { cleanupFileTransferTempFiles, handleFileDownloadRoutes } from "./server/routes/file-download-routes";
|
||||
import { handleClientRoutes } from "./server/routes/client-routes";
|
||||
import { handleMiscRoutes } from "./server/routes/misc-routes";
|
||||
import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
|
||||
@@ -141,6 +141,21 @@ function envFlagEnabled(name: string): boolean {
|
||||
|
||||
const TLS_OFFLOAD = envFlagEnabled("OVERLORD_TLS_OFFLOAD");
|
||||
|
||||
function parseMaxHttpBodyBytes(): number {
|
||||
const raw = String(process.env.OVERLORD_MAX_HTTP_BODY_BYTES || "").trim();
|
||||
if (raw !== "") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
logger.warn(`[HTTP] Invalid OVERLORD_MAX_HTTP_BODY_BYTES=${raw}; using default`);
|
||||
}
|
||||
// Default to 2 GiB to allow large ISO uploads in file browser staging.
|
||||
return 2 * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
|
||||
|
||||
const pluginLoadedByClient = new Map<string, Set<string>>();
|
||||
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
||||
const pluginLoadingByClient = new Map<string, Set<string>>();
|
||||
@@ -241,6 +256,8 @@ const pendingCommandReplies = new Map<string, PendingCommandReply>();
|
||||
|
||||
async function startServer() {
|
||||
await loadPluginState();
|
||||
await cleanupFileTransferTempFiles(DATA_DIR);
|
||||
logger.info("[filebrowser] cleaned stale transfer temp files on startup");
|
||||
let tls:
|
||||
| {
|
||||
tlsOptions: { cert?: string; key?: string; ca?: string };
|
||||
@@ -392,6 +409,7 @@ async function startServer() {
|
||||
hostname: HOST,
|
||||
...(tls ? { tls: tls.tlsOptions } : {}),
|
||||
idleTimeout: 255,
|
||||
maxRequestBodySize: MAX_HTTP_BODY_BYTES,
|
||||
fetch: createHttpFetchHandler({
|
||||
metrics,
|
||||
CORS_HEADERS,
|
||||
@@ -440,6 +458,7 @@ async function startServer() {
|
||||
|
||||
if (tls) {
|
||||
logServerStartup(server, tls.certPathUsed, tls.source);
|
||||
logger.info(`[HTTP] maxRequestBodySize=${MAX_HTTP_BODY_BYTES} bytes`);
|
||||
} else {
|
||||
const hostname = server.hostname || "0.0.0.0";
|
||||
const port = server.port ?? 0;
|
||||
@@ -451,6 +470,7 @@ async function startServer() {
|
||||
logger.info("");
|
||||
logger.info("External access should be HTTPS/WSS via your reverse proxy platform.");
|
||||
logger.info("Set this mode only when TLS is terminated by the platform (for example Render). ");
|
||||
logger.info(`[HTTP] maxRequestBodySize=${MAX_HTTP_BODY_BYTES} bytes`);
|
||||
logger.info("========================================");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, readdir, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { generateToken } from "../../auth";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { createUser, deleteUser, getUserById } from "../../users";
|
||||
import { handleFileDownloadRoutes } from "./file-download-routes";
|
||||
|
||||
const PASSWORD = "Aa1!RouteUploadTestPass123";
|
||||
|
||||
function makeRouteDeps(dataDir: string) {
|
||||
return {
|
||||
DATA_DIR: dataDir,
|
||||
secureHeaders: (_contentType?: string) => ({}),
|
||||
sanitizeOutputName: (name: string) => {
|
||||
const cleaned = (name || "").replace(/[^A-Za-z0-9._-]/g, "");
|
||||
if (!cleaned) throw new Error("invalid filename");
|
||||
return cleaned;
|
||||
},
|
||||
pendingHttpDownloads: new Map<string, any>(),
|
||||
downloadIntents: new Map<string, any>(),
|
||||
};
|
||||
}
|
||||
|
||||
const mockServer = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
async function createAdminToken() {
|
||||
const username = `u_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, "admin", "test");
|
||||
expect(created.success).toBe(true);
|
||||
expect(typeof created.userId).toBe("number");
|
||||
|
||||
const user = getUserById(created.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
|
||||
const token = await generateToken(user!);
|
||||
return {
|
||||
userId: created.userId!,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTempDataDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "overlord-upload-route-test-"));
|
||||
}
|
||||
|
||||
async function removeTempDataDir(dir: string): Promise<void> {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
describe("file upload route flow", () => {
|
||||
test("stages and serves one-time upload payload for matching client", async () => {
|
||||
const auth = await createAdminToken();
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
const payload = new Uint8Array(3 * 1024 * 1024 + 123);
|
||||
crypto.getRandomValues(payload.subarray(0, Math.min(payload.length, 65536)));
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
const prevDisableAgentAuth = process.env.OVERLORD_DISABLE_AGENT_AUTH;
|
||||
process.env.OVERLORD_DISABLE_AGENT_AUTH = "true";
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\big.iso",
|
||||
fileName: "big.iso",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(requestRes).not.toBeNull();
|
||||
expect(requestRes!.status).toBe(200);
|
||||
const requestJson = await requestRes!.json() as any;
|
||||
expect(requestJson.ok).toBe(true);
|
||||
expect(typeof requestJson.uploadUrl).toBe("string");
|
||||
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const stageRes = await handleFileDownloadRoutes(
|
||||
new Request(stageUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: payload,
|
||||
}),
|
||||
stageUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(stageRes).not.toBeNull();
|
||||
expect(stageRes!.status).toBe(200);
|
||||
const stageJson = await stageRes!.json() as any;
|
||||
expect(stageJson.ok).toBe(true);
|
||||
expect(stageJson.size).toBe(payload.length);
|
||||
expect(typeof stageJson.pullUrl).toBe("string");
|
||||
|
||||
const pullUrl = new URL(stageJson.pullUrl);
|
||||
const pullRes = await handleFileDownloadRoutes(
|
||||
new Request(pullUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-overlord-client-id": clientId,
|
||||
},
|
||||
}),
|
||||
pullUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(pullRes).not.toBeNull();
|
||||
expect(pullRes!.status).toBe(200);
|
||||
const pulled = new Uint8Array(await pullRes!.arrayBuffer());
|
||||
expect(pulled.length).toBe(payload.length);
|
||||
expect(pulled[0]).toBe(payload[0]);
|
||||
expect(pulled[pulled.length - 1]).toBe(payload[payload.length - 1]);
|
||||
|
||||
const uploadDir = join(dataDir, "uploads");
|
||||
const uploadEntries = await readdir(uploadDir).catch(() => [] as string[]);
|
||||
expect(uploadEntries.filter((name) => name.endsWith(".bin")).length).toBe(0);
|
||||
|
||||
const pullAgainRes = await handleFileDownloadRoutes(
|
||||
new Request(pullUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-overlord-client-id": clientId,
|
||||
},
|
||||
}),
|
||||
pullUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
expect(pullAgainRes).not.toBeNull();
|
||||
expect(pullAgainRes!.status).toBe(404);
|
||||
} finally {
|
||||
if (prevDisableAgentAuth === undefined) {
|
||||
delete process.env.OVERLORD_DISABLE_AGENT_AUTH;
|
||||
} else {
|
||||
process.env.OVERLORD_DISABLE_AGENT_AUTH = prevDisableAgentAuth;
|
||||
}
|
||||
clientManager.deleteClient(clientId);
|
||||
const deleted = deleteUser(auth.userId);
|
||||
expect(deleted.success).toBe(true);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects pull when client id does not match", async () => {
|
||||
const auth = await createAdminToken();
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
const prevDisableAgentAuth = process.env.OVERLORD_DISABLE_AGENT_AUTH;
|
||||
process.env.OVERLORD_DISABLE_AGENT_AUTH = "true";
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\small.bin",
|
||||
fileName: "small.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
const requestJson = await requestRes!.json() as any;
|
||||
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const stageRes = await handleFileDownloadRoutes(
|
||||
new Request(stageUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: payload,
|
||||
}),
|
||||
stageUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
const stageJson = await stageRes!.json() as any;
|
||||
|
||||
const pullUrl = new URL(stageJson.pullUrl);
|
||||
const badPullRes = await handleFileDownloadRoutes(
|
||||
new Request(pullUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-overlord-client-id": "different-client",
|
||||
},
|
||||
}),
|
||||
pullUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(badPullRes).not.toBeNull();
|
||||
expect(badPullRes!.status).toBe(403);
|
||||
} finally {
|
||||
if (prevDisableAgentAuth === undefined) {
|
||||
delete process.env.OVERLORD_DISABLE_AGENT_AUTH;
|
||||
} else {
|
||||
process.env.OVERLORD_DISABLE_AGENT_AUTH = prevDisableAgentAuth;
|
||||
}
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { logger } from "../../logger";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { getConfig } from "../../config";
|
||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -40,6 +42,27 @@ type DownloadIntent = {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type UploadIntent = {
|
||||
id: string;
|
||||
userId: string;
|
||||
clientId: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
expiresAt: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type UploadPull = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
tmpPath: string;
|
||||
size: number;
|
||||
expiresAt: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type FileDownloadRouteDeps = {
|
||||
DATA_DIR: string;
|
||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||
@@ -48,17 +71,222 @@ type FileDownloadRouteDeps = {
|
||||
downloadIntents: Map<string, DownloadIntent>;
|
||||
};
|
||||
|
||||
export async function cleanupFileTransferTempFiles(dataDir: string): Promise<void> {
|
||||
const uploadsDir = path.join(dataDir, "uploads");
|
||||
const downloadsDir = path.join(dataDir, "downloads");
|
||||
await fs.rm(uploadsDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(downloadsDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function streamFileAndDelete(tmpPath: string): ReadableStream<Uint8Array> {
|
||||
const reader = Bun.file(tmpPath).stream().getReader();
|
||||
let cleaned = false;
|
||||
|
||||
const cleanup = async () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
};
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
await cleanup();
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
await cleanup();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const UUID_TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const uploadIntents = new Map<string, UploadIntent>();
|
||||
const uploadPulls = new Map<string, UploadPull>();
|
||||
|
||||
function isSafeRemotePath(value: string): boolean {
|
||||
if (!value || value.length > 4096) return false;
|
||||
return !/[\x00-\x1F\x7F]/.test(value);
|
||||
}
|
||||
|
||||
async function serveDownloadById(
|
||||
req: Request,
|
||||
downloadId: string,
|
||||
server: RequestIpProvider,
|
||||
deps: FileDownloadRouteDeps,
|
||||
): Promise<Response> {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (user.role !== "admin" && user.role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http download request", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
});
|
||||
|
||||
if (!UUID_TOKEN_RE.test(downloadId)) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const intent = deps.downloadIntents.get(downloadId);
|
||||
if (!intent || intent.userId !== user.userId || intent.expiresAt < Date.now()) {
|
||||
logger.debug("[filebrowser] http download intent missing", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
intentUserId: intent?.userId,
|
||||
expiresAt: intent?.expiresAt,
|
||||
});
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
deps.downloadIntents.delete(downloadId);
|
||||
clearTimeout(intent.timeout);
|
||||
|
||||
const clientId = intent.clientId;
|
||||
const downloadPath = intent.path;
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
logger.debug("[filebrowser] http download target offline", {
|
||||
downloadId,
|
||||
clientId,
|
||||
});
|
||||
return new Response("Client offline", { status: 404 });
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
const downloadDir = path.join(deps.DATA_DIR, "downloads");
|
||||
await fs.mkdir(downloadDir, { recursive: true });
|
||||
const tmpPath = path.join(downloadDir, `${commandId}.bin`);
|
||||
|
||||
let fileName = path.basename(downloadPath) || "download.bin";
|
||||
try {
|
||||
fileName = deps.sanitizeOutputName(fileName);
|
||||
} catch {
|
||||
fileName = "download.bin";
|
||||
}
|
||||
|
||||
const fileHandle = await fs.open(tmpPath, "w+");
|
||||
|
||||
logger.debug("[filebrowser] http download start", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
tmpPath,
|
||||
});
|
||||
|
||||
const downloadPromise = new Promise<PendingHttpDownload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deps.pendingHttpDownloads.delete(commandId);
|
||||
void fileHandle.close();
|
||||
void fs.unlink(tmpPath).catch(() => {});
|
||||
reject(new Error("Download timed out"));
|
||||
}, 5 * 60_000);
|
||||
|
||||
deps.pendingHttpDownloads.set(commandId, {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
fileName,
|
||||
total: 0,
|
||||
receivedBytes: 0,
|
||||
receivedOffsets: new Set(),
|
||||
receivedChunks: new Set(),
|
||||
chunkSize: 0,
|
||||
expectedChunks: 0,
|
||||
loggedTotal: false,
|
||||
loggedFirstChunk: false,
|
||||
tmpPath,
|
||||
fileHandle,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
});
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_download",
|
||||
id: commandId,
|
||||
payload: { path: downloadPath },
|
||||
}),
|
||||
);
|
||||
|
||||
logger.debug("[filebrowser] http download command sent", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.FILE_DOWNLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: downloadPath, via: "http" }),
|
||||
success: true,
|
||||
});
|
||||
|
||||
let completed: PendingHttpDownload;
|
||||
try {
|
||||
completed = await downloadPromise;
|
||||
} catch (err) {
|
||||
logger.debug("[filebrowser] http download failed", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
error: (err as Error)?.message || String(err),
|
||||
});
|
||||
return new Response((err as Error).message || "Download failed", { status: 500 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http download complete", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
total: completed.total,
|
||||
receivedBytes: completed.receivedBytes,
|
||||
expectedChunks: completed.expectedChunks,
|
||||
receivedChunks: completed.receivedChunks.size,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
...deps.secureHeaders("application/octet-stream"),
|
||||
"Content-Disposition": `attachment; filename="${completed.fileName}"`,
|
||||
"Cache-Control": "no-store, private",
|
||||
};
|
||||
|
||||
return new Response(streamFileAndDelete(completed.tmpPath), { headers });
|
||||
}
|
||||
|
||||
export async function handleFileDownloadRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
server: RequestIpProvider,
|
||||
deps: FileDownloadRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/file/download")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/file/download") {
|
||||
if (req.method === "POST" && url.pathname === "/api/file/upload/request") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
@@ -74,151 +302,225 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const downloadId = typeof body?.downloadId === "string" ? body.downloadId : "";
|
||||
logger.debug("[filebrowser] http download request", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
});
|
||||
if (!downloadId) {
|
||||
const clientId = typeof body?.clientId === "string" ? body.clientId.trim() : "";
|
||||
const uploadPath = typeof body?.path === "string" ? body.path.trim() : "";
|
||||
const fileName = typeof body?.fileName === "string" ? body.fileName.trim() : "upload.bin";
|
||||
if (!clientId || !uploadPath || !isSafeRemotePath(uploadPath)) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const intent = deps.downloadIntents.get(downloadId);
|
||||
if (!intent || intent.userId !== user.userId || intent.expiresAt < Date.now()) {
|
||||
logger.debug("[filebrowser] http download intent missing", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
intentUserId: intent?.userId,
|
||||
expiresAt: intent?.expiresAt,
|
||||
});
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
deps.downloadIntents.delete(downloadId);
|
||||
clearTimeout(intent.timeout);
|
||||
|
||||
const clientId = intent.clientId;
|
||||
const downloadPath = intent.path;
|
||||
logger.debug("[filebrowser] http upload request", {
|
||||
userId: user.userId,
|
||||
clientId,
|
||||
path: uploadPath,
|
||||
fileName,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
});
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
logger.debug("[filebrowser] http download target offline", {
|
||||
downloadId,
|
||||
clientId,
|
||||
});
|
||||
return new Response("Client offline", { status: 404 });
|
||||
}
|
||||
|
||||
const commandId = uuidv4();
|
||||
const downloadDir = path.join(deps.DATA_DIR, "downloads");
|
||||
await fs.mkdir(downloadDir, { recursive: true });
|
||||
const tmpPath = path.join(downloadDir, `${commandId}.bin`);
|
||||
const uploadId = uuidv4();
|
||||
const expiresAt = Date.now() + 2 * 60_000;
|
||||
const timeout = setTimeout(() => {
|
||||
uploadIntents.delete(uploadId);
|
||||
}, 2 * 60_000);
|
||||
|
||||
let fileName = path.basename(downloadPath) || "download.bin";
|
||||
try {
|
||||
fileName = deps.sanitizeOutputName(fileName);
|
||||
} catch {
|
||||
fileName = "download.bin";
|
||||
uploadIntents.set(uploadId, {
|
||||
id: uploadId,
|
||||
userId: user.userId,
|
||||
clientId,
|
||||
path: uploadPath,
|
||||
fileName,
|
||||
expiresAt,
|
||||
timeout,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
uploadId,
|
||||
uploadUrl: `/api/file/upload/${encodeURIComponent(uploadId)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if ((req.method === "PUT" || req.method === "POST") && url.pathname.startsWith("/api/file/upload/")) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (user.role !== "admin" && user.role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
const fileHandle = await fs.open(tmpPath, "w+");
|
||||
|
||||
logger.debug("[filebrowser] http download start", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
tmpPath,
|
||||
});
|
||||
|
||||
const downloadPromise = new Promise<PendingHttpDownload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deps.pendingHttpDownloads.delete(commandId);
|
||||
void fileHandle.close();
|
||||
void fs.unlink(tmpPath).catch(() => {});
|
||||
reject(new Error("Download timed out"));
|
||||
}, 5 * 60_000);
|
||||
|
||||
deps.pendingHttpDownloads.set(commandId, {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
fileName,
|
||||
total: 0,
|
||||
receivedBytes: 0,
|
||||
receivedOffsets: new Set(),
|
||||
receivedChunks: new Set(),
|
||||
chunkSize: 0,
|
||||
expectedChunks: 0,
|
||||
loggedTotal: false,
|
||||
loggedFirstChunk: false,
|
||||
tmpPath,
|
||||
fileHandle,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
});
|
||||
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_download",
|
||||
id: commandId,
|
||||
payload: { path: downloadPath },
|
||||
}),
|
||||
);
|
||||
|
||||
logger.debug("[filebrowser] http download command sent", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.FILE_DOWNLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: downloadPath, via: "http" }),
|
||||
success: true,
|
||||
});
|
||||
|
||||
let completed: PendingHttpDownload;
|
||||
let uploadId = "";
|
||||
try {
|
||||
completed = await downloadPromise;
|
||||
uploadId = decodeURIComponent(url.pathname.slice("/api/file/upload/".length));
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
if (!UUID_TOKEN_RE.test(uploadId)) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http upload stage", {
|
||||
method: req.method,
|
||||
uploadId,
|
||||
userId: user.userId,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
});
|
||||
|
||||
const intent = uploadIntents.get(uploadId);
|
||||
if (!intent || intent.userId !== user.userId || intent.expiresAt < Date.now()) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const uploadDir = path.join(deps.DATA_DIR, "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
const tmpPath = path.join(uploadDir, `${uploadId}.bin`);
|
||||
|
||||
let stagedSize = 0;
|
||||
const fileHandle = await fs.open(tmpPath, "w");
|
||||
try {
|
||||
if (req.body) {
|
||||
const reader = req.body.getReader();
|
||||
let position = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
await fileHandle.write(value, 0, value.byteLength, position);
|
||||
position += value.byteLength;
|
||||
stagedSize += value.byteLength;
|
||||
}
|
||||
} else {
|
||||
const bytes = new Uint8Array(await req.arrayBuffer());
|
||||
if (bytes.byteLength > 0) {
|
||||
await fileHandle.write(bytes, 0, bytes.byteLength, 0);
|
||||
stagedSize = bytes.byteLength;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug("[filebrowser] http download failed", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
logger.debug("[filebrowser] http upload stage error", {
|
||||
uploadId,
|
||||
error: (err as Error)?.message || String(err),
|
||||
});
|
||||
return new Response((err as Error).message || "Download failed", { status: 500 });
|
||||
await fileHandle.close();
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return new Response("Upload staging failed", { status: 500 });
|
||||
}
|
||||
await fileHandle.close();
|
||||
|
||||
logger.debug("[filebrowser] http upload staged bytes", {
|
||||
uploadId,
|
||||
bytes: stagedSize,
|
||||
clientId: intent.clientId,
|
||||
path: intent.path,
|
||||
});
|
||||
|
||||
uploadIntents.delete(uploadId);
|
||||
clearTimeout(intent.timeout);
|
||||
|
||||
const pullId = uuidv4();
|
||||
const pullExpiresAt = Date.now() + 10 * 60_000;
|
||||
const pullTimeout = setTimeout(() => {
|
||||
const pull = uploadPulls.get(pullId);
|
||||
uploadPulls.delete(pullId);
|
||||
if (pull) {
|
||||
void fs.unlink(pull.tmpPath).catch(() => {});
|
||||
}
|
||||
}, 10 * 60_000);
|
||||
|
||||
uploadPulls.set(pullId, {
|
||||
id: pullId,
|
||||
clientId: intent.clientId,
|
||||
path: intent.path,
|
||||
fileName: intent.fileName,
|
||||
tmpPath,
|
||||
size: stagedSize,
|
||||
expiresAt: pullExpiresAt,
|
||||
timeout: pullTimeout,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
size: stagedSize,
|
||||
path: intent.path,
|
||||
pullUrl: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/file/upload/pull/")) {
|
||||
const agentToken = getConfig().auth.agentToken;
|
||||
if (!isAuthorizedAgentRequest(req, url, agentToken)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http download complete", {
|
||||
commandId,
|
||||
clientId,
|
||||
path: downloadPath,
|
||||
total: completed.total,
|
||||
receivedBytes: completed.receivedBytes,
|
||||
expectedChunks: completed.expectedChunks,
|
||||
receivedChunks: completed.receivedChunks.size,
|
||||
let pullId = "";
|
||||
try {
|
||||
pullId = decodeURIComponent(url.pathname.slice("/api/file/upload/pull/".length));
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
if (!UUID_TOKEN_RE.test(pullId)) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const pull = uploadPulls.get(pullId);
|
||||
if (!pull || pull.expiresAt < Date.now()) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const requesterClientId = req.headers.get("x-overlord-client-id") || "";
|
||||
if (!requesterClientId || requesterClientId !== pull.clientId) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http upload pull", {
|
||||
pullId,
|
||||
clientId: pull.clientId,
|
||||
path: pull.path,
|
||||
bytes: pull.size,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
});
|
||||
|
||||
uploadPulls.delete(pullId);
|
||||
clearTimeout(pull.timeout);
|
||||
|
||||
const headers = {
|
||||
...deps.secureHeaders("application/octet-stream"),
|
||||
"Content-Disposition": `attachment; filename="${completed.fileName}"`,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `attachment; filename="${deps.sanitizeOutputName(path.basename(pull.fileName) || "upload.bin")}"`,
|
||||
"Cache-Control": "no-store, private",
|
||||
"Content-Length": String(pull.size),
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
void fs.unlink(completed.tmpPath).catch(() => {});
|
||||
}, 5 * 60_000);
|
||||
return new Response(streamFileAndDelete(pull.tmpPath), { headers });
|
||||
}
|
||||
|
||||
return new Response(Bun.file(completed.tmpPath), { headers });
|
||||
if (!url.pathname.startsWith("/api/file/download")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/file/download/")) {
|
||||
let downloadId = "";
|
||||
try {
|
||||
downloadId = decodeURIComponent(url.pathname.slice("/api/file/download/".length));
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
return serveDownloadById(req, downloadId, server, deps);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/file/download") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
const downloadId = typeof body?.downloadId === "string" ? body.downloadId : "";
|
||||
return serveDownloadById(req, downloadId, server, deps);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/file/download/request") {
|
||||
@@ -237,9 +539,9 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const clientId = typeof body?.clientId === "string" ? body.clientId : "";
|
||||
const downloadPath = typeof body?.path === "string" ? body.path : "";
|
||||
if (!clientId || !downloadPath) {
|
||||
const clientId = typeof body?.clientId === "string" ? body.clientId.trim() : "";
|
||||
const downloadPath = typeof body?.path === "string" ? body.path.trim() : "";
|
||||
if (!clientId || !downloadPath || !isSafeRemotePath(downloadPath)) {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
@@ -263,7 +565,11 @@ export async function handleFileDownloadRoutes(
|
||||
timeout,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, downloadId });
|
||||
return Response.json({
|
||||
ok: true,
|
||||
downloadId,
|
||||
downloadUrl: `/api/file/download/${encodeURIComponent(downloadId)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -116,6 +116,19 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_execute", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_execute");
|
||||
break;
|
||||
case "file_upload_http":
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_upload_http", id: payload.id || commandId, payload: actualPayload } as any));
|
||||
metrics.recordCommand("file_upload");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_UPLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: actualPayload.path || "", mode: "http_pull" }),
|
||||
success: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -152,27 +165,13 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
case "file_upload": {
|
||||
const upload = normalizeFileUploadPayload(payload);
|
||||
if (!upload) return;
|
||||
target.ws.send(encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_upload",
|
||||
id: commandId,
|
||||
payload: {
|
||||
path: upload.path,
|
||||
data: upload.data,
|
||||
offset: upload.offset,
|
||||
total: upload.total,
|
||||
transferId: upload.transferId,
|
||||
},
|
||||
} as any));
|
||||
metrics.recordCommand("file_upload");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: (ws.data as any).username || "unknown",
|
||||
ip: ws.data.ip || "unknown",
|
||||
action: AuditAction.FILE_UPLOAD,
|
||||
targetClientId: clientId,
|
||||
details: JSON.stringify({ path: upload.path, size: upload.data.length, offset: upload.offset }),
|
||||
success: true,
|
||||
safeSendViewer(ws, {
|
||||
type: "file_upload_result",
|
||||
commandId,
|
||||
transferId: upload.transferId,
|
||||
path: upload.path,
|
||||
ok: false,
|
||||
error: "chunked uploads are disabled; refresh and retry",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user