Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /** |
| 2 | * zftpd Web File Explorer — app.js |
| 3 | * |
| 4 | * CHANGES vs original: |
| 5 | * |
| 6 | * Fix #1 — UI cache after delete/rename/mkdir: |
| 7 | * All mutating API calls (delete, rename, create_file, create_dir) now call |
| 8 | * L0(P) on success to force a fresh /api/list fetch. Previously the DOM |
| 9 | * was not updated, causing stale entries to remain visible until a manual |
| 10 | * refresh or incognito window was opened. |
| 11 | * |
| 12 | * Fix #6 — Default destination path for Send To / Move: |
| 13 | * The Send-To dialog now defaults to "/" (root) instead of the current |
| 14 | * working directory P. This prevents accidental nesting when the user |
| 15 | * forgets to change the path, matching the expected UX. |
| 16 | * |
| 17 | * Fix #5 — UI instability during active transfers: |
| 18 | * Added a global g_transfer_active flag. Navigation (L0) and mutating |
| 19 | * actions are gated on this flag while an upload or background copy is |
| 20 | * running. A visible "Transfer in progress…" banner is shown. |
| 21 | */ |
| 22 | |
| 23 | var D = document, |
| 24 | $ = D.getElementById.bind(D), |
| 25 | E = encodeURIComponent, |
| 26 | P = "/", /* current directory path */ |
| 27 | L = [], /* current directory entries */ |
| 28 | g_transfer_active = 0; /* 1 while upload/copy is running */ |
| 29 | |
| 30 | /* ------------------------------------------------------------------------- |
| 31 | * CSRF |
| 32 | * ---------------------------------------------------------------------- */ |
| 33 | function T() { |
| 34 | var m = D.querySelector('meta[name="csrf-token"]'); |
| 35 | return m ? m.content : ""; |
| 36 | } |
| 37 | |
| 38 | /* ------------------------------------------------------------------------- |
| 39 | * PATH HELPERS |
| 40 | * ---------------------------------------------------------------------- */ |
| 41 | function N(p) { |
| 42 | return !p || p[0] !== "/" ? "/" : |
| 43 | p.length > 1 && p[p.length - 1] === "/" ? p.slice(0, -1) : p; |
| 44 | } |
| 45 | function parentOf(p) { |
| 46 | p = N(p); |
| 47 | if (p === "/") return null; |
| 48 | var i = p.lastIndexOf("/"); |
| 49 | return i <= 0 ? "/" : p.slice(0, i); |
| 50 | } |
| 51 | function joinPath(n) { |
| 52 | return P === "/" ? "/" + n : P + "/" + n; |
| 53 | } |
| 54 | |
| 55 | /* ------------------------------------------------------------------------- |
| 56 | * STATUS PILL |
| 57 | * ---------------------------------------------------------------------- */ |
| 58 | function S(t, ok) { |
| 59 | var x = $("status"); |
| 60 | x.textContent = t; |
| 61 | x.className = "status-pill " + (ok ? "status-ok" : "status-bad"); |
| 62 | } |
| 63 | |
| 64 | /* ------------------------------------------------------------------------- |
| 65 | * TRANSFER BANNER (Fix #5) |
| 66 | * Shows/hides a banner and blocks navigation while a transfer is active. |
| 67 | * ---------------------------------------------------------------------- */ |
| 68 | function setTransferActive(active) { |
| 69 | g_transfer_active = active ? 1 : 0; |
| 70 | var banner = $("transfer-banner"); |
| 71 | if (banner) { |
| 72 | banner.style.display = active ? "block" : "none"; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /* ------------------------------------------------------------------------- |
| 77 | * DROP OVERLAY |
| 78 | * ---------------------------------------------------------------------- */ |
| 79 | function O(t, p) { |
| 80 | var x = $("drop"); |
| 81 | if (t) $("drop-sub").textContent = t; |
| 82 | if (typeof p === "number") $("drop-bar").style.width = p + "%"; |
| 83 | x.classList.add("show"); |
| 84 | } |
| 85 | function O0() { |
| 86 | $("drop").classList.remove("show"); |
| 87 | $("drop-sub").textContent = "Release"; |
| 88 | $("drop-bar").style.width = "0%"; |
| 89 | } |
| 90 | |
| 91 | /* ------------------------------------------------------------------------- |
| 92 | * BREADCRUMB |
| 93 | * ---------------------------------------------------------------------- */ |
| 94 | function B() { |
| 95 | var b = $("breadcrumb"); |
| 96 | b.innerHTML = ""; |
| 97 | var r = D.createElement("span"); |
| 98 | r.className = "crumb"; |
| 99 | r.textContent = "Root"; |
| 100 | r.setAttribute("data-path", "/"); |
| 101 | r.onclick = function () { L0("/"); }; |
| 102 | b.appendChild(r); |
| 103 | var parts = N(P).split("/"), a = ""; |
| 104 | for (var i = 0; i < parts.length; i++) { |
| 105 | var p = parts[i]; |
| 106 | if (!p) continue; |
| 107 | a += "/" + p; |
| 108 | var it = D.createElement("span"); |
| 109 | it.className = "crumb"; |
| 110 | it.textContent = p; |
| 111 | it.setAttribute("data-path", a); |
| 112 | it.onclick = (function (path) { |
| 113 | return function () { L0(path); }; |
| 114 | })(a); |
| 115 | b.appendChild(it); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /* ------------------------------------------------------------------------- |
| 120 | * RENDER FILE LIST |
| 121 | * ---------------------------------------------------------------------- */ |
| 122 | function R(q) { |
| 123 | var fl = $("file-list"); |
| 124 | fl.innerHTML = ""; |
| 125 | q = (q || "").trim().toLowerCase(); |
| 126 | var a = L || [], k = 0; |
| 127 | if (!a.length) { fl.innerHTML = '<div class="empty">Empty</div>'; return; } |
| 128 | for (var i = 0; i < a.length; i++) { |
| 129 | var x = a[i]; |
| 130 | if (q && x.name.toLowerCase().indexOf(q) < 0) continue; |
| 131 | k++; |
| 132 | var dir = x.type === "directory", |
| 133 | path = joinPath(x.name), |
| 134 | ic = dir ? "📁" : "📄"; |
| 135 | var c = D.createElement("div"); |
| 136 | c.className = "card"; |
| 137 | c.setAttribute("data-path", path); |
| 138 | c.setAttribute("data-dir", dir ? "1" : "0"); |
| 139 | c.onclick = function () { |
| 140 | var p = this.getAttribute("data-path"); |
| 141 | if (this.getAttribute("data-dir") === "1") L0(p); |
| 142 | else location.href = "/api/download?path=" + E(p); |
| 143 | }; |
| 144 | c.innerHTML = |
| 145 | '<div class="icon">' + ic + '</div>' + |
| 146 | '<div class="meta"><div class="name">' + x.name + '</div></div>'; |
| 147 | fl.appendChild(c); |
| 148 | } |
| 149 | if (!k) fl.innerHTML = '<div class="empty">Empty</div>'; |
| 150 | } |
| 151 | |
| 152 | /* ------------------------------------------------------------------------- |
| 153 | * DIRECTORY NAVIGATION (Fix #5: gated on g_transfer_active) |
| 154 | * ---------------------------------------------------------------------- */ |
| 155 | function L0(path) { |
| 156 | /* Block navigation while a transfer is in progress (Fix #5) */ |
| 157 | if (g_transfer_active) { |
| 158 | S("Transfer in progress…", 0); |
| 159 | return; |
| 160 | } |
| 161 | P = N(path); |
| 162 | $("current-path").textContent = P; |
| 163 | $("file-list").innerHTML = '<div class="loading">Loading…</div>'; |
| 164 | fetch("/api/list?path=" + E(P)) |
| 165 | .then(function (r) { |
| 166 | if (!r.ok) throw new Error("HTTP " + r.status); |
| 167 | return r.json(); |
| 168 | }) |
| 169 | .then(function (d) { |
| 170 | L = d && d.entries ? d.entries : []; |
| 171 | B(); |
| 172 | R($("search").value); |
| 173 | S("Connected", 1); |
| 174 | }) |
| 175 | .catch(function () { |
| 176 | $("file-list").innerHTML = '<div class="error">Error</div>'; |
| 177 | S("Error", 0); |
| 178 | }); |
| 179 | } |
| 180 | |
| 181 | /* ------------------------------------------------------------------------- |
| 182 | * UPLOAD (Fix #5: sets/clears transfer flag) |
| 183 | * ---------------------------------------------------------------------- */ |
| 184 | function U0(files) { |
| 185 | if (!files || !files.length) return; |
| 186 | if (g_transfer_active) { S("Transfer in progress…", 0); return; } |
| 187 | setTransferActive(1); |
| 188 | var i = 0; |
| 189 | function next() { |
| 190 | if (i >= files.length) { |
| 191 | O0(); |
| 192 | setTransferActive(0); |
| 193 | /* Fix #1: refresh directory listing after all uploads complete */ |
| 194 | L0(P); |
| 195 | return; |
| 196 | } |
| 197 | var f = files[i++]; |
| 198 | S("Upload", 0); |
| 199 | O("Uploading", 0); |
| 200 | var x = new XMLHttpRequest(); |
| 201 | x.open("POST", "/api/upload?path=" + E(P) + "&name=" + E(f.name), 1); |
| 202 | var t = T(); |
| 203 | if (t) x.setRequestHeader("X-CSRF-Token", t); |
| 204 | x.upload.onprogress = function (e) { |
| 205 | if (e.lengthComputable) O("Uploading", Math.floor(e.loaded / e.total * 100)); |
| 206 | }; |
| 207 | x.onload = function () { |
| 208 | if (x.status >= 200 && x.status < 300) { |
| 209 | next(); |
| 210 | } else { |
| 211 | S("Error", 0); |
| 212 | setTransferActive(0); |
| 213 | setTimeout(O0, 800); |
| 214 | } |
| 215 | }; |
| 216 | x.onerror = function () { |
| 217 | S("Error", 0); |
| 218 | setTransferActive(0); |
| 219 | setTimeout(O0, 800); |
| 220 | }; |
| 221 | x.send(f); |
| 222 | } |
| 223 | next(); |
| 224 | } |
| 225 | |
| 226 | /* ------------------------------------------------------------------------- |
| 227 | * CREATE FILE (Fix #1: refresh after create) |
| 228 | * ---------------------------------------------------------------------- */ |
| 229 | function C0() { |
| 230 | var name = prompt("File name"); |
| 231 | if (!name) return; |
| 232 | S("Creating…", 0); |
| 233 | fetch("/api/create_file?path=" + E(P) + "&name=" + E(name), { |
| 234 | method: "POST", |
| 235 | headers: { "Content-Type": "text/plain", "X-CSRF-Token": T() }, |
| 236 | body: "" |
| 237 | }) |
| 238 | .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) |
| 239 | .then(function () { |
| 240 | S("Created", 1); |
| 241 | L0(P); /* Fix #1 */ |
| 242 | }) |
| 243 | .catch(function (e) { S("Error", 0); alert("Failed: " + e.message); }); |
| 244 | } |
| 245 | |
| 246 | /* ------------------------------------------------------------------------- |
| 247 | * CREATE DIRECTORY (Fix #1: refresh after create) |
| 248 | * ---------------------------------------------------------------------- */ |
| 249 | function CD() { |
| 250 | var name = prompt("Folder name"); |
| 251 | if (!name) return; |
| 252 | S("Creating…", 0); |
| 253 | fetch("/api/mkdir?path=" + E(P) + "&name=" + E(name), { |
| 254 | method: "POST", |
| 255 | headers: { "X-CSRF-Token": T() } |
| 256 | }) |
| 257 | .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) |
| 258 | .then(function () { |
| 259 | S("Created", 1); |
| 260 | L0(P); /* Fix #1 */ |
| 261 | }) |
| 262 | .catch(function (e) { S("Error", 0); alert("Failed: " + e.message); }); |
| 263 | } |
| 264 | |
| 265 | /* ------------------------------------------------------------------------- |
| 266 | * DELETE (Fix #1: refresh after delete) |
| 267 | * ---------------------------------------------------------------------- */ |
| 268 | function DEL(path, recursive) { |
| 269 | if (!confirm("Delete: " + path + (recursive ? " (recursive)" : "") + "?")) return; |
| 270 | var url = "/api/delete?path=" + E(path) + (recursive ? "&recursive=1" : ""); |
| 271 | fetch(url, { method: "POST", headers: { "X-CSRF-Token": T() } }) |
| 272 | .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) |
| 273 | .then(function () { |
| 274 | S("Deleted", 1); |
| 275 | L0(P); /* Fix #1: force refresh — previously the DOM was not updated */ |
| 276 | }) |
| 277 | .catch(function (e) { S("Error", 0); alert("Failed: " + e.message); }); |
| 278 | } |
| 279 | |
| 280 | /* ------------------------------------------------------------------------- |
| 281 | * RENAME (Fix #1: refresh after rename) |
| 282 | * ---------------------------------------------------------------------- */ |
| 283 | function REN(path) { |
| 284 | var newName = prompt("New name", path.split("/").pop()); |
| 285 | if (!newName) return; |
| 286 | fetch("/api/rename?path=" + E(path) + "&name=" + E(newName), { |
| 287 | method: "POST", |
| 288 | headers: { "X-CSRF-Token": T() } |
| 289 | }) |
| 290 | .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) |
| 291 | .then(function () { |
| 292 | S("Renamed", 1); |
| 293 | L0(P); /* Fix #1 */ |
| 294 | }) |
| 295 | .catch(function (e) { S("Error", 0); alert("Failed: " + e.message); }); |
| 296 | } |
| 297 | |
| 298 | /* ------------------------------------------------------------------------- |
| 299 | * SEND TO / COPY (Fix #6: default destination = "/") |
| 300 | * ---------------------------------------------------------------------- */ |
| 301 | function SENDTO(srcPath) { |
| 302 | if (g_transfer_active) { S("Transfer in progress…", 0); return; } |
| 303 | |
| 304 | /* |
| 305 | * Fix #6: Default destination is "/" (root), NOT the current directory P. |
| 306 | * |
| 307 | * WHY: When the user clicks "Send To" from inside a deep directory, using P |
| 308 | * as the default caused files to be silently copied into that same directory |
| 309 | * or a subdirectory by accident. Root is the safe, unambiguous default — |
| 310 | * the user must explicitly choose a different destination if desired. |
| 311 | */ |
| 312 | var dst = prompt("Destination path (default: /)", "/"); |
| 313 | if (dst === null) return; /* user cancelled */ |
| 314 | if (!dst || dst === "") dst = "/"; /* empty → root */ |
| 315 | dst = N(dst); |
| 316 | |
| 317 | setTransferActive(1); |
| 318 | S("Copying…", 0); |
| 319 | |
| 320 | fetch("/api/copy?src=" + E(srcPath) + "&dst=" + E(dst), { |
| 321 | method: "POST", |
| 322 | headers: { "X-CSRF-Token": T() } |
| 323 | }) |
| 324 | .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) |
| 325 | .then(function () { |
| 326 | setTransferActive(0); |
| 327 | S("Copied", 1); |
| 328 | L0(P); |
| 329 | }) |
| 330 | .catch(function (e) { |
| 331 | setTransferActive(0); |
| 332 | S("Error", 0); |
| 333 | alert("Copy failed: " + e.message); |
| 334 | }); |
| 335 | } |
| 336 | |
| 337 | /* ------------------------------------------------------------------------- |
| 338 | * NETWORK STACK RESET (Fix #4) |
| 339 | * |
| 340 | * Calls POST /api/network/reset which flushes the FTP server's TCP send/recv |
| 341 | * buffers and drops idle connections. Equivalent to the "disable/enable |
| 342 | * network" workaround but without requiring a PS5 reboot. |
| 343 | * Falls back to showing a PAL notification if the reset cannot be applied. |
| 344 | * ---------------------------------------------------------------------- */ |
| 345 | function NETRESET() { |
| 346 | if (!confirm("Reset network stack? Active transfers will be interrupted.")) return; |
| 347 | S("Resetting…", 0); |
| 348 | fetch("/api/network/reset", { method: "POST", headers: { "X-CSRF-Token": T() } }) |
| 349 | .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) |
| 350 | .then(function (d) { S(d.message || "Reset OK", 1); }) |
| 351 | .catch(function (e) { S("Reset failed: " + e.message, 0); }); |
| 352 | } |
| 353 | |
| 354 | /* ------------------------------------------------------------------------- |
| 355 | * CONTEXT MENU BUILDER |
| 356 | * Attaches right-click / long-press actions to each card in the file list. |
| 357 | * Centralises all mutating actions so the refresh (Fix #1) is always applied. |
| 358 | * ---------------------------------------------------------------------- */ |
| 359 | function attachContextMenu(card) { |
| 360 | card.addEventListener("contextmenu", function (ev) { |
| 361 | ev.preventDefault(); |
| 362 | var path = card.getAttribute("data-path"); |
| 363 | var isDir = card.getAttribute("data-dir") === "1"; |
| 364 | /* Remove any existing menu */ |
| 365 | var old = D.getElementById("ctx-menu"); |
| 366 | if (old) old.parentNode.removeChild(old); |
| 367 | |
| 368 | var menu = D.createElement("div"); |
| 369 | menu.id = "ctx-menu"; |
| 370 | menu.className = "ctx-menu"; |
| 371 | menu.style.cssText = |
| 372 | "position:fixed;z-index:9999;background:#1e1e2e;border:1px solid #444;" + |
| 373 | "border-radius:6px;padding:4px 0;min-width:160px;" + |
| 374 | "left:" + ev.clientX + "px;top:" + ev.clientY + "px;"; |
| 375 | |
| 376 | function item(label, fn) { |
| 377 | var li = D.createElement("div"); |
| 378 | li.textContent = label; |
| 379 | li.style.cssText = "padding:7px 14px;cursor:pointer;font-size:13px;color:#cdd6f4;"; |
| 380 | li.onmouseenter = function () { li.style.background = "#313244"; }; |
| 381 | li.onmouseleave = function () { li.style.background = ""; }; |
| 382 | li.onclick = function () { |
| 383 | menu.parentNode && menu.parentNode.removeChild(menu); |
| 384 | fn(); |
| 385 | }; |
| 386 | menu.appendChild(li); |
| 387 | } |
| 388 | |
| 389 | if (!isDir) item("⬇ Download", function () { location.href = "/api/download?path=" + E(path); }); |
| 390 | item("✏ Rename", function () { REN(path); }); |
| 391 | item("📋 Send To…", function () { SENDTO(path); }); |
| 392 | item("🗑 Delete", function () { DEL(path, false); }); |
| 393 | if (isDir) item("🗑 Delete (recursive)", function () { DEL(path, true); }); |
| 394 | |
| 395 | D.body.appendChild(menu); |
| 396 | D.addEventListener("click", function dismiss() { |
| 397 | if (menu.parentNode) menu.parentNode.removeChild(menu); |
| 398 | D.removeEventListener("click", dismiss); |
| 399 | }, { once: true }); |
| 400 | }); |
| 401 | } |
| 402 | |
| 403 | /* ------------------------------------------------------------------------- |
| 404 | * BOOTSTRAP |
| 405 | * ---------------------------------------------------------------------- */ |
| 406 | D.addEventListener("DOMContentLoaded", function () { |
| 407 | |
| 408 | /* Navigation buttons */ |
| 409 | $("btn-up").onclick = function () { |
| 410 | var p = parentOf(P); |
| 411 | if (p !== null) L0(p); |
| 412 | }; |
| 413 | $("btn-refresh").onclick = function () { L0(P); }; |
| 414 | |
| 415 | /* Search */ |
| 416 | $("search").oninput = function () { R(this.value); }; |
| 417 | |
| 418 | /* Upload */ |
| 419 | $("btn-upload").onclick = function () { $("file-input").click(); }; |
| 420 | $("file-input").onchange = function (e) { U0(e.target.files); e.target.value = ""; }; |
| 421 | |
| 422 | /* Create file */ |
| 423 | if ($("btn-create")) $("btn-create").onclick = function () { C0(); }; |
| 424 | /* Create directory */ |
| 425 | if ($("btn-mkdir")) $("btn-mkdir").onclick = function () { CD(); }; |
| 426 | /* Network reset button (Fix #4) */ |
| 427 | if ($("btn-netreset")) $("btn-netreset").onclick = function () { NETRESET(); }; |
| 428 | |
| 429 | /* Drag-and-drop upload */ |
| 430 | var dr = 0; |
| 431 | D.addEventListener("dragenter", function (e) { e.preventDefault(); dr++; O(); }); |
| 432 | D.addEventListener("dragover", function (e) { e.preventDefault(); O(); }); |
| 433 | D.addEventListener("dragleave", function (e) { |
| 434 | e.preventDefault(); |
| 435 | dr = Math.max(0, dr - 1); |
| 436 | if (dr === 0) O0(); |
| 437 | }); |
| 438 | D.addEventListener("drop", function (e) { |
| 439 | e.preventDefault(); |
| 440 | dr = 0; |
| 441 | O("Upload", 0); |
| 442 | U0(e.dataTransfer.files); |
| 443 | }); |
| 444 | |
| 445 | /* Intercept card rendering to attach context menus after each L0() */ |
| 446 | var origR = R; |
| 447 | R = function (q) { |
| 448 | origR(q); |
| 449 | var cards = D.querySelectorAll(".card"); |
| 450 | for (var i = 0; i < cards.length; i++) attachContextMenu(cards[i]); |
| 451 | }; |
| 452 | |
| 453 | /* Initial load */ |
| 454 | L0("/"); |
| 455 | }); |