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) {