modularize build and file browser and add more precautions for downloading from file transfer.

This commit is contained in:
KingKDot
2026-05-29 22:12:02 -05:00
parent 3102a00e8c
commit 329d1e2981
12 changed files with 1559 additions and 1103 deletions
@@ -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")
@@ -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,
};
}
@@ -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,
};
}
+39 -543
View File
@@ -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");
@@ -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 = '<div class="text-slate-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Loading...</div>';
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(`<span>${escapeHtml(String.fromCharCode(b))}</span>`);
} else {
asciiParts.push('<span class="np">.</span>');
}
} else {
hexParts.push(" ");
asciiParts.push(" ");
}
if (i === 7) hexParts.push("");
}
rows.push(
`<div class="hex-row">` +
`<span class="hex-off">${off.toString(16).padStart(8, "0")}</span>` +
`<span class="hex-bytes">${hexParts.join(" ")}</span>` +
`<span class="hex-ascii">${asciiParts.join("")}</span>` +
`</div>`,
);
}
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 = `<div class="text-red-400 p-3"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(msg.error)}</div>`;
}
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,
};
}
@@ -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 = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(text || "Failed to load preview")}</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>Failed to load preview</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(text || "Failed to load preview")}</div>`;
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 = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(contentLength))})</div>`;
if (previewStatusEl) previewStatusEl.textContent = "";
return;
}
const arrayBuffer = await res.arrayBuffer();
if (currentPreviewPath !== path) return;
if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(err.message || "Failed to load preview")}</div>`;
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,
};
}
@@ -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 = `
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<i class="fa-solid ${icon} ${color}"></i>
<span class="text-sm truncate transfer-name"></span>
</div>
<button class="cancel-btn text-red-400 hover:text-red-300 px-2" type="button">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="progress-bar-container w-full bg-slate-700 rounded-full h-2 mb-1">
<div class="progress-bar bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: ${transfer.progress}%"></div>
</div>
<div class="flex justify-between text-xs text-slate-400">
<span class="progress-text">${transfer.progress}%</span>
<span class="size-text">${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}</span>
</div>
`;
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,
};
}
@@ -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;
}
+77 -552
View File
@@ -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 = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(text || "Failed to load preview")}</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>Failed to load preview</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(text || "Failed to load preview")}</div>`;
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 = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(contentLength))})</div>`;
if (previewStatusEl) previewStatusEl.textContent = "";
return;
}
const arrayBuffer = await res.arrayBuffer();
if (currentPreviewPath !== path) return;
if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(err.message || "Failed to load preview")}</div>`;
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 = '<div class="text-slate-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Loading...</div>';
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(`<span>${escapeHtml(String.fromCharCode(b))}</span>`);
} else {
asciiParts.push('<span class="np">.</span>');
}
} else {
hexParts.push(" ");
asciiParts.push(" ");
}
if (i === 7) hexParts.push("");
}
rows.push(
`<div class="hex-row">` +
`<span class="hex-off">${off.toString(16).padStart(8, "0")}</span>` +
`<span class="hex-bytes">${hexParts.join(" ")}</span>` +
`<span class="hex-ascii">${asciiParts.join("")}</span>` +
`</div>`
);
}
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 = `<div class="text-red-400 p-3"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(msg.error)}</div>`;
}
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 = `
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<i class="fa-solid ${icon} ${color}"></i>
<span class="text-sm truncate transfer-name"></span>
</div>
<button class="cancel-btn text-red-400 hover:text-red-300 px-2" type="button">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="progress-bar-container w-full bg-slate-700 rounded-full h-2 mb-1">
<div class="progress-bar bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: ${transfer.progress}%"></div>
</div>
<div class="flex justify-between text-xs text-slate-400">
<span class="progress-text">${transfer.progress}%</span>
<span class="size-text">${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}</span>
</div>
`;
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", () => {
@@ -202,6 +202,45 @@ export function streamFileAndDelete(tmpPath: string): ReadableStream<Uint8Array>
});
}
export function streamFileRangeWithCleanup(
tmpPath: string,
start: number,
endInclusive: number,
cleanupOnComplete: () => void | Promise<void>,
): ReadableStream<Uint8Array> {
let cancelled = false;
return new ReadableStream<Uint8Array>({
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<void> {
const uploadsDir = path.join(dataDir, "uploads");
const downloadsDir = path.join(dataDir, "downloads");
@@ -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" }) {
@@ -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) {