diff --git a/Overlord-Client/cmd/agent/handlers/files_test.go b/Overlord-Client/cmd/agent/handlers/files_test.go
index 9252db6..4fc6346 100644
--- a/Overlord-Client/cmd/agent/handlers/files_test.go
+++ b/Overlord-Client/cmd/agent/handlers/files_test.go
@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"context"
+ "fmt"
"net/http"
"net/http/httptest"
"os"
@@ -521,6 +522,72 @@ func TestHandleFileUploadHTTP(t *testing.T) {
}
}
+func TestHandleFileUploadHTTP_ResumesWithRangeAfterShortRead(t *testing.T) {
+ data := bytes.Repeat([]byte("range-retry-"), 128*1024)
+ firstLen := len(data) / 3
+ var requests int
+ var sawRange string
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if requests == 1 {
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
+ _, _ = w.Write(data[:firstLen])
+ return
+ }
+
+ sawRange = r.Header.Get("Range")
+ expectedRange := fmt.Sprintf("bytes=%d-", firstLen)
+ if sawRange != expectedRange {
+ t.Fatalf("expected resume range %q, got %q", expectedRange, sawRange)
+ }
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)-firstLen))
+ w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", firstLen, len(data)-1, len(data)))
+ w.WriteHeader(http.StatusPartialContent)
+ _, _ = w.Write(data[firstLen:])
+ }))
+ defer ts.Close()
+
+ tmpDir := t.TempDir()
+ destPath := filepath.Join(tmpDir, "uploaded-http-resume.bin")
+
+ writer := &testWriter{}
+ env := &rt.Env{
+ Conn: writer,
+ Cfg: config.Config{TLSInsecureSkipVerify: true},
+ }
+
+ if err := HandleFileUploadHTTP(context.Background(), env, "cmd-http-upload-resume", destPath, ts.URL+"/file", int64(len(data))); err != nil {
+ t.Fatalf("HandleFileUploadHTTP failed: %v", err)
+ }
+
+ if requests < 2 {
+ t.Fatalf("expected retry request, got %d request(s)", requests)
+ }
+ if sawRange == "" {
+ t.Fatal("expected retry request to include Range header")
+ }
+
+ content, err := os.ReadFile(destPath)
+ if err != nil {
+ t.Fatalf("failed reading uploaded file: %v", err)
+ }
+ if !bytes.Equal(content, data) {
+ t.Fatalf("uploaded content mismatch after resume")
+ }
+
+ if len(writer.msgs) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(writer.msgs))
+ }
+ var result wire.CommandResult
+ if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
+ t.Fatalf("failed to unmarshal command result: %v", err)
+ }
+ if !result.OK {
+ t.Fatalf("expected OK=true, got false: %s", result.Message)
+ }
+}
+
func TestHandleFileUploadHTTP_SizeMismatch(t *testing.T) {
data := []byte("small")
diff --git a/Overlord-Server/public/assets/build-history-manager.js b/Overlord-Server/public/assets/build-history-manager.js
new file mode 100644
index 0000000..6a97c22
--- /dev/null
+++ b/Overlord-Server/public/assets/build-history-manager.js
@@ -0,0 +1,327 @@
+export function formatFileSize(bytes) {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+}
+
+function formatStubVersion(version) {
+ if (typeof version === "string" && version.trim()) {
+ return version.trim();
+ }
+ return "unknown (legacy build)";
+}
+
+function saveBuildToStorage(buildId, buildData) {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+ const existingIndex = builds.findIndex((b) => b.id === buildId);
+
+ if (existingIndex >= 0) {
+ builds[existingIndex] = buildData;
+ } else {
+ builds.push(buildData);
+ }
+
+ if (builds.length > 20) {
+ builds.splice(0, builds.length - 20);
+ }
+
+ localStorage.setItem("overlord_builds", JSON.stringify(builds));
+ } catch (err) {
+ console.error("Failed to save build to localStorage:", err);
+ }
+}
+
+function getAllBuildsFromStorage() {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+ return builds.sort((a, b) => b.startTime - a.startTime);
+ } catch (err) {
+ console.error("Failed to get builds from localStorage:", err);
+ return [];
+ }
+}
+
+function removeBuildFromStorage(buildId) {
+ try {
+ const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
+ const filtered = builds.filter((b) => b.id !== buildId);
+ localStorage.setItem("overlord_builds", JSON.stringify(filtered));
+ } catch (err) {
+ console.error("Failed to remove build from localStorage:", err);
+ }
+}
+
+export function updateExpirationTimer(timerEl, expiresAt) {
+ if (!timerEl) return;
+
+ const now = Date.now();
+ const remaining = expiresAt - now;
+
+ if (remaining <= 0) {
+ timerEl.textContent = "Expired";
+ timerEl.className = "text-red-400 font-medium";
+ return;
+ }
+
+ const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
+ const hours = Math.floor(
+ (remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
+ );
+ const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (days > 0) {
+ timerEl.textContent = `${days}d ${hours}h`;
+ } else if (hours > 0) {
+ timerEl.textContent = `${hours}h ${minutes}m`;
+ } else {
+ timerEl.textContent = `${minutes}m`;
+ }
+
+ if (days >= 3) {
+ timerEl.className = "text-green-400 font-medium";
+ } else if (days >= 1) {
+ timerEl.className = "text-yellow-400 font-medium";
+ } else {
+ timerEl.className = "text-orange-400 font-medium";
+ }
+}
+
+export function createBuildHistoryManager({
+ buildResults,
+ buildFilesDiv,
+ getCurrentServerVersion,
+ getCurrentUserRole,
+ getShowAllBuilds,
+}) {
+ function isVersionMismatch(versionValue) {
+ const currentServerVersion = getCurrentServerVersion();
+ if (!currentServerVersion) return false;
+ return versionValue !== currentServerVersion;
+ }
+
+ async function deleteBuild(buildId) {
+ if (!confirm("Are you sure you want to delete this build?")) {
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, {
+ method: "DELETE",
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "Failed to delete build");
+ }
+
+ const buildElement = document.getElementById(`build-${buildId}`);
+ if (buildElement) {
+ buildElement.remove();
+ }
+
+ removeBuildFromStorage(buildId);
+
+ if (buildFilesDiv.children.length === 0) {
+ buildResults.classList.add("hidden");
+ }
+ } catch (err) {
+ console.error("Failed to delete build:", err);
+ alert("Failed to delete build. Please try again.");
+ }
+ }
+
+ async function loadSavedBuilds() {
+ try {
+ const queryParam =
+ getShowAllBuilds() && getCurrentUserRole() === "admin" ? "?all=true" : "";
+ const res = await fetch(`/api/build/list${queryParam}`, {
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ console.error("Failed to fetch builds from server");
+ return;
+ }
+
+ const data = await res.json();
+ const builds = data.builds || [];
+
+ const now = Date.now();
+ const validBuilds = builds.filter((build) => {
+ if (build.expiresAt && build.expiresAt <= now) {
+ return false;
+ }
+ return true;
+ });
+
+ if (validBuilds.length === 0) {
+ return;
+ }
+
+ buildResults.classList.remove("hidden");
+
+ for (const build of validBuilds) {
+ displayBuild(build);
+ saveBuildToStorage(build.id, build);
+ }
+ } catch (err) {
+ console.error("Failed to load builds:", err);
+
+ const builds = getAllBuildsFromStorage();
+ const now = Date.now();
+ const validBuilds = builds.filter((build) => {
+ if (build.expiresAt && build.expiresAt <= now) {
+ removeBuildFromStorage(build.id);
+ return false;
+ }
+ return true;
+ });
+
+ if (validBuilds.length > 0) {
+ buildResults.classList.remove("hidden");
+ validBuilds.forEach((build) => displayBuild(build));
+ }
+ }
+ }
+
+ function displayBuild(build) {
+ const buildContainer = document.createElement("div");
+ buildContainer.className =
+ "build-result-item mb-6 pb-6 border-b border-gray-700 last:border-b-0";
+ buildContainer.id = `build-${build.id}`;
+ const header = document.createElement("div");
+ header.className = "flex items-center justify-between mb-3";
+
+ const left = document.createElement("div");
+ left.className = "flex items-center gap-3";
+ const boxIcon = document.createElement("i");
+ boxIcon.className = "fa-solid fa-box text-blue-400";
+ const buildLabel = document.createElement("span");
+ buildLabel.className = "text-gray-300 font-medium";
+ buildLabel.textContent = `Build ID: ${build.id.substring(0, 8)}`;
+ const sep = document.createElement("span");
+ sep.className = "text-gray-500";
+ sep.textContent = "•";
+ const startedAt = document.createElement("span");
+ startedAt.className = "text-sm text-gray-400";
+ startedAt.textContent = new Date(build.startTime).toLocaleString();
+ left.appendChild(boxIcon);
+ left.appendChild(buildLabel);
+ left.appendChild(sep);
+ left.appendChild(startedAt);
+
+ const right = document.createElement("div");
+ right.className = "flex items-center gap-3";
+ const timerWrap = document.createElement("div");
+ timerWrap.className = "flex items-center gap-2";
+ const clockIcon = document.createElement("i");
+ clockIcon.className = "fa-solid fa-clock text-gray-400";
+ const timer = document.createElement("span");
+ timer.id = `timer-${build.id}`;
+ timer.className = "text-gray-300 font-medium";
+ timer.textContent = "Loading...";
+ timerWrap.appendChild(clockIcon);
+ timerWrap.appendChild(timer);
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.id = `delete-btn-${build.id}`;
+ deleteBtn.className =
+ "px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors flex items-center gap-2 text-sm";
+ deleteBtn.title = "Delete build";
+ const deleteIcon = document.createElement("i");
+ deleteIcon.className = "fa-solid fa-trash";
+ const deleteText = document.createElement("span");
+ deleteText.textContent = "Delete";
+ deleteBtn.appendChild(deleteIcon);
+ deleteBtn.appendChild(deleteText);
+ deleteBtn.addEventListener("click", () => deleteBuild(build.id));
+
+ right.appendChild(timerWrap);
+ right.appendChild(deleteBtn);
+
+ header.appendChild(left);
+ header.appendChild(right);
+
+ const filesContainer = document.createElement("div");
+ filesContainer.id = `files-${build.id}`;
+ filesContainer.className = "space-y-2";
+
+ buildContainer.appendChild(header);
+ buildContainer.appendChild(filesContainer);
+
+ buildFilesDiv.appendChild(buildContainer);
+
+ showBuildFilesForContainer(build, `files-${build.id}`, `timer-${build.id}`);
+ }
+
+ function showBuildFilesForContainer(build, containerId, timerId) {
+ const container = document.getElementById(containerId);
+ const timerEl = document.getElementById(timerId);
+
+ if (!container || !timerEl) return;
+
+ build.files.forEach((file) => {
+ const fileDiv = document.createElement("div");
+ fileDiv.className =
+ "flex items-center justify-between bg-gray-700/50 p-4 rounded-lg hover:bg-gray-700 transition-colors";
+
+ const fileMeta = document.createElement("div");
+ fileMeta.className = "flex items-center gap-3";
+ const fileIcon = document.createElement("i");
+ fileIcon.className = "fa-solid fa-file text-blue-400";
+ const fileText = document.createElement("div");
+ const fileName = document.createElement("div");
+ fileName.className = "text-white font-medium";
+ fileName.textContent = file.filename;
+ const filePlatform = document.createElement("div");
+ filePlatform.className = "text-sm text-gray-400";
+ const versionValue = formatStubVersion(file.version);
+ const platformText = document.createElement("span");
+ platformText.textContent = `${file.platform} | `;
+ const versionText = document.createElement("span");
+ versionText.className = isVersionMismatch(versionValue)
+ ? "server-version-number-mismatch"
+ : "server-version-number";
+ versionText.textContent =
+ versionValue === "unknown (legacy build)" ? versionValue : `v${versionValue}`;
+ filePlatform.appendChild(platformText);
+ filePlatform.appendChild(versionText);
+ fileText.appendChild(fileName);
+ fileText.appendChild(filePlatform);
+ fileMeta.appendChild(fileIcon);
+ fileMeta.appendChild(fileText);
+
+ const download = document.createElement("a");
+ download.href = `/api/build/download/${encodeURIComponent(file.filename)}`;
+ download.download = "";
+ download.className =
+ "px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center gap-2";
+ const downloadIcon = document.createElement("i");
+ downloadIcon.className = "fa-solid fa-download";
+ const downloadText = document.createElement("span");
+ downloadText.textContent = "Download";
+ download.appendChild(downloadIcon);
+ download.appendChild(downloadText);
+
+ fileDiv.appendChild(fileMeta);
+ fileDiv.appendChild(download);
+
+ container.appendChild(fileDiv);
+ });
+
+ if (build.expiresAt) {
+ updateExpirationTimer(timerEl, build.expiresAt);
+ setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000);
+ }
+ }
+
+ return {
+ displayBuild,
+ loadSavedBuilds,
+ saveBuildToStorage,
+ };
+}
diff --git a/Overlord-Server/public/assets/build-profile-manager.js b/Overlord-Server/public/assets/build-profile-manager.js
new file mode 100644
index 0000000..20583f8
--- /dev/null
+++ b/Overlord-Server/public/assets/build-profile-manager.js
@@ -0,0 +1,243 @@
+const PROFILE_EXPORT_MAGIC = "overlord-build-profile";
+
+function sanitizeProfileName(name) {
+ if (typeof name !== "string") return "";
+ const trimmed = name.trim();
+ if (!trimmed || trimmed.length > 64) return "";
+ if (!/^[A-Za-z0-9 _.-]+$/.test(trimmed)) return "";
+ return trimmed;
+}
+
+function exportProfileToFile(name, config) {
+ const payload = {
+ type: PROFILE_EXPORT_MAGIC,
+ version: 1,
+ name,
+ exportedAt: Date.now(),
+ config,
+ };
+ const json = JSON.stringify(payload, null, 2);
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ const safeName = (name || "build-profile").replace(/[^A-Za-z0-9._-]/g, "_");
+ a.href = url;
+ a.download = `${safeName}.json`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+}
+
+export function createBuildProfileManager({
+ elements,
+ collectFormSettings,
+ applyFormSettings,
+ saveFormSettings,
+}) {
+ const {
+ profileSelect,
+ profileNameInput,
+ profileSaveBtn,
+ profileLoadBtn,
+ profileDeleteBtn,
+ profileExportBtn,
+ profileImportBtn,
+ profileImportFile,
+ } = elements;
+
+ let buildProfiles = [];
+
+ function renderProfileOptions(selectedName = "") {
+ if (!profileSelect) return;
+ profileSelect.innerHTML = "";
+
+ const placeholder = document.createElement("option");
+ placeholder.value = "";
+ placeholder.textContent = "Select a saved profile...";
+ profileSelect.appendChild(placeholder);
+
+ buildProfiles.forEach((profile) => {
+ const option = document.createElement("option");
+ option.value = profile.name;
+ option.textContent = profile.name;
+ if (selectedName && selectedName === profile.name) option.selected = true;
+ profileSelect.appendChild(option);
+ });
+ }
+
+ function getSelectedProfile() {
+ if (!profileSelect || !profileSelect.value) return null;
+ return buildProfiles.find((profile) => profile.name === profileSelect.value) || null;
+ }
+
+ async function loadBuildProfiles(selectedName = "") {
+ try {
+ const res = await fetch("/api/build/profiles", { credentials: "include" });
+ if (!res.ok) {
+ buildProfiles = [];
+ renderProfileOptions();
+ return;
+ }
+ const data = await res.json();
+ buildProfiles = Array.isArray(data?.profiles) ? data.profiles : [];
+ renderProfileOptions(selectedName || profileSelect?.value || "");
+ } catch (err) {
+ console.error("Failed to load build profiles:", err);
+ buildProfiles = [];
+ renderProfileOptions();
+ }
+ }
+
+ async function saveCurrentProfile() {
+ const inputName = profileNameInput?.value || "";
+ const selectedName = profileSelect?.value || "";
+ const profileName = sanitizeProfileName(inputName || selectedName);
+ if (!profileName) {
+ alert("Enter a valid profile name (1-64 chars; letters, numbers, spaces, . _ -)");
+ profileNameInput?.focus();
+ return;
+ }
+
+ const config = collectFormSettings();
+
+ const res = await fetch("/api/build/profiles", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ name: profileName, config }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "Failed to save profile");
+ }
+
+ if (profileNameInput) profileNameInput.value = profileName;
+ await loadBuildProfiles(profileName);
+ }
+
+ async function importProfileFromFile(file) {
+ const text = await file.text();
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new Error("Invalid JSON file");
+ }
+
+ const fallbackName = file.name.replace(/\.json$/i, "");
+ const importedName = sanitizeProfileName(parsed?.name || fallbackName);
+ if (!importedName) {
+ throw new Error("Invalid profile name in imported file");
+ }
+
+ const importedConfig = parsed?.config;
+ if (!importedConfig || typeof importedConfig !== "object" || Array.isArray(importedConfig)) {
+ throw new Error("Imported profile is missing a valid config object");
+ }
+
+ applyFormSettings(importedConfig);
+ saveFormSettings();
+
+ if (profileNameInput) profileNameInput.value = importedName;
+ await saveCurrentProfile();
+ }
+
+ function bindProfileControls() {
+ if (profileSelect) {
+ profileSelect.addEventListener("change", () => {
+ const profile = getSelectedProfile();
+ if (profileNameInput) {
+ profileNameInput.value = profile?.name || "";
+ }
+ });
+ }
+
+ if (profileSaveBtn) {
+ profileSaveBtn.addEventListener("click", async () => {
+ try {
+ await saveCurrentProfile();
+ alert("Profile saved.");
+ } catch (err) {
+ alert(err?.message || "Failed to save profile");
+ }
+ });
+ }
+
+ if (profileLoadBtn) {
+ profileLoadBtn.addEventListener("click", () => {
+ const profile = getSelectedProfile();
+ if (!profile) {
+ alert("Select a profile to load.");
+ return;
+ }
+ applyFormSettings(profile.config || {});
+ saveFormSettings();
+ if (profileNameInput) profileNameInput.value = profile.name;
+ alert(`Loaded profile: ${profile.name}`);
+ });
+ }
+
+ if (profileDeleteBtn) {
+ profileDeleteBtn.addEventListener("click", async () => {
+ const profile = getSelectedProfile();
+ if (!profile) {
+ alert("Select a profile to delete.");
+ return;
+ }
+ if (!confirm(`Delete profile \"${profile.name}\"?`)) {
+ return;
+ }
+ try {
+ const res = await fetch(`/api/build/profiles/${encodeURIComponent(profile.name)}`, {
+ method: "DELETE",
+ credentials: "include",
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "Failed to delete profile");
+ }
+ await loadBuildProfiles();
+ if (profileNameInput) profileNameInput.value = "";
+ alert("Profile deleted.");
+ } catch (err) {
+ alert(err?.message || "Failed to delete profile");
+ }
+ });
+ }
+
+ if (profileExportBtn) {
+ profileExportBtn.addEventListener("click", () => {
+ const profile = getSelectedProfile();
+ if (profile) {
+ exportProfileToFile(profile.name, profile.config || {});
+ return;
+ }
+ const fallbackName = sanitizeProfileName(profileNameInput?.value || "") || "current-build-profile";
+ exportProfileToFile(fallbackName, collectFormSettings());
+ });
+ }
+
+ if (profileImportBtn && profileImportFile) {
+ profileImportBtn.addEventListener("click", () => profileImportFile.click());
+ profileImportFile.addEventListener("change", async () => {
+ const file = profileImportFile.files?.[0];
+ profileImportFile.value = "";
+ if (!file) return;
+ try {
+ await importProfileFromFile(file);
+ alert("Profile imported and saved.");
+ } catch (err) {
+ alert(err?.message || "Failed to import profile");
+ }
+ });
+ }
+ }
+
+ return {
+ bindProfileControls,
+ loadBuildProfiles,
+ sanitizeProfileName,
+ };
+}
diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index 2cad010..4453492 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -1,3 +1,10 @@
+import { createBuildProfileManager } from "./build-profile-manager.js";
+import {
+ createBuildHistoryManager,
+ formatFileSize,
+ updateExpirationTimer,
+} from "./build-history-manager.js";
+
const form = document.getElementById("build-form");
const buildBtn = document.getElementById("build-btn");
const buildStatus = document.getElementById("build-status");
@@ -39,6 +46,7 @@ const platformInputs = document.querySelectorAll('input[name="platform"]');
let currentServerVersion = null;
let currentUserRole = null;
+let showAllBuilds = false;
async function loadServerVersion() {
try {
@@ -115,9 +123,6 @@ function updateWindowsSectionVisibility() {
}
const BUILD_SETTINGS_KEY = "overlord_build_settings";
-const PROFILE_EXPORT_MAGIC = "overlord-build-profile";
-
-let buildProfiles = [];
function collectFormSettings() {
return {
@@ -285,130 +290,35 @@ function restoreFormSettings() {
}
}
-function sanitizeProfileName(name) {
- if (typeof name !== "string") return "";
- const trimmed = name.trim();
- if (!trimmed || trimmed.length > 64) return "";
- if (!/^[A-Za-z0-9 _.-]+$/.test(trimmed)) return "";
- return trimmed;
-}
+const buildProfileManager = createBuildProfileManager({
+ elements: {
+ profileSelect,
+ profileNameInput,
+ profileSaveBtn,
+ profileLoadBtn,
+ profileDeleteBtn,
+ profileExportBtn,
+ profileImportBtn,
+ profileImportFile,
+ },
+ collectFormSettings,
+ applyFormSettings,
+ saveFormSettings,
+});
+const { loadBuildProfiles } = buildProfileManager;
-function renderProfileOptions(selectedName = "") {
- if (!profileSelect) return;
- profileSelect.innerHTML = "";
-
- const placeholder = document.createElement("option");
- placeholder.value = "";
- placeholder.textContent = "Select a saved profile...";
- profileSelect.appendChild(placeholder);
-
- buildProfiles.forEach((profile) => {
- const option = document.createElement("option");
- option.value = profile.name;
- option.textContent = profile.name;
- if (selectedName && selectedName === profile.name) option.selected = true;
- profileSelect.appendChild(option);
- });
-}
-
-function getSelectedProfile() {
- if (!profileSelect || !profileSelect.value) return null;
- return buildProfiles.find((profile) => profile.name === profileSelect.value) || null;
-}
-
-async function loadBuildProfiles(selectedName = "") {
- try {
- const res = await fetch("/api/build/profiles", { credentials: "include" });
- if (!res.ok) {
- buildProfiles = [];
- renderProfileOptions();
- return;
- }
- const data = await res.json();
- buildProfiles = Array.isArray(data?.profiles) ? data.profiles : [];
- renderProfileOptions(selectedName || profileSelect?.value || "");
- } catch (err) {
- console.error("Failed to load build profiles:", err);
- buildProfiles = [];
- renderProfileOptions();
- }
-}
-
-async function saveCurrentProfile() {
- const inputName = profileNameInput?.value || "";
- const selectedName = profileSelect?.value || "";
- const profileName = sanitizeProfileName(inputName || selectedName);
- if (!profileName) {
- alert("Enter a valid profile name (1-64 chars; letters, numbers, spaces, . _ -)");
- profileNameInput?.focus();
- return;
- }
-
- const config = collectFormSettings();
-
- const res = await fetch("/api/build/profiles", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ name: profileName, config }),
- });
-
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.error || "Failed to save profile");
- }
-
- if (profileNameInput) profileNameInput.value = profileName;
- await loadBuildProfiles(profileName);
-}
-
-function exportProfileToFile(name, config) {
- const payload = {
- type: PROFILE_EXPORT_MAGIC,
- version: 1,
- name,
- exportedAt: Date.now(),
- config,
- };
- const json = JSON.stringify(payload, null, 2);
- const blob = new Blob([json], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- const safeName = (name || "build-profile").replace(/[^A-Za-z0-9._-]/g, "_");
- a.href = url;
- a.download = `${safeName}.json`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
-}
-
-async function importProfileFromFile(file) {
- const text = await file.text();
- let parsed;
- try {
- parsed = JSON.parse(text);
- } catch {
- throw new Error("Invalid JSON file");
- }
-
- const fallbackName = file.name.replace(/\.json$/i, "");
- const importedName = sanitizeProfileName(parsed?.name || fallbackName);
- if (!importedName) {
- throw new Error("Invalid profile name in imported file");
- }
-
- const importedConfig = parsed?.config;
- if (!importedConfig || typeof importedConfig !== "object" || Array.isArray(importedConfig)) {
- throw new Error("Imported profile is missing a valid config object");
- }
-
- applyFormSettings(importedConfig);
- saveFormSettings();
-
- if (profileNameInput) profileNameInput.value = importedName;
- await saveCurrentProfile();
-}
+const buildHistoryManager = createBuildHistoryManager({
+ buildResults,
+ buildFilesDiv,
+ getCurrentServerVersion: () => currentServerVersion,
+ getCurrentUserRole: () => currentUserRole,
+ getShowAllBuilds: () => showAllBuilds,
+});
+const {
+ displayBuild,
+ loadSavedBuilds,
+ saveBuildToStorage,
+} = buildHistoryManager;
const CRYPTABLE_DISABLE_TARGETS = [
'input[name="enable-persistence"]',
@@ -1419,94 +1329,7 @@ if (logoutBtn && !logoutBtn.dataset.boundLogout) {
});
}
-if (profileSelect) {
- profileSelect.addEventListener("change", () => {
- const profile = getSelectedProfile();
- if (profileNameInput) {
- profileNameInput.value = profile?.name || "";
- }
- });
-}
-
-if (profileSaveBtn) {
- profileSaveBtn.addEventListener("click", async () => {
- try {
- await saveCurrentProfile();
- alert("Profile saved.");
- } catch (err) {
- alert(err?.message || "Failed to save profile");
- }
- });
-}
-
-if (profileLoadBtn) {
- profileLoadBtn.addEventListener("click", () => {
- const profile = getSelectedProfile();
- if (!profile) {
- alert("Select a profile to load.");
- return;
- }
- applyFormSettings(profile.config || {});
- saveFormSettings();
- if (profileNameInput) profileNameInput.value = profile.name;
- alert(`Loaded profile: ${profile.name}`);
- });
-}
-
-if (profileDeleteBtn) {
- profileDeleteBtn.addEventListener("click", async () => {
- const profile = getSelectedProfile();
- if (!profile) {
- alert("Select a profile to delete.");
- return;
- }
- if (!confirm(`Delete profile \"${profile.name}\"?`)) {
- return;
- }
- try {
- const res = await fetch(`/api/build/profiles/${encodeURIComponent(profile.name)}`, {
- method: "DELETE",
- credentials: "include",
- });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.error || "Failed to delete profile");
- }
- await loadBuildProfiles();
- if (profileNameInput) profileNameInput.value = "";
- alert("Profile deleted.");
- } catch (err) {
- alert(err?.message || "Failed to delete profile");
- }
- });
-}
-
-if (profileExportBtn) {
- profileExportBtn.addEventListener("click", () => {
- const profile = getSelectedProfile();
- if (profile) {
- exportProfileToFile(profile.name, profile.config || {});
- return;
- }
- const fallbackName = sanitizeProfileName(profileNameInput?.value || "") || "current-build-profile";
- exportProfileToFile(fallbackName, collectFormSettings());
- });
-}
-
-if (profileImportBtn && profileImportFile) {
- profileImportBtn.addEventListener("click", () => profileImportFile.click());
- profileImportFile.addEventListener("change", async () => {
- const file = profileImportFile.files?.[0];
- profileImportFile.value = "";
- if (!file) return;
- try {
- await importProfileFromFile(file);
- alert("Profile imported and saved.");
- } catch (err) {
- alert(err?.message || "Failed to import profile");
- }
- });
-}
+buildProfileManager.bindProfileControls();
form?.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -2078,8 +1901,8 @@ function showBuildFiles(files, buildId, expiresAt) {
buildInfoDiv.appendChild(infoRow);
buildFilesDiv.appendChild(buildInfoDiv);
- updateExpirationTimer();
- setInterval(updateExpirationTimer, 60000);
+ updateExpirationTimer(timer, expiresAt);
+ setInterval(() => updateExpirationTimer(timer, expiresAt), 60000);
files.forEach((file) => {
const fileDiv = document.createElement("div");
@@ -2112,333 +1935,6 @@ function showBuildFiles(files, buildId, expiresAt) {
});
}
-function updateExpirationTimer(timerEl, expiresAt) {
- if (!timerEl) return;
-
- const now = Date.now();
- const remaining = expiresAt - now;
-
- if (remaining <= 0) {
- timerEl.textContent = "Expired";
- timerEl.className = "text-red-400 font-medium";
- return;
- }
-
- const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
- const hours = Math.floor(
- (remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
- );
- const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
-
- if (days > 0) {
- timerEl.textContent = `${days}d ${hours}h`;
- } else if (hours > 0) {
- timerEl.textContent = `${hours}h ${minutes}m`;
- } else {
- timerEl.textContent = `${minutes}m`;
- }
-
- if (days >= 3) {
- timerEl.className = "text-green-400 font-medium";
- } else if (days >= 1) {
- timerEl.className = "text-yellow-400 font-medium";
- } else {
- timerEl.className = "text-orange-400 font-medium";
- }
-}
-
-async function deleteBuild(buildId) {
- if (!confirm("Are you sure you want to delete this build?")) {
- return;
- }
-
- try {
- const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, {
- method: "DELETE",
- credentials: "include",
- });
-
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.error || "Failed to delete build");
- }
-
- const buildElement = document.getElementById(`build-${buildId}`);
- if (buildElement) {
- buildElement.remove();
- }
-
- removeBuildFromStorage(buildId);
-
- if (buildFilesDiv.children.length === 0) {
- buildResults.classList.add("hidden");
- }
- } catch (err) {
- console.error("Failed to delete build:", err);
- alert("Failed to delete build. Please try again.");
- }
-}
-
-function formatFileSize(bytes) {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
-}
-
-function formatStubVersion(version) {
- if (typeof version === "string" && version.trim()) {
- return version.trim();
- }
- return "unknown (legacy build)";
-}
-
-function isVersionMismatch(versionValue) {
- if (!currentServerVersion) return false;
- return versionValue !== currentServerVersion;
-}
-
-function saveBuildToStorage(buildId, buildData) {
- try {
- const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
- const existingIndex = builds.findIndex((b) => b.id === buildId);
-
- if (existingIndex >= 0) {
- builds[existingIndex] = buildData;
- } else {
- builds.push(buildData);
- }
-
- if (builds.length > 20) {
- builds.splice(0, builds.length - 20);
- }
-
- localStorage.setItem("overlord_builds", JSON.stringify(builds));
- } catch (err) {
- console.error("Failed to save build to localStorage:", err);
- }
-}
-
-function getBuildFromStorage(buildId) {
- try {
- const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
- return builds.find((b) => b.id === buildId);
- } catch (err) {
- console.error("Failed to get build from localStorage:", err);
- return null;
- }
-}
-
-function getAllBuildsFromStorage() {
- try {
- const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
-
- return builds.sort((a, b) => b.startTime - a.startTime);
- } catch (err) {
- console.error("Failed to get builds from localStorage:", err);
- return [];
- }
-}
-
-function removeBuildFromStorage(buildId) {
- try {
- const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
- const filtered = builds.filter((b) => b.id !== buildId);
- localStorage.setItem("overlord_builds", JSON.stringify(filtered));
- } catch (err) {
- console.error("Failed to remove build from localStorage:", err);
- }
-}
-
-let showAllBuilds = false;
-
-async function loadSavedBuilds() {
- try {
- const queryParam = showAllBuilds && currentUserRole === "admin" ? "?all=true" : "";
- const res = await fetch(`/api/build/list${queryParam}`, {
- credentials: "include",
- });
-
- if (!res.ok) {
- console.error("Failed to fetch builds from server");
- return;
- }
-
- const data = await res.json();
- const builds = data.builds || [];
-
- const now = Date.now();
- const validBuilds = builds.filter((build) => {
- if (build.expiresAt && build.expiresAt <= now) {
- return false;
- }
- return true;
- });
-
- if (validBuilds.length === 0) {
- return;
- }
-
- buildResults.classList.remove("hidden");
-
- for (const build of validBuilds) {
- displayBuild(build);
-
- saveBuildToStorage(build.id, build);
- }
- } catch (err) {
- console.error("Failed to load builds:", err);
-
- const builds = getAllBuildsFromStorage();
- const now = Date.now();
- const validBuilds = builds.filter((build) => {
- if (build.expiresAt && build.expiresAt <= now) {
- removeBuildFromStorage(build.id);
- return false;
- }
- return true;
- });
-
- if (validBuilds.length > 0) {
- buildResults.classList.remove("hidden");
- validBuilds.forEach((build) => displayBuild(build));
- }
- }
-}
-
-function displayBuild(build) {
- const buildContainer = document.createElement("div");
- buildContainer.className =
- "build-result-item mb-6 pb-6 border-b border-gray-700 last:border-b-0";
- buildContainer.id = `build-${build.id}`;
- const header = document.createElement("div");
- header.className = "flex items-center justify-between mb-3";
-
- const left = document.createElement("div");
- left.className = "flex items-center gap-3";
- const boxIcon = document.createElement("i");
- boxIcon.className = "fa-solid fa-box text-blue-400";
- const buildLabel = document.createElement("span");
- buildLabel.className = "text-gray-300 font-medium";
- buildLabel.textContent = `Build ID: ${build.id.substring(0, 8)}`;
- const sep = document.createElement("span");
- sep.className = "text-gray-500";
- sep.textContent = "•";
- const startedAt = document.createElement("span");
- startedAt.className = "text-sm text-gray-400";
- startedAt.textContent = new Date(build.startTime).toLocaleString();
- left.appendChild(boxIcon);
- left.appendChild(buildLabel);
- left.appendChild(sep);
- left.appendChild(startedAt);
-
- const right = document.createElement("div");
- right.className = "flex items-center gap-3";
- const timerWrap = document.createElement("div");
- timerWrap.className = "flex items-center gap-2";
- const clockIcon = document.createElement("i");
- clockIcon.className = "fa-solid fa-clock text-gray-400";
- const timer = document.createElement("span");
- timer.id = `timer-${build.id}`;
- timer.className = "text-gray-300 font-medium";
- timer.textContent = "Loading...";
- timerWrap.appendChild(clockIcon);
- timerWrap.appendChild(timer);
-
- const deleteBtn = document.createElement("button");
- deleteBtn.id = `delete-btn-${build.id}`;
- deleteBtn.className =
- "px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors flex items-center gap-2 text-sm";
- deleteBtn.title = "Delete build";
- const deleteIcon = document.createElement("i");
- deleteIcon.className = "fa-solid fa-trash";
- const deleteText = document.createElement("span");
- deleteText.textContent = "Delete";
- deleteBtn.appendChild(deleteIcon);
- deleteBtn.appendChild(deleteText);
- deleteBtn.addEventListener("click", () => deleteBuild(build.id));
-
- right.appendChild(timerWrap);
- right.appendChild(deleteBtn);
-
- header.appendChild(left);
- header.appendChild(right);
-
- const filesContainer = document.createElement("div");
- filesContainer.id = `files-${build.id}`;
- filesContainer.className = "space-y-2";
-
- buildContainer.appendChild(header);
- buildContainer.appendChild(filesContainer);
-
- buildFilesDiv.appendChild(buildContainer);
-
- showBuildFilesForContainer(build, `files-${build.id}`, `timer-${build.id}`);
-}
-
-function showBuildFilesForContainer(build, containerId, timerId) {
- const container = document.getElementById(containerId);
- const timerEl = document.getElementById(timerId);
-
- if (!container || !timerEl) return;
-
- build.files.forEach((file) => {
- const fileDiv = document.createElement("div");
- fileDiv.className =
- "flex items-center justify-between bg-gray-700/50 p-4 rounded-lg hover:bg-gray-700 transition-colors";
-
- const fileMeta = document.createElement("div");
- fileMeta.className = "flex items-center gap-3";
- const fileIcon = document.createElement("i");
- fileIcon.className = "fa-solid fa-file text-blue-400";
- const fileText = document.createElement("div");
- const fileName = document.createElement("div");
- fileName.className = "text-white font-medium";
- fileName.textContent = file.filename;
- const filePlatform = document.createElement("div");
- filePlatform.className = "text-sm text-gray-400";
- const versionValue = formatStubVersion(file.version);
- const platformText = document.createElement("span");
- platformText.textContent = `${file.platform} | `;
- const versionText = document.createElement("span");
- versionText.className = isVersionMismatch(versionValue)
- ? "server-version-number-mismatch"
- : "server-version-number";
- versionText.textContent =
- versionValue === "unknown (legacy build)" ? versionValue : `v${versionValue}`;
- filePlatform.appendChild(platformText);
- filePlatform.appendChild(versionText);
- fileText.appendChild(fileName);
- fileText.appendChild(filePlatform);
- fileMeta.appendChild(fileIcon);
- fileMeta.appendChild(fileText);
-
- const download = document.createElement("a");
- download.href = `/api/build/download/${encodeURIComponent(file.filename)}`;
- download.download = "";
- download.className =
- "px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center gap-2";
- const downloadIcon = document.createElement("i");
- downloadIcon.className = "fa-solid fa-download";
- const downloadText = document.createElement("span");
- downloadText.textContent = "Download";
- download.appendChild(downloadIcon);
- download.appendChild(downloadText);
-
- fileDiv.appendChild(fileMeta);
- fileDiv.appendChild(download);
-
- container.appendChild(fileDiv);
- });
-
- if (build.expiresAt) {
- updateExpirationTimer(timerEl, build.expiresAt);
-
- setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000);
- }
-}
-
const buildUpdateAllBtn = document.getElementById("build-update-all-btn");
const updateAllModal = document.getElementById("update-all-modal");
const updateAllModalBody = document.getElementById("update-all-modal-body");
diff --git a/Overlord-Server/public/assets/filebrowser-hex-hash.js b/Overlord-Server/public/assets/filebrowser-hex-hash.js
new file mode 100644
index 0000000..44a2c91
--- /dev/null
+++ b/Overlord-Server/public/assets/filebrowser-hex-hash.js
@@ -0,0 +1,253 @@
+import { escapeHtml, formatBytes } from "./filebrowser-utils.js";
+
+const KNOWN_HASHES_SHA256 = {
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)",
+};
+
+function decodeTextHead(bytes) {
+ try {
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
+ } catch {
+ return new TextDecoder("latin1").decode(bytes);
+ }
+}
+
+export function createFileHexHashManager({
+ send,
+ notifyToast,
+ getCurrentPreviewEntry,
+}) {
+ const previewTextHeadHost = document.getElementById("preview-text-head-host");
+ const previewTextHead = document.getElementById("preview-text-head");
+ const previewHexBtn = document.getElementById("preview-hex-btn");
+ const previewHashBtn = document.getElementById("preview-hash-btn");
+ const previewHashResult = document.getElementById("preview-hash-result");
+ const previewHashValue = document.getElementById("preview-hash-value");
+ const previewHashKnown = document.getElementById("preview-hash-known");
+
+ const hexViewerModal = document.getElementById("hex-viewer-modal");
+ const hexViewerFile = document.getElementById("hex-viewer-file");
+ const hexViewerBody = document.getElementById("hex-viewer-body");
+ const hexViewerInfo = document.getElementById("hex-viewer-info");
+ const hexViewerCloseBtn = document.getElementById("hex-viewer-close-btn");
+ const hexViewerCopyBtn = document.getElementById("hex-viewer-copy-btn");
+
+ const peekRequests = new Map();
+ const hashRequests = new Map();
+ let lastHexBytes = null;
+
+ function resetPreviewTextHead() {
+ previewTextHeadHost?.classList.add("hidden");
+ if (previewTextHead) previewTextHead.textContent = "";
+ }
+
+ function resetPreviewHash() {
+ previewHashResult?.classList.add("hidden");
+ if (previewHashValue) previewHashValue.textContent = "";
+ if (previewHashKnown) {
+ previewHashKnown.classList.add("hidden");
+ previewHashKnown.textContent = "";
+ }
+ }
+
+ function requestFilePeek(path, kind) {
+ const commandId = `peek-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ peekRequests.set(commandId, { kind, path, fileName });
+ send({
+ type: "command",
+ commandType: "file_peek",
+ id: commandId,
+ payload: { path, bytes: 4096 },
+ });
+ }
+
+ function openHexViewer(path) {
+ if (!hexViewerModal) return;
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ if (hexViewerFile) hexViewerFile.textContent = fileName;
+ if (hexViewerBody) hexViewerBody.innerHTML = '
Loading...
';
+ if (hexViewerInfo) hexViewerInfo.textContent = "";
+ hexViewerModal.classList.add("show");
+ requestFilePeek(path, "hex");
+ }
+
+ function closeHexViewer() {
+ hexViewerModal?.classList.remove("show");
+ }
+
+ function renderHexDump(bytes, totalSize) {
+ if (!hexViewerBody) return;
+ const len = bytes.length;
+ const rows = [];
+ for (let off = 0; off < len; off += 16) {
+ const chunk = bytes.slice(off, Math.min(off + 16, len));
+ const hexParts = [];
+ const asciiParts = [];
+ for (let i = 0; i < 16; i++) {
+ if (i < chunk.length) {
+ hexParts.push(chunk[i].toString(16).padStart(2, "0"));
+ const b = chunk[i];
+ if (b >= 0x20 && b <= 0x7e) {
+ asciiParts.push(`${escapeHtml(String.fromCharCode(b))}`);
+ } else {
+ asciiParts.push('.');
+ }
+ } else {
+ hexParts.push(" ");
+ asciiParts.push(" ");
+ }
+ if (i === 7) hexParts.push("");
+ }
+ rows.push(
+ `` +
+ `${off.toString(16).padStart(8, "0")}` +
+ `${hexParts.join(" ")}` +
+ `${asciiParts.join("")}` +
+ `
`,
+ );
+ }
+ hexViewerBody.innerHTML = rows.join("");
+ if (hexViewerInfo) {
+ const peekStr = `${formatBytes(len)} peek`;
+ const totalStr = totalSize > 0 ? ` of ${formatBytes(totalSize)}` : "";
+ hexViewerInfo.textContent = peekStr + totalStr;
+ }
+ lastHexBytes = bytes;
+ }
+
+ function handleFilePeekResult(msg) {
+ const req = msg.commandId ? peekRequests.get(msg.commandId) : null;
+ if (!req) return;
+ peekRequests.delete(msg.commandId);
+
+ const currentPreviewEntry = getCurrentPreviewEntry();
+ if (msg.error) {
+ if (req.kind === "hex") {
+ if (hexViewerBody) hexViewerBody.innerHTML = `${escapeHtml(msg.error)}
`;
+ }
+ if (req.kind === "preview" && currentPreviewEntry && currentPreviewEntry.path === req.path) {
+ previewTextHeadHost?.classList.add("hidden");
+ }
+ return;
+ }
+
+ let data = msg.data;
+ if (!data) data = new Uint8Array(0);
+ if (!(data instanceof Uint8Array)) {
+ try { data = new Uint8Array(data); } catch { data = new Uint8Array(0); }
+ }
+
+ if (req.kind === "hex") {
+ renderHexDump(data, Number(msg.size || 0));
+ return;
+ }
+
+ if (!currentPreviewEntry || currentPreviewEntry.path !== req.path) return;
+ if (!msg.isText || data.length === 0) {
+ previewTextHeadHost?.classList.add("hidden");
+ return;
+ }
+ const text = decodeTextHead(data);
+ if (previewTextHead) previewTextHead.textContent = text;
+ previewTextHeadHost?.classList.remove("hidden");
+ }
+
+ function requestFileHash(path, source) {
+ const commandId = `hash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ hashRequests.set(commandId, { path, fileName, source });
+ send({
+ type: "command",
+ commandType: "file_hash",
+ id: commandId,
+ payload: { path, algorithm: "sha256" },
+ });
+ notifyToast(`Hashing ${fileName}...`, "info", 2500);
+ }
+
+ function handleFileHashResult(msg) {
+ const req = msg.commandId ? hashRequests.get(msg.commandId) : null;
+ if (!req) return;
+ hashRequests.delete(msg.commandId);
+
+ if (msg.error) {
+ notifyToast(`Hash failed: ${msg.error}`, "error", 5000);
+ return;
+ }
+ const digest = (msg.digest || "").toLowerCase();
+ const known = KNOWN_HASHES_SHA256[digest];
+ const currentPreviewEntry = getCurrentPreviewEntry();
+
+ if (currentPreviewEntry && currentPreviewEntry.path === req.path && previewHashValue) {
+ previewHashValue.textContent = digest;
+ previewHashResult?.classList.remove("hidden");
+ if (previewHashKnown) {
+ if (known) {
+ previewHashKnown.textContent = `Known: ${known}`;
+ previewHashKnown.classList.remove("hidden");
+ } else {
+ previewHashKnown.classList.add("hidden");
+ }
+ }
+ }
+
+ const msgSuffix = known ? ` - matches known: ${known}` : "";
+ notifyToast(`SHA-256: ${digest.slice(0, 16)}...${msgSuffix}`, "success", 6000);
+ navigator.clipboard?.writeText(digest).catch(() => {});
+ }
+
+ function bindControls() {
+ hexViewerCloseBtn?.addEventListener("click", closeHexViewer);
+ hexViewerModal?.addEventListener("click", (e) => {
+ if (e.target === hexViewerModal) closeHexViewer();
+ });
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && hexViewerModal?.classList.contains("show")) {
+ closeHexViewer();
+ }
+ });
+ hexViewerCopyBtn?.addEventListener("click", async () => {
+ if (!lastHexBytes) return;
+ const hex = Array.from(lastHexBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
+ try {
+ await navigator.clipboard.writeText(hex);
+ notifyToast("Hex copied to clipboard", "success", 2000);
+ } catch {
+ notifyToast("Clipboard copy failed", "error", 2500);
+ }
+ });
+
+ previewHexBtn?.addEventListener("click", () => {
+ const currentPreviewEntry = getCurrentPreviewEntry();
+ if (currentPreviewEntry && !currentPreviewEntry.isDir) {
+ openHexViewer(currentPreviewEntry.path);
+ }
+ });
+ previewHashBtn?.addEventListener("click", () => {
+ const currentPreviewEntry = getCurrentPreviewEntry();
+ if (currentPreviewEntry && !currentPreviewEntry.isDir) {
+ requestFileHash(currentPreviewEntry.path, "preview");
+ }
+ });
+ previewHashValue?.addEventListener("click", async () => {
+ const v = previewHashValue.textContent || "";
+ if (!v) return;
+ try {
+ await navigator.clipboard.writeText(v);
+ notifyToast("SHA-256 copied", "success", 1500);
+ } catch {}
+ });
+ }
+
+ return {
+ bindControls,
+ handleFileHashResult,
+ handleFilePeekResult,
+ openHexViewer,
+ requestFileHash,
+ requestFilePeek,
+ resetPreviewHash,
+ resetPreviewTextHead,
+ };
+}
diff --git a/Overlord-Server/public/assets/filebrowser-preview-modal.js b/Overlord-Server/public/assets/filebrowser-preview-modal.js
new file mode 100644
index 0000000..2ab62c9
--- /dev/null
+++ b/Overlord-Server/public/assets/filebrowser-preview-modal.js
@@ -0,0 +1,166 @@
+import {
+ PREVIEW_IMAGE_EXTS,
+ PREVIEW_MAX_BYTES,
+ escapeHtml,
+ formatBytes,
+ getFileExt,
+ getPreviewMimeType,
+} from "./filebrowser-utils.js";
+
+export function createFilePreviewModal({
+ clientId,
+ notifyToast,
+ onDownload,
+}) {
+ const filePreviewModal = document.getElementById("file-preview-modal");
+ const previewFileNameEl = document.getElementById("preview-file-name");
+ const previewContent = document.getElementById("preview-content");
+ const previewCloseBtn = document.getElementById("preview-close-btn");
+ const previewDownloadBtn = document.getElementById("preview-download-btn");
+ const previewStatusEl = document.getElementById("preview-status");
+
+ let currentPreviewBlobUrl = null;
+ let currentPreviewPath = null;
+
+ function closePreview() {
+ if (filePreviewModal) filePreviewModal.classList.remove("show");
+ if (currentPreviewBlobUrl) {
+ URL.revokeObjectURL(currentPreviewBlobUrl);
+ currentPreviewBlobUrl = null;
+ }
+ if (previewContent) previewContent.innerHTML = "";
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ currentPreviewPath = null;
+ }
+
+ async function openFilePreview(path, knownSize) {
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ const mimeType = getPreviewMimeType(fileName);
+ if (!mimeType) return;
+
+ if (typeof knownSize === "number" && knownSize > PREVIEW_MAX_BYTES) {
+ notifyToast(`File too large to preview (${formatBytes(knownSize)})`, "info", 4000);
+ return;
+ }
+
+ currentPreviewPath = path;
+ if (previewFileNameEl) previewFileNameEl.textContent = fileName;
+ if (previewContent) previewContent.innerHTML = '';
+ if (previewStatusEl) previewStatusEl.textContent = "Loading...";
+ if (filePreviewModal) filePreviewModal.classList.add("show");
+
+ try {
+ const requestRes = await fetch("/api/file/download/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ clientId, path }),
+ });
+
+ if (!requestRes.ok) {
+ const text = await requestRes.text();
+ if (currentPreviewPath !== path) return;
+ if (previewContent) previewContent.innerHTML = `${escapeHtml(text || "Failed to load preview")}
`;
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ return;
+ }
+
+ const requestData = await requestRes.json();
+ const downloadUrl = typeof requestData?.downloadUrl === "string"
+ ? requestData.downloadUrl
+ : (requestData?.downloadId
+ ? `/api/file/download/${encodeURIComponent(requestData.downloadId)}`
+ : "");
+
+ if (!downloadUrl) {
+ if (currentPreviewPath !== path) return;
+ if (previewContent) previewContent.innerHTML = `Failed to load preview
`;
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ return;
+ }
+
+ const res = await fetch(downloadUrl, {
+ method: "GET",
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ if (currentPreviewPath !== path) return;
+ if (previewContent) previewContent.innerHTML = `${escapeHtml(text || "Failed to load preview")}
`;
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ return;
+ }
+
+ const contentLength = Number(res.headers.get("Content-Length") || 0);
+ if (contentLength > PREVIEW_MAX_BYTES) {
+ if (currentPreviewPath !== path) return;
+ if (previewContent) previewContent.innerHTML = `File too large to preview (${escapeHtml(formatBytes(contentLength))})
`;
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ return;
+ }
+
+ const arrayBuffer = await res.arrayBuffer();
+
+ if (currentPreviewPath !== path) return;
+
+ if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
+ if (previewContent) previewContent.innerHTML = `File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})
`;
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ return;
+ }
+
+ if (currentPreviewBlobUrl) {
+ URL.revokeObjectURL(currentPreviewBlobUrl);
+ currentPreviewBlobUrl = null;
+ }
+
+ const blob = new Blob([arrayBuffer], { type: mimeType });
+ currentPreviewBlobUrl = URL.createObjectURL(blob);
+
+ if (previewContent) {
+ previewContent.innerHTML = "";
+ if (PREVIEW_IMAGE_EXTS.has(getFileExt(fileName))) {
+ const img = document.createElement("img");
+ img.src = currentPreviewBlobUrl;
+ img.alt = "";
+ previewContent.appendChild(img);
+ } else {
+ const obj = document.createElement("object");
+ obj.data = currentPreviewBlobUrl;
+ obj.type = "application/pdf";
+ previewContent.appendChild(obj);
+ }
+ }
+
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ } catch (err) {
+ if (currentPreviewPath !== path) return;
+ if (previewContent) previewContent.innerHTML = `${escapeHtml(err.message || "Failed to load preview")}
`;
+ if (previewStatusEl) previewStatusEl.textContent = "";
+ }
+ }
+
+ function bindControls() {
+ if (previewCloseBtn) previewCloseBtn.addEventListener("click", closePreview);
+ if (previewDownloadBtn) previewDownloadBtn.addEventListener("click", () => {
+ if (currentPreviewPath) onDownload(currentPreviewPath);
+ });
+ if (filePreviewModal) {
+ filePreviewModal.addEventListener("click", (e) => {
+ if (e.target === filePreviewModal) closePreview();
+ });
+ }
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && filePreviewModal && filePreviewModal.classList.contains("show")) {
+ closePreview();
+ }
+ });
+ }
+
+ return {
+ bindControls,
+ closePreview,
+ openFilePreview,
+ };
+}
diff --git a/Overlord-Server/public/assets/filebrowser-transfer-panel.js b/Overlord-Server/public/assets/filebrowser-transfer-panel.js
new file mode 100644
index 0000000..d0cdc4d
--- /dev/null
+++ b/Overlord-Server/public/assets/filebrowser-transfer-panel.js
@@ -0,0 +1,80 @@
+import { formatBytes } from "./filebrowser-utils.js";
+
+export function createTransferPanel({ onCancel }) {
+ const transferPanel = document.getElementById("transfer-panel");
+ const transferList = document.getElementById("transfer-list");
+
+ function addTransferToUI(transfer) {
+ if (!transferPanel || !transferList) return;
+ const transferItem = document.createElement("div");
+ transferItem.id = `transfer-${transfer.id}`;
+ transferItem.className =
+ "transfer-item bg-slate-800/50 border border-slate-700 rounded-lg p-3";
+
+ const icon = transfer.type === "upload" ? "fa-upload" : "fa-download";
+ const color = transfer.type === "upload" ? "text-blue-400" : "text-green-400";
+
+ transferItem.innerHTML = `
+
+
+
+ ${transfer.progress}%
+ ${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}
+
+ `;
+
+ const nameEl = transferItem.querySelector(".transfer-name");
+ if (nameEl) {
+ nameEl.textContent = transfer.fileName;
+ }
+ const cancelBtn = transferItem.querySelector(".cancel-btn");
+ if (cancelBtn) {
+ cancelBtn.addEventListener("click", () => onCancel(transfer.id));
+ }
+
+ transferList.appendChild(transferItem);
+ transferPanel.classList.remove("hidden");
+ }
+
+ function updateTransferProgress(transferId, progress, current, total) {
+ const transferItem = document.getElementById(`transfer-${transferId}`);
+ if (!transferItem) return;
+
+ const progressBar = transferItem.querySelector(".progress-bar");
+ const progressText = transferItem.querySelector(".progress-text");
+ const sizeText = transferItem.querySelector(".size-text");
+
+ if (progressBar) progressBar.style.width = `${progress}%`;
+ if (progressText) progressText.textContent = `${progress}%`;
+ if (sizeText) {
+ sizeText.textContent = `${formatBytes(current)} / ${formatBytes(total)}`;
+ }
+ }
+
+ function removeTransfer(transferId) {
+ const transferItem = document.getElementById(`transfer-${transferId}`);
+ if (transferItem) {
+ transferItem.remove();
+ }
+
+ if (transferList && transferList.children.length === 0) {
+ transferPanel?.classList.add("hidden");
+ }
+ }
+
+ return {
+ addTransferToUI,
+ removeTransfer,
+ updateTransferProgress,
+ };
+}
diff --git a/Overlord-Server/public/assets/filebrowser-utils.js b/Overlord-Server/public/assets/filebrowser-utils.js
new file mode 100644
index 0000000..cf362ec
--- /dev/null
+++ b/Overlord-Server/public/assets/filebrowser-utils.js
@@ -0,0 +1,94 @@
+export function getFileExt(name = "") {
+ const idx = name.lastIndexOf(".");
+ if (idx < 0 || idx === name.length - 1) return "";
+ return name.slice(idx + 1).toLowerCase();
+}
+
+export const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
+export const PREVIEW_PDF_EXTS = new Set(["pdf"]);
+export const PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
+
+export const KNOWN_BINARY_EXTS = new Set([
+ "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v",
+ "mp3", "wav", "flac", "ogg", "aac", "wma", "m4a",
+ "exe", "msi", "com", "app", "appimage",
+ "dll", "so", "dylib", "lib",
+ "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz",
+ "db", "sqlite", "sqlite3", "mdb",
+ "ttf", "otf", "woff", "woff2", "eot",
+ "iso", "img", "vhd", "vmdk",
+]);
+
+const IMAGE_MIME_MAP = {
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ gif: "image/gif",
+ webp: "image/webp",
+ bmp: "image/bmp",
+};
+
+export function isPreviewable(name) {
+ const ext = getFileExt(name);
+ return PREVIEW_IMAGE_EXTS.has(ext) || PREVIEW_PDF_EXTS.has(ext);
+}
+
+export function getPreviewMimeType(name) {
+ const ext = getFileExt(name);
+ if (PREVIEW_PDF_EXTS.has(ext)) return "application/pdf";
+ return IMAGE_MIME_MAP[ext] || null;
+}
+
+export function shouldShowParentDirectory(path) {
+ if (!path || path === ".") {
+ return false;
+ }
+
+ return true;
+}
+
+export function getParentPath(path) {
+ const normalized = path.replace(/\\/g, "/");
+ const parts = normalized.split("/").filter((p) => p);
+
+ if (parts.length === 1 && parts[0].match(/^[A-Za-z]:$/)) {
+ return ".";
+ }
+
+ if (parts.length <= 1) {
+ return ".";
+ }
+
+ parts.pop();
+ let parentPath = parts.join("/");
+
+ if (parentPath.match(/^[A-Za-z]:?$/)) {
+ return parentPath.replace(/^([A-Za-z]):?$/, "$1:\\");
+ }
+
+ return parentPath || ".";
+}
+
+export function formatBytes(bytes) {
+ if (bytes === 0 || bytes === 0n) return "0 B";
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ if (typeof bytes === "bigint") {
+ const k = 1024n;
+ let i = 0;
+ let value = bytes;
+ while (value >= k && i < sizes.length - 1) {
+ value /= k;
+ i += 1;
+ }
+ return `${value.toString()} ${sizes[i]}`;
+ }
+ const k = 1024;
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+}
+
+export function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js
index 44df093..2923f4b 100644
--- a/Overlord-Server/public/assets/filebrowser.js
+++ b/Overlord-Server/public/assets/filebrowser.js
@@ -1,5 +1,18 @@
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
import { checkFeatureAccess } from "./feature-gate.js";
+import { createFileHexHashManager } from "./filebrowser-hex-hash.js";
+import { createFilePreviewModal } from "./filebrowser-preview-modal.js";
+import { createTransferPanel } from "./filebrowser-transfer-panel.js";
+import {
+ KNOWN_BINARY_EXTS,
+ PREVIEW_IMAGE_EXTS,
+ escapeHtml,
+ formatBytes,
+ getFileExt,
+ getParentPath,
+ isPreviewable,
+ shouldShowParentDirectory,
+} from "./filebrowser-utils.js";
const clientId = window.location.pathname.split("/")[1];
let ws = null;
@@ -11,8 +24,6 @@ let fileUploads = new Map();
let fileUploadsById = new Map();
let activeTransfers = new Map();
let currentEditingFile = null;
-let currentPreviewBlobUrl = null;
-let currentPreviewPath = null;
let lastSuccessfulResponse = 0;
let pendingToast = null;
const recentToasts = new Map();
@@ -43,8 +54,6 @@ const backBtn = document.getElementById("back-btn");
const homeBtn = document.getElementById("home-btn");
const pathInput = document.getElementById("path-input");
const pathGoBtn = document.getElementById("path-go-btn");
-const transferPanel = document.getElementById("transfer-panel");
-const transferList = document.getElementById("transfer-list");
const fileListPanel = document.getElementById("file-list-panel");
const sortFieldEl = document.getElementById("sort-field");
const sortOrderBtn = document.getElementById("sort-order-btn");
@@ -72,12 +81,6 @@ const editorStatus = document.getElementById("editor-status");
const editorSaveBtn = document.getElementById("editor-save-btn");
const editorCancelBtn = document.getElementById("editor-cancel-btn");
const editorCloseBtn = document.getElementById("editor-close-btn");
-const filePreviewModal = document.getElementById("file-preview-modal");
-const previewFileNameEl = document.getElementById("preview-file-name");
-const previewContent = document.getElementById("preview-content");
-const previewCloseBtn = document.getElementById("preview-close-btn");
-const previewDownloadBtn = document.getElementById("preview-download-btn");
-const previewStatusEl = document.getElementById("preview-status");
if (clientIdHeader) {
clientIdHeader.textContent = `${clientId} - File Browser`;
@@ -364,168 +367,19 @@ function updateBreadcrumb(path) {
});
}
-function getFileExt(name = "") {
- const idx = name.lastIndexOf(".");
- if (idx < 0 || idx === name.length - 1) return "";
- return name.slice(idx + 1).toLowerCase();
-}
-
-
-const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
-const PREVIEW_PDF_EXTS = new Set(["pdf"]);
-const PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
-
-// list of binary files from chatgpt (I can't think of anything else either)
-const KNOWN_BINARY_EXTS = new Set([
- "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v",
- "mp3", "wav", "flac", "ogg", "aac", "wma", "m4a",
- "exe", "msi", "com", "app", "appimage",
- "dll", "so", "dylib", "lib",
- "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz",
- "db", "sqlite", "sqlite3", "mdb",
- "ttf", "otf", "woff", "woff2", "eot",
- "iso", "img", "vhd", "vmdk",
-]);
-
-const IMAGE_MIME_MAP = {
- jpg: "image/jpeg",
- jpeg: "image/jpeg",
- png: "image/png",
- gif: "image/gif",
- webp: "image/webp",
- bmp: "image/bmp",
-};
-
-function isPreviewable(name) {
- const ext = getFileExt(name);
- return PREVIEW_IMAGE_EXTS.has(ext) || PREVIEW_PDF_EXTS.has(ext);
-}
-
-function getPreviewMimeType(name) {
- const ext = getFileExt(name);
- if (PREVIEW_PDF_EXTS.has(ext)) return "application/pdf";
- return IMAGE_MIME_MAP[ext] || null;
-}
-
-function closePreview() {
- if (filePreviewModal) filePreviewModal.classList.remove("show");
- if (currentPreviewBlobUrl) {
- URL.revokeObjectURL(currentPreviewBlobUrl);
- currentPreviewBlobUrl = null;
- }
- if (previewContent) previewContent.innerHTML = "";
- if (previewStatusEl) previewStatusEl.textContent = "";
- currentPreviewPath = null;
-}
-
-async function openFilePreview(path, knownSize) {
- const fileName = path.split(/[\/\\]/).pop() || "";
- const mimeType = getPreviewMimeType(fileName);
- if (!mimeType) return;
-
- if (typeof knownSize === "number" && knownSize > PREVIEW_MAX_BYTES) {
- notifyToast(`File too large to preview (${formatBytes(knownSize)})`, "info", 4000);
- return;
- }
-
- currentPreviewPath = path;
- if (previewFileNameEl) previewFileNameEl.textContent = fileName;
- if (previewContent) previewContent.innerHTML = '';
- if (previewStatusEl) previewStatusEl.textContent = "Loading...";
- if (filePreviewModal) filePreviewModal.classList.add("show");
-
- try {
- const requestRes = await fetch("/api/file/download/request", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ clientId, path }),
- });
-
- if (!requestRes.ok) {
- const text = await requestRes.text();
- if (currentPreviewPath !== path) return;
- if (previewContent) previewContent.innerHTML = `${escapeHtml(text || "Failed to load preview")}
`;
- if (previewStatusEl) previewStatusEl.textContent = "";
- return;
- }
-
- const requestData = await requestRes.json();
- const downloadUrl = typeof requestData?.downloadUrl === "string"
- ? requestData.downloadUrl
- : (requestData?.downloadId
- ? `/api/file/download/${encodeURIComponent(requestData.downloadId)}`
- : "");
-
- if (!downloadUrl) {
- if (currentPreviewPath !== path) return;
- if (previewContent) previewContent.innerHTML = `Failed to load preview
`;
- if (previewStatusEl) previewStatusEl.textContent = "";
- return;
- }
-
- const res = await fetch(downloadUrl, {
- method: "GET",
- credentials: "include",
- });
-
- if (!res.ok) {
- const text = await res.text();
- if (currentPreviewPath !== path) return;
- if (previewContent) previewContent.innerHTML = `${escapeHtml(text || "Failed to load preview")}
`;
- if (previewStatusEl) previewStatusEl.textContent = "";
- return;
- }
-
- // Reject oversized files before buffering to prevent OOM.
- const contentLength = Number(res.headers.get("Content-Length") || 0);
- if (contentLength > PREVIEW_MAX_BYTES) {
- if (currentPreviewPath !== path) return;
- if (previewContent) previewContent.innerHTML = `File too large to preview (${escapeHtml(formatBytes(contentLength))})
`;
- if (previewStatusEl) previewStatusEl.textContent = "";
- return;
- }
-
- const arrayBuffer = await res.arrayBuffer();
-
- if (currentPreviewPath !== path) return;
-
- if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
- if (previewContent) previewContent.innerHTML = `File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})
`;
- if (previewStatusEl) previewStatusEl.textContent = "";
- return;
- }
-
- if (currentPreviewBlobUrl) {
- URL.revokeObjectURL(currentPreviewBlobUrl);
- currentPreviewBlobUrl = null;
- }
-
- const blob = new Blob([arrayBuffer], { type: mimeType });
- currentPreviewBlobUrl = URL.createObjectURL(blob);
-
- if (previewContent) {
- previewContent.innerHTML = "";
- if (PREVIEW_IMAGE_EXTS.has(getFileExt(fileName))) {
- const img = document.createElement("img");
- img.src = currentPreviewBlobUrl;
- img.alt = "";
- previewContent.appendChild(img);
- } else {
- const obj = document.createElement("object");
- obj.data = currentPreviewBlobUrl;
- obj.type = "application/pdf";
- previewContent.appendChild(obj);
- }
- }
-
- if (previewStatusEl) previewStatusEl.textContent = "";
- } catch (err) {
- if (currentPreviewPath !== path) return;
- if (previewContent) previewContent.innerHTML = `${escapeHtml(err.message || "Failed to load preview")}
`;
- if (previewStatusEl) previewStatusEl.textContent = "";
- }
-}
+const filePreviewModalManager = createFilePreviewModal({
+ clientId,
+ notifyToast,
+ onDownload: (path) => downloadFile(path),
+});
+let openFilePreview = filePreviewModalManager.openFilePreview;
+const transferPanelManager = createTransferPanel({
+ onCancel: (transferId) => cancelTransfer(transferId),
+});
+const {
+ addTransferToUI,
+ updateTransferProgress,
+} = transferPanelManager;
const FILE_ICON_MAP = {
// Images
@@ -1316,36 +1170,6 @@ function requestFolderAccess(path) {
notifyToast("Asking the Mac to open Privacy settings...", "info", 3500);
}
-function shouldShowParentDirectory(path) {
- if (!path || path === ".") {
- return false;
- }
-
- return true;
-}
-
-function getParentPath(path) {
- const normalized = path.replace(/\\/g, "/");
- const parts = normalized.split("/").filter((p) => p);
-
- if (parts.length === 1 && parts[0].match(/^[A-Za-z]:$/)) {
- return ".";
- }
-
- if (parts.length <= 1) {
- return ".";
- }
-
- parts.pop();
- let parentPath = parts.join("/");
-
- if (parentPath.match(/^[A-Za-z]:?$/)) {
- return parentPath.replace(/^([A-Za-z]):?$/, "$1:\\");
- }
-
- return parentPath || ".";
-}
-
function createParentRow(parentPath) {
const row = document.createElement("div");
row.className =
@@ -2359,30 +2183,6 @@ function hideContextMenu() {
contextMenu.classList.remove("show");
}
-function formatBytes(bytes) {
- if (bytes === 0 || bytes === 0n) return "0 B";
- const sizes = ["B", "KB", "MB", "GB", "TB"];
- if (typeof bytes === "bigint") {
- const k = 1024n;
- let i = 0;
- let value = bytes;
- while (value >= k && i < sizes.length - 1) {
- value /= k;
- i += 1;
- }
- return `${value.toString()} ${sizes[i]}`;
- }
- const k = 1024;
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
-}
-
-function escapeHtml(text) {
- const div = document.createElement("div");
- div.textContent = text;
- return div.innerHTML;
-}
-
refreshBtn.onclick = () => listFiles(currentPath);
uploadBtn.onclick = () => fileInput.click();
@@ -2686,6 +2486,7 @@ const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024;
const WS_UPLOAD_CHUNK_SIZE = 512 * 1024;
const WS_UPLOAD_CONCURRENCY = 4;
const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000;
+const PREFER_HTTP_UPLOAD_PULL = true;
async function uploadFileViaWsChunks(file, path, transfer) {
const total = file.size;
@@ -2787,16 +2588,41 @@ async function uploadFile(file) {
activeTransfers.set(transferId, transfer);
addTransferToUI(transfer);
- const useWs = file.size <= WS_UPLOAD_MAX_TOTAL;
-
try {
- if (useWs) {
+ if (PREFER_HTTP_UPLOAD_PULL) {
+ await uploadFileViaHttpPull(file, path, transfer);
+ } else if (file.size <= WS_UPLOAD_MAX_TOTAL) {
await uploadFileViaWsChunks(file, path, transfer);
} else {
await uploadFileViaHttpPull(file, path, transfer);
}
finishUpload(transfer);
} catch (err) {
+ const canFallbackToWs = PREFER_HTTP_UPLOAD_PULL && file.size <= WS_UPLOAD_MAX_TOTAL && !transfer.cancelled;
+ if (canFallbackToWs) {
+ console.warn("[filebrowser] http upload failed, falling back to ws chunks", err);
+ notifyToast("HTTP upload failed; retrying through WebSocket...", "warning", 3500);
+ transfer.sent = 0;
+ transfer.receivedBytes = 0;
+ transfer.receivedChunks = 0;
+ transfer.expectedChunks = 0;
+ transfer.completed = false;
+ transfer.ackedOffsets.clear();
+ transfer.pendingAcks.forEach((pending) => {
+ clearTimeout(pending.timeoutId);
+ try { pending.reject(new Error("switching upload method")); } catch {}
+ });
+ transfer.pendingAcks.clear();
+ transfer.progress = 0;
+ updateTransferProgress(transfer.id, 0, 0, transfer.total);
+ try {
+ await uploadFileViaWsChunks(file, path, transfer);
+ finishUpload(transfer);
+ return;
+ } catch (fallbackErr) {
+ err = fallbackErr;
+ }
+ }
console.error("Upload error:", err);
removeTransfer(transferId);
fileUploads.delete(path);
@@ -2961,17 +2787,20 @@ const previewThumbBox = document.getElementById("preview-thumb-box");
const previewNameEl = document.getElementById("preview-name");
const previewPathEl = document.getElementById("preview-path");
const previewMetaList = document.getElementById("preview-meta-list");
-const previewTextHeadHost = document.getElementById("preview-text-head-host");
-const previewTextHead = document.getElementById("preview-text-head");
-const previewHexBtn = document.getElementById("preview-hex-btn");
-const previewHashBtn = document.getElementById("preview-hash-btn");
-const previewHashResult = document.getElementById("preview-hash-result");
-const previewHashValue = document.getElementById("preview-hash-value");
-const previewHashKnown = document.getElementById("preview-hash-known");
let currentPreviewEntry = null;
-let lastPeekCommandId = null;
-let lastHashCommandId = null;
+const fileHexHashManager = createFileHexHashManager({
+ send,
+ notifyToast,
+ getCurrentPreviewEntry: () => currentPreviewEntry,
+});
+const {
+ handleFileHashResult,
+ handleFilePeekResult,
+ openHexViewer,
+ requestFileHash,
+ requestFilePeek,
+} = fileHexHashManager;
function setPreviewPaneVisible(visible) {
if (!previewPaneHost) return;
@@ -3000,9 +2829,8 @@ function clearPreviewPane() {
previewActiveEl?.classList.add("hidden");
if (previewThumbBox) previewThumbBox.innerHTML = "";
if (previewMetaList) previewMetaList.innerHTML = "";
- if (previewTextHead) previewTextHead.textContent = "";
- previewTextHeadHost?.classList.add("hidden");
- previewHashResult?.classList.add("hidden");
+ fileHexHashManager.resetPreviewTextHead();
+ fileHexHashManager.resetPreviewHash();
}
function renderPreviewMeta(entry) {
@@ -3054,8 +2882,7 @@ function showPreviewForEntry(entry) {
}
// Text head — only for likely-text files under 4KB peek window
- previewTextHeadHost?.classList.add("hidden");
- if (previewTextHead) previewTextHead.textContent = "";
+ fileHexHashManager.resetPreviewTextHead();
if (!entry.isDir) {
const ext = getFileExt(entry.name);
if (!KNOWN_BINARY_EXTS.has(ext) && !PREVIEW_IMAGE_EXTS.has(ext) && !PREVIEW_PDF_EXTS.has(ext)) {
@@ -3063,236 +2890,9 @@ function showPreviewForEntry(entry) {
}
}
- previewHashResult?.classList.add("hidden");
- if (previewHashValue) previewHashValue.textContent = "";
- if (previewHashKnown) {
- previewHashKnown.classList.add("hidden");
- previewHashKnown.textContent = "";
- }
+ fileHexHashManager.resetPreviewHash();
}
-// ── file_peek (hex viewer + text head) ──────────────────────────────────────
-const hexViewerModal = document.getElementById("hex-viewer-modal");
-const hexViewerFile = document.getElementById("hex-viewer-file");
-const hexViewerBody = document.getElementById("hex-viewer-body");
-const hexViewerInfo = document.getElementById("hex-viewer-info");
-const hexViewerCloseBtn = document.getElementById("hex-viewer-close-btn");
-const hexViewerCopyBtn = document.getElementById("hex-viewer-copy-btn");
-
-// commandId -> { kind: 'preview'|'hex', path, fileName }
-const peekRequests = new Map();
-let lastHexBytes = null;
-let lastHexFileName = "";
-
-function requestFilePeek(path, kind) {
- const commandId = `peek-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- const fileName = path.split(/[\/\\]/).pop() || "";
- peekRequests.set(commandId, { kind, path, fileName });
- lastPeekCommandId = commandId;
- send({
- type: "command",
- commandType: "file_peek",
- id: commandId,
- payload: { path, bytes: 4096 },
- });
-}
-
-function openHexViewer(path) {
- if (!hexViewerModal) return;
- const fileName = path.split(/[\/\\]/).pop() || "";
- if (hexViewerFile) hexViewerFile.textContent = fileName;
- if (hexViewerBody) hexViewerBody.innerHTML = 'Loading...
';
- if (hexViewerInfo) hexViewerInfo.textContent = "";
- hexViewerModal.classList.add("show");
- requestFilePeek(path, "hex");
-}
-
-function closeHexViewer() {
- hexViewerModal?.classList.remove("show");
-}
-
-function decodeTextHead(bytes) {
- // Try UTF-8 first; fall back to latin1.
- try {
- return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
- } catch {
- return new TextDecoder("latin1").decode(bytes);
- }
-}
-
-function renderHexDump(bytes, totalSize) {
- if (!hexViewerBody) return;
- const len = bytes.length;
- const rows = [];
- for (let off = 0; off < len; off += 16) {
- const chunk = bytes.slice(off, Math.min(off + 16, len));
- const hexParts = [];
- const asciiParts = [];
- for (let i = 0; i < 16; i++) {
- if (i < chunk.length) {
- hexParts.push(chunk[i].toString(16).padStart(2, "0"));
- const b = chunk[i];
- if (b >= 0x20 && b <= 0x7e) {
- asciiParts.push(`${escapeHtml(String.fromCharCode(b))}`);
- } else {
- asciiParts.push('.');
- }
- } else {
- hexParts.push(" ");
- asciiParts.push(" ");
- }
- if (i === 7) hexParts.push("");
- }
- rows.push(
- `` +
- `${off.toString(16).padStart(8, "0")}` +
- `${hexParts.join(" ")}` +
- `${asciiParts.join("")}` +
- `
`
- );
- }
- hexViewerBody.innerHTML = rows.join("");
- if (hexViewerInfo) {
- const peekStr = `${formatBytes(len)} peek`;
- const totalStr = totalSize > 0 ? ` of ${formatBytes(totalSize)}` : "";
- hexViewerInfo.textContent = peekStr + totalStr;
- }
- lastHexBytes = bytes;
-}
-
-function handleFilePeekResult(msg) {
- const req = msg.commandId ? peekRequests.get(msg.commandId) : null;
- if (!req) return;
- peekRequests.delete(msg.commandId);
-
- if (msg.error) {
- if (req.kind === "hex") {
- if (hexViewerBody) hexViewerBody.innerHTML = `${escapeHtml(msg.error)}
`;
- }
- if (req.kind === "preview" && currentPreviewEntry && currentPreviewEntry.path === req.path) {
- previewTextHeadHost?.classList.add("hidden");
- }
- return;
- }
-
- let data = msg.data;
- if (!data) data = new Uint8Array(0);
- if (!(data instanceof Uint8Array)) {
- try { data = new Uint8Array(data); } catch { data = new Uint8Array(0); }
- }
-
- if (req.kind === "hex") {
- lastHexFileName = req.fileName;
- renderHexDump(data, Number(msg.size || 0));
- return;
- }
-
- // preview kind
- if (!currentPreviewEntry || currentPreviewEntry.path !== req.path) return;
- if (!msg.isText || data.length === 0) {
- previewTextHeadHost?.classList.add("hidden");
- return;
- }
- const text = decodeTextHead(data);
- if (previewTextHead) previewTextHead.textContent = text;
- previewTextHeadHost?.classList.remove("hidden");
-}
-
-hexViewerCloseBtn?.addEventListener("click", closeHexViewer);
-hexViewerModal?.addEventListener("click", (e) => {
- if (e.target === hexViewerModal) closeHexViewer();
-});
-document.addEventListener("keydown", (e) => {
- if (e.key === "Escape" && hexViewerModal?.classList.contains("show")) {
- closeHexViewer();
- }
-});
-hexViewerCopyBtn?.addEventListener("click", async () => {
- if (!lastHexBytes) return;
- const hex = Array.from(lastHexBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
- try {
- await navigator.clipboard.writeText(hex);
- notifyToast("Hex copied to clipboard", "success", 2000);
- } catch {
- notifyToast("Clipboard copy failed", "error", 2500);
- }
-});
-
-// ── file_hash + tiny known catalog ──────────────────────────────────────────
-// SHA-256 hex digests for a few well-known files (operator tip). Lowercase.
-const KNOWN_HASHES_SHA256 = {
- // empty file
- "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)",
- // example placeholders — populate over time
-};
-
-// commandId -> { path, fileName, source: 'preview'|'context' }
-const hashRequests = new Map();
-
-function requestFileHash(path, source) {
- const commandId = `hash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- const fileName = path.split(/[\/\\]/).pop() || "";
- hashRequests.set(commandId, { path, fileName, source });
- lastHashCommandId = commandId;
- send({
- type: "command",
- commandType: "file_hash",
- id: commandId,
- payload: { path, algorithm: "sha256" },
- });
- notifyToast(`Hashing ${fileName}…`, "info", 2500);
-}
-
-function handleFileHashResult(msg) {
- const req = msg.commandId ? hashRequests.get(msg.commandId) : null;
- if (!req) return;
- hashRequests.delete(msg.commandId);
-
- if (msg.error) {
- notifyToast(`Hash failed: ${msg.error}`, "error", 5000);
- return;
- }
- const digest = (msg.digest || "").toLowerCase();
- const known = KNOWN_HASHES_SHA256[digest];
-
- // Update preview pane if it's showing this file
- if (currentPreviewEntry && currentPreviewEntry.path === req.path && previewHashValue) {
- previewHashValue.textContent = digest;
- previewHashResult?.classList.remove("hidden");
- if (previewHashKnown) {
- if (known) {
- previewHashKnown.textContent = `Known: ${known}`;
- previewHashKnown.classList.remove("hidden");
- } else {
- previewHashKnown.classList.add("hidden");
- }
- }
- }
-
- const msgSuffix = known ? ` — matches known: ${known}` : "";
- notifyToast(`SHA-256: ${digest.slice(0, 16)}…${msgSuffix}`, "success", 6000);
- navigator.clipboard?.writeText(digest).catch(() => {});
-}
-
-previewHexBtn?.addEventListener("click", () => {
- if (currentPreviewEntry && !currentPreviewEntry.isDir) {
- openHexViewer(currentPreviewEntry.path);
- }
-});
-previewHashBtn?.addEventListener("click", () => {
- if (currentPreviewEntry && !currentPreviewEntry.isDir) {
- requestFileHash(currentPreviewEntry.path, "preview");
- }
-});
-previewHashValue?.addEventListener("click", async () => {
- const v = previewHashValue.textContent || "";
- if (!v) return;
- try {
- await navigator.clipboard.writeText(v);
- notifyToast("SHA-256 copied", "success", 1500);
- } catch {}
-});
-
// ── Sidebar Quick Access ──
let detectedHomePath = "";
let detectedOS = "";
@@ -3447,75 +3047,12 @@ function highlightSidebarActive() {
updatePinnedSidebar();
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
-function addTransferToUI(transfer) {
- const transferItem = document.createElement("div");
- transferItem.id = `transfer-${transfer.id}`;
- transferItem.className =
- "transfer-item bg-slate-800/50 border border-slate-700 rounded-lg p-3";
-
- const icon = transfer.type === "upload" ? "fa-upload" : "fa-download";
- const color = transfer.type === "upload" ? "text-blue-400" : "text-green-400";
-
- transferItem.innerHTML = `
-
-
-
- ${transfer.progress}%
- ${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}
-
- `;
-
- const nameEl = transferItem.querySelector(".transfer-name");
- if (nameEl) {
- nameEl.textContent = transfer.fileName;
- }
- const cancelBtn = transferItem.querySelector(".cancel-btn");
- if (cancelBtn) {
- cancelBtn.addEventListener("click", () => cancelTransfer(transfer.id));
- }
-
- transferList.appendChild(transferItem);
- transferPanel.classList.remove("hidden");
-}
-
-function updateTransferProgress(transferId, progress, current, total) {
- const transferItem = document.getElementById(`transfer-${transferId}`);
- if (!transferItem) return;
-
- const progressBar = transferItem.querySelector(".progress-bar");
- const progressText = transferItem.querySelector(".progress-text");
- const sizeText = transferItem.querySelector(".size-text");
-
- if (progressBar) progressBar.style.width = `${progress}%`;
- if (progressText) progressText.textContent = `${progress}%`;
- if (sizeText)
- sizeText.textContent = `${formatBytes(current)} / ${formatBytes(total)}`;
-}
-
function removeTransfer(transferId) {
- const transferItem = document.getElementById(`transfer-${transferId}`);
- if (transferItem) {
- transferItem.remove();
- }
-
+ transferPanelManager.removeTransfer(transferId);
activeTransfers.delete(transferId);
-
- if (transferList.children.length === 0) {
- transferPanel.classList.add("hidden");
- }
}
-window.cancelTransfer = function (transferId) {
+function cancelTransfer(transferId) {
const transfer = activeTransfers.get(transferId);
if (transfer) {
transfer.cancelled = true;
@@ -3540,7 +3077,8 @@ window.cancelTransfer = function (transferId) {
console.log("Transfer cancelled:", transferId);
}
-};
+}
+window.cancelTransfer = cancelTransfer;
function updateSelectionUI() {
const count = selectedFiles.size;
@@ -3844,21 +3382,8 @@ editorSaveBtn.addEventListener("click", saveFileFromEditor);
editorCancelBtn.addEventListener("click", closeEditor);
editorCloseBtn.addEventListener("click", closeEditor);
-if (previewCloseBtn) previewCloseBtn.addEventListener("click", closePreview);
-if (previewDownloadBtn) previewDownloadBtn.addEventListener("click", () => {
- if (currentPreviewPath) downloadFile(currentPreviewPath);
-});
-if (filePreviewModal) {
- // Close on backdrop click.
- filePreviewModal.addEventListener("click", (e) => {
- if (e.target === filePreviewModal) closePreview();
- });
-}
-document.addEventListener("keydown", (e) => {
- if (e.key === "Escape" && filePreviewModal && filePreviewModal.classList.contains("show")) {
- closePreview();
- }
-});
+filePreviewModalManager.bindControls();
+fileHexHashManager.bindControls();
const editorRunBtn = document.getElementById("editor-run-btn");
editorRunBtn.addEventListener("click", () => {
diff --git a/Overlord-Server/src/server/file-transfer-state.ts b/Overlord-Server/src/server/file-transfer-state.ts
index 945e1fe..8d10214 100644
--- a/Overlord-Server/src/server/file-transfer-state.ts
+++ b/Overlord-Server/src/server/file-transfer-state.ts
@@ -202,6 +202,45 @@ export function streamFileAndDelete(tmpPath: string): ReadableStream
});
}
+export function streamFileRangeWithCleanup(
+ tmpPath: string,
+ start: number,
+ endInclusive: number,
+ cleanupOnComplete: () => void | Promise,
+): ReadableStream {
+ let cancelled = false;
+ return new ReadableStream({
+ async start(controller) {
+ let fh: FileHandle | null = null;
+ try {
+ fh = await fs.open(tmpPath, "r");
+ let pos = start;
+ const READ_CHUNK = 256 * 1024;
+ while (!cancelled && pos <= endInclusive) {
+ const remaining = endInclusive - pos + 1;
+ const buf = new Uint8Array(Math.min(remaining, READ_CHUNK));
+ const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
+ if (bytesRead === 0) break;
+ controller.enqueue(bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
+ pos += bytesRead;
+ }
+ if (cancelled) return;
+ await cleanupOnComplete();
+ controller.close();
+ } catch (err) {
+ try { controller.error(err); } catch {}
+ } finally {
+ if (fh) {
+ try { await fh.close(); } catch {}
+ }
+ }
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+}
+
export async function cleanupFileTransferTempFiles(dataDir: string): Promise {
const uploadsDir = path.join(dataDir, "uploads");
const downloadsDir = path.join(dataDir, "downloads");
diff --git a/Overlord-Server/src/server/routes/file-download-routes.test.ts b/Overlord-Server/src/server/routes/file-download-routes.test.ts
index 2e7290b..2c55852 100644
--- a/Overlord-Server/src/server/routes/file-download-routes.test.ts
+++ b/Overlord-Server/src/server/routes/file-download-routes.test.ts
@@ -254,6 +254,126 @@ describe("file upload route flow", () => {
await removeTempDataDir(dataDir);
}
});
+
+ test("keeps staged upload payload available for agent range retry after interrupted pull", async () => {
+ const auth = await createAdminToken();
+ const clientId = `client-${Date.now().toString(36)}`;
+ const dataDir = await createTempDataDir();
+ const deps = makeRouteDeps(dataDir);
+ const payload = new Uint8Array(1024 * 1024 + 17);
+ 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\\retry.bin",
+ fileName: "retry.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: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ Authorization: `Bearer ${auth.token}`,
+ },
+ body: payload,
+ }),
+ stageUrl,
+ mockServer,
+ deps,
+ );
+ const stageJson = await stageRes!.json() as any;
+ const pullUrl = new URL(stageJson.pullUrl);
+
+ const firstPullRes = await handleFileDownloadRoutes(
+ new Request(pullUrl, {
+ method: "GET",
+ headers: { "x-overlord-client-id": clientId },
+ }),
+ pullUrl,
+ mockServer,
+ deps,
+ );
+ expect(firstPullRes).not.toBeNull();
+ expect(firstPullRes!.status).toBe(200);
+ const reader = firstPullRes!.body!.getReader();
+ const firstRead = await reader.read();
+ expect(firstRead.done).toBe(false);
+ const firstChunk = firstRead.value!;
+ expect(firstChunk.byteLength).toBeGreaterThan(0);
+ await reader.cancel();
+
+ const retryStart = firstChunk.byteLength;
+ const retryRes = await handleFileDownloadRoutes(
+ new Request(pullUrl, {
+ method: "GET",
+ headers: {
+ "x-overlord-client-id": clientId,
+ Range: `bytes=${retryStart}-`,
+ },
+ }),
+ pullUrl,
+ mockServer,
+ deps,
+ );
+ expect(retryRes).not.toBeNull();
+ expect(retryRes!.status).toBe(206);
+ expect(retryRes!.headers.get("Content-Range")).toBe(
+ `bytes ${retryStart}-${payload.length - 1}/${payload.length}`,
+ );
+ const retried = new Uint8Array(await retryRes!.arrayBuffer());
+ expect(retried.length).toBe(payload.length - retryStart);
+ expect(retried[0]).toBe(payload[retryStart]);
+ expect(retried[retried.length - 1]).toBe(payload[payload.length - 1]);
+
+ 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);
+ deleteUser(auth.userId);
+ await removeTempDataDir(dataDir);
+ }
+ });
});
async function createOperatorToken(opts: { fileBrowserAllowed: boolean; clientScope?: "none" | "all" } = { fileBrowserAllowed: true, clientScope: "all" }) {
diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts
index 14ef91b..7e72f94 100644
--- a/Overlord-Server/src/server/routes/file-download-routes.ts
+++ b/Overlord-Server/src/server/routes/file-download-routes.ts
@@ -16,7 +16,7 @@ import {
isSafeRemotePath,
makeIncrementalPullStream,
notifyPullWaiters,
- streamFileAndDelete,
+ streamFileRangeWithCleanup,
uploadIntents,
uploadPulls,
waitForPullProgress,
@@ -557,15 +557,21 @@ export async function handleFileDownloadRoutes(
};
if (pull.deleteFile) {
- uploadPulls.delete(pullId);
- clearTimeout(pull.timeout);
+ const cleanupPull = async () => {
+ const current = uploadPulls.get(pullId);
+ if (current === pull) {
+ uploadPulls.delete(pullId);
+ clearTimeout(pull.timeout);
+ }
+ await fs.unlink(pull.tmpPath).catch(() => {});
+ };
if (pull.state && !rangeHeader) {
const expected = pull.expectedTotal ?? pull.size;
const stream = makeIncrementalPullStream(pull);
const cleanupOnDone = stream.pipeThrough(new TransformStream({
flush: async () => {
- await fs.unlink(pull.tmpPath).catch(() => {});
+ await cleanupPull();
},
}));
return new Response(cleanupOnDone, {
@@ -578,13 +584,53 @@ export async function handleFileDownloadRoutes(
await waitForPullProgress(pull.state);
}
if (pull.state.error) {
- await fs.unlink(pull.tmpPath).catch(() => {});
+ await cleanupPull();
return new Response("Upload streaming failed", { status: 500 });
}
}
- return new Response(streamFileAndDelete(pull.tmpPath), {
- headers: { ...baseHeaders, "Content-Length": String(pull.size) },
- });
+
+ if (rangeHeader) {
+ const match = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader.trim());
+ if (!match) {
+ return new Response("Range Not Satisfiable", {
+ status: 416,
+ headers: { ...baseHeaders, "Content-Range": `bytes */${pull.size}` },
+ });
+ }
+ const start = Number(match[1]);
+ const end = match[2] === "" ? pull.size - 1 : Number(match[2]);
+ if (
+ !Number.isFinite(start) ||
+ !Number.isFinite(end) ||
+ start < 0 ||
+ end >= pull.size ||
+ start > end
+ ) {
+ return new Response("Range Not Satisfiable", {
+ status: 416,
+ headers: { ...baseHeaders, "Content-Range": `bytes */${pull.size}` },
+ });
+ }
+ const length = end - start + 1;
+ return new Response(
+ streamFileRangeWithCleanup(pull.tmpPath, start, end, cleanupPull),
+ {
+ status: 206,
+ headers: {
+ ...baseHeaders,
+ "Content-Range": `bytes ${start}-${end}/${pull.size}`,
+ "Content-Length": String(length),
+ },
+ },
+ );
+ }
+
+ return new Response(
+ streamFileRangeWithCleanup(pull.tmpPath, 0, Math.max(0, pull.size - 1), cleanupPull),
+ {
+ headers: { ...baseHeaders, "Content-Length": String(pull.size) },
+ },
+ );
}
if (rangeHeader) {