modularize build and file browser and add more precautions for downloading from file transfer.
This commit is contained in:
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"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) {
|
func TestHandleFileUploadHTTP_SizeMismatch(t *testing.T) {
|
||||||
data := []byte("small")
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 form = document.getElementById("build-form");
|
||||||
const buildBtn = document.getElementById("build-btn");
|
const buildBtn = document.getElementById("build-btn");
|
||||||
const buildStatus = document.getElementById("build-status");
|
const buildStatus = document.getElementById("build-status");
|
||||||
@@ -39,6 +46,7 @@ const platformInputs = document.querySelectorAll('input[name="platform"]');
|
|||||||
|
|
||||||
let currentServerVersion = null;
|
let currentServerVersion = null;
|
||||||
let currentUserRole = null;
|
let currentUserRole = null;
|
||||||
|
let showAllBuilds = false;
|
||||||
|
|
||||||
async function loadServerVersion() {
|
async function loadServerVersion() {
|
||||||
try {
|
try {
|
||||||
@@ -115,9 +123,6 @@ function updateWindowsSectionVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BUILD_SETTINGS_KEY = "overlord_build_settings";
|
const BUILD_SETTINGS_KEY = "overlord_build_settings";
|
||||||
const PROFILE_EXPORT_MAGIC = "overlord-build-profile";
|
|
||||||
|
|
||||||
let buildProfiles = [];
|
|
||||||
|
|
||||||
function collectFormSettings() {
|
function collectFormSettings() {
|
||||||
return {
|
return {
|
||||||
@@ -285,130 +290,35 @@ function restoreFormSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeProfileName(name) {
|
const buildProfileManager = createBuildProfileManager({
|
||||||
if (typeof name !== "string") return "";
|
elements: {
|
||||||
const trimmed = name.trim();
|
profileSelect,
|
||||||
if (!trimmed || trimmed.length > 64) return "";
|
profileNameInput,
|
||||||
if (!/^[A-Za-z0-9 _.-]+$/.test(trimmed)) return "";
|
profileSaveBtn,
|
||||||
return trimmed;
|
profileLoadBtn,
|
||||||
}
|
profileDeleteBtn,
|
||||||
|
profileExportBtn,
|
||||||
function renderProfileOptions(selectedName = "") {
|
profileImportBtn,
|
||||||
if (!profileSelect) return;
|
profileImportFile,
|
||||||
profileSelect.innerHTML = "";
|
},
|
||||||
|
collectFormSettings,
|
||||||
const placeholder = document.createElement("option");
|
applyFormSettings,
|
||||||
placeholder.value = "";
|
saveFormSettings,
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
const { loadBuildProfiles } = buildProfileManager;
|
||||||
|
|
||||||
function getSelectedProfile() {
|
const buildHistoryManager = createBuildHistoryManager({
|
||||||
if (!profileSelect || !profileSelect.value) return null;
|
buildResults,
|
||||||
return buildProfiles.find((profile) => profile.name === profileSelect.value) || null;
|
buildFilesDiv,
|
||||||
}
|
getCurrentServerVersion: () => currentServerVersion,
|
||||||
|
getCurrentUserRole: () => currentUserRole,
|
||||||
async function loadBuildProfiles(selectedName = "") {
|
getShowAllBuilds: () => showAllBuilds,
|
||||||
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 }),
|
|
||||||
});
|
});
|
||||||
|
const {
|
||||||
if (!res.ok) {
|
displayBuild,
|
||||||
const data = await res.json().catch(() => ({}));
|
loadSavedBuilds,
|
||||||
throw new Error(data.error || "Failed to save profile");
|
saveBuildToStorage,
|
||||||
}
|
} = buildHistoryManager;
|
||||||
|
|
||||||
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 CRYPTABLE_DISABLE_TARGETS = [
|
const CRYPTABLE_DISABLE_TARGETS = [
|
||||||
'input[name="enable-persistence"]',
|
'input[name="enable-persistence"]',
|
||||||
@@ -1419,94 +1329,7 @@ if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profileSelect) {
|
buildProfileManager.bindProfileControls();
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form?.addEventListener("submit", async (e) => {
|
form?.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2078,8 +1901,8 @@ function showBuildFiles(files, buildId, expiresAt) {
|
|||||||
buildInfoDiv.appendChild(infoRow);
|
buildInfoDiv.appendChild(infoRow);
|
||||||
buildFilesDiv.appendChild(buildInfoDiv);
|
buildFilesDiv.appendChild(buildInfoDiv);
|
||||||
|
|
||||||
updateExpirationTimer();
|
updateExpirationTimer(timer, expiresAt);
|
||||||
setInterval(updateExpirationTimer, 60000);
|
setInterval(() => updateExpirationTimer(timer, expiresAt), 60000);
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const fileDiv = document.createElement("div");
|
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 buildUpdateAllBtn = document.getElementById("build-update-all-btn");
|
||||||
const updateAllModal = document.getElementById("update-all-modal");
|
const updateAllModal = document.getElementById("update-all-modal");
|
||||||
const updateAllModalBody = document.getElementById("update-all-modal-body");
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
import { checkFeatureAccess } from "./feature-gate.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];
|
const clientId = window.location.pathname.split("/")[1];
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -11,8 +24,6 @@ let fileUploads = new Map();
|
|||||||
let fileUploadsById = new Map();
|
let fileUploadsById = new Map();
|
||||||
let activeTransfers = new Map();
|
let activeTransfers = new Map();
|
||||||
let currentEditingFile = null;
|
let currentEditingFile = null;
|
||||||
let currentPreviewBlobUrl = null;
|
|
||||||
let currentPreviewPath = null;
|
|
||||||
let lastSuccessfulResponse = 0;
|
let lastSuccessfulResponse = 0;
|
||||||
let pendingToast = null;
|
let pendingToast = null;
|
||||||
const recentToasts = new Map();
|
const recentToasts = new Map();
|
||||||
@@ -43,8 +54,6 @@ const backBtn = document.getElementById("back-btn");
|
|||||||
const homeBtn = document.getElementById("home-btn");
|
const homeBtn = document.getElementById("home-btn");
|
||||||
const pathInput = document.getElementById("path-input");
|
const pathInput = document.getElementById("path-input");
|
||||||
const pathGoBtn = document.getElementById("path-go-btn");
|
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 fileListPanel = document.getElementById("file-list-panel");
|
||||||
const sortFieldEl = document.getElementById("sort-field");
|
const sortFieldEl = document.getElementById("sort-field");
|
||||||
const sortOrderBtn = document.getElementById("sort-order-btn");
|
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 editorSaveBtn = document.getElementById("editor-save-btn");
|
||||||
const editorCancelBtn = document.getElementById("editor-cancel-btn");
|
const editorCancelBtn = document.getElementById("editor-cancel-btn");
|
||||||
const editorCloseBtn = document.getElementById("editor-close-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) {
|
if (clientIdHeader) {
|
||||||
clientIdHeader.textContent = `${clientId} - File Browser`;
|
clientIdHeader.textContent = `${clientId} - File Browser`;
|
||||||
@@ -364,168 +367,19 @@ function updateBreadcrumb(path) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileExt(name = "") {
|
const filePreviewModalManager = createFilePreviewModal({
|
||||||
const idx = name.lastIndexOf(".");
|
clientId,
|
||||||
if (idx < 0 || idx === name.length - 1) return "";
|
notifyToast,
|
||||||
return name.slice(idx + 1).toLowerCase();
|
onDownload: (path) => downloadFile(path),
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
});
|
});
|
||||||
|
let openFilePreview = filePreviewModalManager.openFilePreview;
|
||||||
if (!requestRes.ok) {
|
const transferPanelManager = createTransferPanel({
|
||||||
const text = await requestRes.text();
|
onCancel: (transferId) => cancelTransfer(transferId),
|
||||||
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",
|
|
||||||
});
|
});
|
||||||
|
const {
|
||||||
if (!res.ok) {
|
addTransferToUI,
|
||||||
const text = await res.text();
|
updateTransferProgress,
|
||||||
if (currentPreviewPath !== path) return;
|
} = transferPanelManager;
|
||||||
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 FILE_ICON_MAP = {
|
const FILE_ICON_MAP = {
|
||||||
// Images
|
// Images
|
||||||
@@ -1316,36 +1170,6 @@ function requestFolderAccess(path) {
|
|||||||
notifyToast("Asking the Mac to open Privacy settings...", "info", 3500);
|
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) {
|
function createParentRow(parentPath) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className =
|
row.className =
|
||||||
@@ -2359,30 +2183,6 @@ function hideContextMenu() {
|
|||||||
contextMenu.classList.remove("show");
|
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);
|
refreshBtn.onclick = () => listFiles(currentPath);
|
||||||
|
|
||||||
uploadBtn.onclick = () => fileInput.click();
|
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_CHUNK_SIZE = 512 * 1024;
|
||||||
const WS_UPLOAD_CONCURRENCY = 4;
|
const WS_UPLOAD_CONCURRENCY = 4;
|
||||||
const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000;
|
const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000;
|
||||||
|
const PREFER_HTTP_UPLOAD_PULL = true;
|
||||||
|
|
||||||
async function uploadFileViaWsChunks(file, path, transfer) {
|
async function uploadFileViaWsChunks(file, path, transfer) {
|
||||||
const total = file.size;
|
const total = file.size;
|
||||||
@@ -2787,16 +2588,41 @@ async function uploadFile(file) {
|
|||||||
activeTransfers.set(transferId, transfer);
|
activeTransfers.set(transferId, transfer);
|
||||||
addTransferToUI(transfer);
|
addTransferToUI(transfer);
|
||||||
|
|
||||||
const useWs = file.size <= WS_UPLOAD_MAX_TOTAL;
|
|
||||||
|
|
||||||
try {
|
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);
|
await uploadFileViaWsChunks(file, path, transfer);
|
||||||
} else {
|
} else {
|
||||||
await uploadFileViaHttpPull(file, path, transfer);
|
await uploadFileViaHttpPull(file, path, transfer);
|
||||||
}
|
}
|
||||||
finishUpload(transfer);
|
finishUpload(transfer);
|
||||||
} catch (err) {
|
} 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);
|
console.error("Upload error:", err);
|
||||||
removeTransfer(transferId);
|
removeTransfer(transferId);
|
||||||
fileUploads.delete(path);
|
fileUploads.delete(path);
|
||||||
@@ -2961,17 +2787,20 @@ const previewThumbBox = document.getElementById("preview-thumb-box");
|
|||||||
const previewNameEl = document.getElementById("preview-name");
|
const previewNameEl = document.getElementById("preview-name");
|
||||||
const previewPathEl = document.getElementById("preview-path");
|
const previewPathEl = document.getElementById("preview-path");
|
||||||
const previewMetaList = document.getElementById("preview-meta-list");
|
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 currentPreviewEntry = null;
|
||||||
let lastPeekCommandId = null;
|
const fileHexHashManager = createFileHexHashManager({
|
||||||
let lastHashCommandId = null;
|
send,
|
||||||
|
notifyToast,
|
||||||
|
getCurrentPreviewEntry: () => currentPreviewEntry,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
handleFileHashResult,
|
||||||
|
handleFilePeekResult,
|
||||||
|
openHexViewer,
|
||||||
|
requestFileHash,
|
||||||
|
requestFilePeek,
|
||||||
|
} = fileHexHashManager;
|
||||||
|
|
||||||
function setPreviewPaneVisible(visible) {
|
function setPreviewPaneVisible(visible) {
|
||||||
if (!previewPaneHost) return;
|
if (!previewPaneHost) return;
|
||||||
@@ -3000,9 +2829,8 @@ function clearPreviewPane() {
|
|||||||
previewActiveEl?.classList.add("hidden");
|
previewActiveEl?.classList.add("hidden");
|
||||||
if (previewThumbBox) previewThumbBox.innerHTML = "";
|
if (previewThumbBox) previewThumbBox.innerHTML = "";
|
||||||
if (previewMetaList) previewMetaList.innerHTML = "";
|
if (previewMetaList) previewMetaList.innerHTML = "";
|
||||||
if (previewTextHead) previewTextHead.textContent = "";
|
fileHexHashManager.resetPreviewTextHead();
|
||||||
previewTextHeadHost?.classList.add("hidden");
|
fileHexHashManager.resetPreviewHash();
|
||||||
previewHashResult?.classList.add("hidden");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPreviewMeta(entry) {
|
function renderPreviewMeta(entry) {
|
||||||
@@ -3054,8 +2882,7 @@ function showPreviewForEntry(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Text head — only for likely-text files under 4KB peek window
|
// Text head — only for likely-text files under 4KB peek window
|
||||||
previewTextHeadHost?.classList.add("hidden");
|
fileHexHashManager.resetPreviewTextHead();
|
||||||
if (previewTextHead) previewTextHead.textContent = "";
|
|
||||||
if (!entry.isDir) {
|
if (!entry.isDir) {
|
||||||
const ext = getFileExt(entry.name);
|
const ext = getFileExt(entry.name);
|
||||||
if (!KNOWN_BINARY_EXTS.has(ext) && !PREVIEW_IMAGE_EXTS.has(ext) && !PREVIEW_PDF_EXTS.has(ext)) {
|
if (!KNOWN_BINARY_EXTS.has(ext) && !PREVIEW_IMAGE_EXTS.has(ext) && !PREVIEW_PDF_EXTS.has(ext)) {
|
||||||
@@ -3063,235 +2890,8 @@ function showPreviewForEntry(entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previewHashResult?.classList.add("hidden");
|
fileHexHashManager.resetPreviewHash();
|
||||||
if (previewHashValue) previewHashValue.textContent = "";
|
|
||||||
if (previewHashKnown) {
|
|
||||||
previewHashKnown.classList.add("hidden");
|
|
||||||
previewHashKnown.textContent = "";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 ──
|
// ── Sidebar Quick Access ──
|
||||||
let detectedHomePath = "";
|
let detectedHomePath = "";
|
||||||
@@ -3447,75 +3047,12 @@ function highlightSidebarActive() {
|
|||||||
updatePinnedSidebar();
|
updatePinnedSidebar();
|
||||||
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
|
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) {
|
function removeTransfer(transferId) {
|
||||||
const transferItem = document.getElementById(`transfer-${transferId}`);
|
transferPanelManager.removeTransfer(transferId);
|
||||||
if (transferItem) {
|
|
||||||
transferItem.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTransfers.delete(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);
|
const transfer = activeTransfers.get(transferId);
|
||||||
if (transfer) {
|
if (transfer) {
|
||||||
transfer.cancelled = true;
|
transfer.cancelled = true;
|
||||||
@@ -3540,7 +3077,8 @@ window.cancelTransfer = function (transferId) {
|
|||||||
|
|
||||||
console.log("Transfer cancelled:", transferId);
|
console.log("Transfer cancelled:", transferId);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
window.cancelTransfer = cancelTransfer;
|
||||||
|
|
||||||
function updateSelectionUI() {
|
function updateSelectionUI() {
|
||||||
const count = selectedFiles.size;
|
const count = selectedFiles.size;
|
||||||
@@ -3844,21 +3382,8 @@ editorSaveBtn.addEventListener("click", saveFileFromEditor);
|
|||||||
editorCancelBtn.addEventListener("click", closeEditor);
|
editorCancelBtn.addEventListener("click", closeEditor);
|
||||||
editorCloseBtn.addEventListener("click", closeEditor);
|
editorCloseBtn.addEventListener("click", closeEditor);
|
||||||
|
|
||||||
if (previewCloseBtn) previewCloseBtn.addEventListener("click", closePreview);
|
filePreviewModalManager.bindControls();
|
||||||
if (previewDownloadBtn) previewDownloadBtn.addEventListener("click", () => {
|
fileHexHashManager.bindControls();
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const editorRunBtn = document.getElementById("editor-run-btn");
|
const editorRunBtn = document.getElementById("editor-run-btn");
|
||||||
editorRunBtn.addEventListener("click", () => {
|
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> {
|
export async function cleanupFileTransferTempFiles(dataDir: string): Promise<void> {
|
||||||
const uploadsDir = path.join(dataDir, "uploads");
|
const uploadsDir = path.join(dataDir, "uploads");
|
||||||
const downloadsDir = path.join(dataDir, "downloads");
|
const downloadsDir = path.join(dataDir, "downloads");
|
||||||
|
|||||||
@@ -254,6 +254,126 @@ describe("file upload route flow", () => {
|
|||||||
await removeTempDataDir(dataDir);
|
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" }) {
|
async function createOperatorToken(opts: { fileBrowserAllowed: boolean; clientScope?: "none" | "all" } = { fileBrowserAllowed: true, clientScope: "all" }) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
isSafeRemotePath,
|
isSafeRemotePath,
|
||||||
makeIncrementalPullStream,
|
makeIncrementalPullStream,
|
||||||
notifyPullWaiters,
|
notifyPullWaiters,
|
||||||
streamFileAndDelete,
|
streamFileRangeWithCleanup,
|
||||||
uploadIntents,
|
uploadIntents,
|
||||||
uploadPulls,
|
uploadPulls,
|
||||||
waitForPullProgress,
|
waitForPullProgress,
|
||||||
@@ -557,15 +557,21 @@ export async function handleFileDownloadRoutes(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (pull.deleteFile) {
|
if (pull.deleteFile) {
|
||||||
|
const cleanupPull = async () => {
|
||||||
|
const current = uploadPulls.get(pullId);
|
||||||
|
if (current === pull) {
|
||||||
uploadPulls.delete(pullId);
|
uploadPulls.delete(pullId);
|
||||||
clearTimeout(pull.timeout);
|
clearTimeout(pull.timeout);
|
||||||
|
}
|
||||||
|
await fs.unlink(pull.tmpPath).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
if (pull.state && !rangeHeader) {
|
if (pull.state && !rangeHeader) {
|
||||||
const expected = pull.expectedTotal ?? pull.size;
|
const expected = pull.expectedTotal ?? pull.size;
|
||||||
const stream = makeIncrementalPullStream(pull);
|
const stream = makeIncrementalPullStream(pull);
|
||||||
const cleanupOnDone = stream.pipeThrough(new TransformStream({
|
const cleanupOnDone = stream.pipeThrough(new TransformStream({
|
||||||
flush: async () => {
|
flush: async () => {
|
||||||
await fs.unlink(pull.tmpPath).catch(() => {});
|
await cleanupPull();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return new Response(cleanupOnDone, {
|
return new Response(cleanupOnDone, {
|
||||||
@@ -578,14 +584,54 @@ export async function handleFileDownloadRoutes(
|
|||||||
await waitForPullProgress(pull.state);
|
await waitForPullProgress(pull.state);
|
||||||
}
|
}
|
||||||
if (pull.state.error) {
|
if (pull.state.error) {
|
||||||
await fs.unlink(pull.tmpPath).catch(() => {});
|
await cleanupPull();
|
||||||
return new Response("Upload streaming failed", { status: 500 });
|
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) {
|
if (rangeHeader) {
|
||||||
const match = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader.trim());
|
const match = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader.trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user