Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
web/app.js.legacy
zftpd / web / app.js.legacy
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 
23var 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 * ---------------------------------------------------------------------- */
33function T() {
34 var m = D.querySelector('meta[name="csrf-token"]');
35 return m ? m.content : "";
36}
37 
38/* -------------------------------------------------------------------------
39 * PATH HELPERS
40 * ---------------------------------------------------------------------- */
41function N(p) {
42 return !p || p[0] !== "/" ? "/" :
43 p.length > 1 && p[p.length - 1] === "/" ? p.slice(0, -1) : p;
44}
45function 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}
51function joinPath(n) {
52 return P === "/" ? "/" + n : P + "/" + n;
53}
54 
55/* -------------------------------------------------------------------------
56 * STATUS PILL
57 * ---------------------------------------------------------------------- */
58function 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 * ---------------------------------------------------------------------- */
68function 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 * ---------------------------------------------------------------------- */
79function 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}
85function O0() {
86 $("drop").classList.remove("show");
87 $("drop-sub").textContent = "Release";
88 $("drop-bar").style.width = "0%";
89}
90 
91/* -------------------------------------------------------------------------
92 * BREADCRUMB
93 * ---------------------------------------------------------------------- */
94function 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 * ---------------------------------------------------------------------- */
122function 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 * ---------------------------------------------------------------------- */
155function 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 * ---------------------------------------------------------------------- */
184function 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 * ---------------------------------------------------------------------- */
229function 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 * ---------------------------------------------------------------------- */
249function 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 * ---------------------------------------------------------------------- */
268function 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 * ---------------------------------------------------------------------- */
283function 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 * ---------------------------------------------------------------------- */
301function 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 * ---------------------------------------------------------------------- */
345function 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 * ---------------------------------------------------------------------- */
359function 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 * ---------------------------------------------------------------------- */
406D.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});