diff --git a/l4d2web/l4d2web/routes/files_routes.py b/l4d2web/l4d2web/routes/files_routes.py index 951fb26..14b95b4 100644 --- a/l4d2web/l4d2web/routes/files_routes.py +++ b/l4d2web/l4d2web/routes/files_routes.py @@ -21,8 +21,13 @@ Mutating endpoints (only `overlay.type == 'files'`, owner or admin): rename or move; refuses cycles via `safe_resolve_for_move`. - `POST /overlays//files/mkdir` — JSON `{path}`. Slashes allowed in `path`; intermediate directories are created. -- `POST /overlays//files/delete` — form `path`. Refuses non-empty - directories. +- `POST /overlays//files/delete` — form `path` plus optional + `recursive=1`. Without the flag, refuses non-empty directories + (HTTP 409). With it, `shutil.rmtree`s the target without following + symlinks. +- `GET /overlays//files/delete_preview?path=` — JSON listing + of what a recursive delete would remove. Powers the GUI confirm + modal so the user sees the blast radius before clicking Delete. - `GET /overlays//files/download_zip?path=` — streams a zip of the folder's contents. """ @@ -615,14 +620,100 @@ def overlay_file_mkdir(overlay_id: int): return jsonify({"path": path}) -@bp.post("/overlays//files/delete") +# Cap on preview-listing entries so a directory with millions of files +# doesn't bloat the response or the modal. Past this point the response +# stops appending to `entries` but keeps counting so the totals stay +# accurate — the JS surfaces this as "… and N more entries will also +# be deleted." +_DELETE_PREVIEW_MAX_ENTRIES = 500 + + +def _walk_delete_preview(target): + """Walk `target` once with followlinks=False, returning the + payload for /files/delete_preview. + + Symlinks are recorded with kind="symlink" but their targets are + not descended into — matches what `shutil.rmtree` will actually + remove. Sizes for symlinks are zero (we unlink the link, not the + file it points to).""" + entries: list[dict] = [] + file_count = 0 + dir_count = 0 + total_bytes = 0 + truncated = False + + for dirpath, dirnames, filenames in os.walk(target, followlinks=False): + # Make output deterministic — also gives the test suite a + # stable order to assert on. + dirnames.sort() + filenames.sort() + rel_base = os.path.relpath(dirpath, target) + + # Symlinks-to-directories show up in `dirnames` for os.walk, + # so we have to inspect each before descending. The followlinks=False + # flag prevents the descent but doesn't filter the names list. + for name in list(dirnames): + child = os.path.join(dirpath, name) + rel = name if rel_base == "." else os.path.join(rel_base, name) + if os.path.islink(child): + # Treat symlinked dirs like symlinked files: enumerated + # as a leaf, not descended. + dirnames.remove(name) + if len(entries) < _DELETE_PREVIEW_MAX_ENTRIES: + entries.append({"path": rel, "kind": "symlink"}) + else: + truncated = True + # symlinks count toward neither file_count nor dir_count; + # they're a third category visible only in `entries`. + continue + dir_count += 1 + if len(entries) < _DELETE_PREVIEW_MAX_ENTRIES: + entries.append({"path": rel + "/", "kind": "dir"}) + else: + truncated = True + + for name in filenames: + child = os.path.join(dirpath, name) + rel = name if rel_base == "." else os.path.join(rel_base, name) + if os.path.islink(child): + if len(entries) < _DELETE_PREVIEW_MAX_ENTRIES: + entries.append({"path": rel, "kind": "symlink"}) + else: + truncated = True + continue + file_count += 1 + try: + total_bytes += os.stat(child, follow_symlinks=False).st_size + except OSError: + pass + if len(entries) < _DELETE_PREVIEW_MAX_ENTRIES: + entries.append({"path": rel, "kind": "file"}) + else: + truncated = True + + return { + "entries": entries, + "file_count": file_count, + "dir_count": dir_count, + "total_bytes": total_bytes, + "truncated": truncated, + } + + +@bp.get("/overlays//files/delete_preview") @require_login -def overlay_file_delete(overlay_id: int): - """Delete a file or empty folder. Refuses recursive directory removal.""" +def overlay_file_delete_preview(overlay_id: int): + """Enumerate what a recursive delete of `path` would remove. + + Drives the confirm modal in dialogs.js — the user sees the full + list of files and subdirectories before they click Delete. + + Reuses safe_resolve_for_delete so the same guards (`..`, overlay + root, symlink escapes) apply here as for the actual delete.""" user = current_user() assert user is not None - path = (request.form.get("path") or "").strip() + path = (request.args.get("path") or "").strip() if not path: return Response("missing path", status=400) @@ -631,6 +722,49 @@ def overlay_file_delete(overlay_id: int): return result overlay = result + try: + target = safe_resolve_for_delete(overlay.path, path) + except ValueError as exc: + return Response(str(exc), status=422) + + if not target.exists() and not target.is_symlink(): + return Response(status=404) + + # Files and symlinks-to-anything: empty payload. The modal renders + # the existing compact "Delete ?" view; the JS only opens + # the preview details when entries is non-empty. + if not target.is_dir() or target.is_symlink(): + return jsonify( + entries=[], file_count=0, dir_count=0, total_bytes=0, truncated=False + ) + + return jsonify(_walk_delete_preview(target)) + + +@bp.post("/overlays//files/delete") +@require_login +def overlay_file_delete(overlay_id: int): + """Delete a file, an empty folder, or — with `recursive=1` — an + entire directory tree. + + Without `recursive`, non-empty directories return HTTP 409 (the + historical safety guard). The recursive branch uses + `shutil.rmtree`, which does not follow symlinks by default, so + a symlink inside the tree is unlinked rather than its target's + contents being deleted.""" + user = current_user() + assert user is not None + + path = (request.form.get("path") or "").strip() + if not path: + return Response("missing path", status=400) + recursive = request.form.get("recursive") == "1" + + result = _load_files_overlay(overlay_id, user) + if isinstance(result, Response): + return result + overlay = result + try: target = safe_resolve_for_delete(overlay.path, path) except ValueError as exc: @@ -641,10 +775,15 @@ def overlay_file_delete(overlay_id: int): try: if target.is_dir() and not target.is_symlink(): - try: - target.rmdir() - except OSError: - return Response("directory is not empty", status=409) + if recursive: + # rmtree refuses to follow symlinks during the walk; + # symlinks inside the tree get unlinked, not chased. + shutil.rmtree(target) + else: + try: + target.rmdir() + except OSError: + return Response("directory is not empty", status=409) else: os.unlink(target) except OSError as exc: diff --git a/l4d2web/l4d2web/static/css/components.css b/l4d2web/l4d2web/static/css/components.css index 477a783..56dddfb 100644 --- a/l4d2web/l4d2web/static/css/components.css +++ b/l4d2web/l4d2web/static/css/components.css @@ -665,6 +665,26 @@ button.danger-outline:hover { background: color-mix(in srgb, var(--color-success) 10%, transparent); } +/* Delete-confirm modal: scrollable preview list. The
wrapper + collapses by default; when expanded, very large folders shouldn't + push the modal off-screen. */ +.files-delete-preview-list { + max-height: 40vh; + overflow-y: auto; + margin: 0.5rem 0 0; + padding-left: 1.25rem; + font-family: var(--font-mono, monospace); + font-size: 0.875rem; +} + +.files-delete-preview-list li { + list-style: square; +} + +.files-delete-preview-summary { + cursor: pointer; +} + /* Wider modal for the editor (textarea needs the breathing room). */ dialog.modal.modal-wide, div.modal.modal-wide { diff --git a/l4d2web/l4d2web/static/js/files-overlay/dialogs.js b/l4d2web/l4d2web/static/js/files-overlay/dialogs.js index 1f2673e..09ce065 100644 --- a/l4d2web/l4d2web/static/js/files-overlay/dialogs.js +++ b/l4d2web/l4d2web/static/js/files-overlay/dialogs.js @@ -37,7 +37,7 @@ if (!fo) return; const { baseUrl, csrfToken } = fo; - const { joinPath, parentOf, basename, postJson, postForm, scheduleRefresh } = fo.helpers; + const { joinPath, parentOf, basename, postJson, postForm, fetchJson, scheduleRefresh } = fo.helpers; const newFolderDialog = document.getElementById("files-new-folder-modal"); const deleteDialog = document.getElementById("files-delete-modal"); @@ -102,15 +102,101 @@ // ---------- delete-confirm modal ---------------------------------------- + // For non-empty directories the modal shows a
preview of + // every path that recursive delete would remove. The fetch is fired + // before openModal() so the modal opens already populated — no + // first-paint "Contents…" placeholder with empty content. Failure of + // the preview fetch is non-fatal: we just open the modal without a + // preview and the user can still confirm; the delete itself still + // works. + let deleteState = null; - function openDelete(targetPath, kind, name) { + function resetPreview() { + if (!deleteDialog) return; + const preview = deleteDialog.querySelector(".files-delete-preview"); + if (!preview) return; + preview.hidden = true; + preview.open = false; + deleteDialog.querySelector(".files-delete-preview-list").innerHTML = ""; + const truncated = deleteDialog.querySelector(".files-delete-preview-truncated"); + truncated.hidden = true; + deleteDialog.querySelector(".files-delete-preview-extra").textContent = ""; + const summary = deleteDialog.querySelector(".files-delete-preview-summary"); + if (summary) summary.textContent = "Contents to be deleted"; + } + + function renderPreview(preview) { + if (!deleteDialog) return; + if (!preview || !Array.isArray(preview.entries) || preview.entries.length === 0) { + return; + } + const container = deleteDialog.querySelector(".files-delete-preview"); + const list = deleteDialog.querySelector(".files-delete-preview-list"); + const summary = deleteDialog.querySelector(".files-delete-preview-summary"); + const truncated = deleteDialog.querySelector(".files-delete-preview-truncated"); + const extraEl = deleteDialog.querySelector(".files-delete-preview-extra"); + + const frag = document.createDocumentFragment(); + for (const entry of preview.entries) { + const li = document.createElement("li"); + li.textContent = entry.path; + if (entry.kind === "symlink") { + const tag = document.createElement("span"); + tag.className = "muted"; + tag.textContent = " (link)"; + li.appendChild(tag); + } + frag.appendChild(li); + } + list.replaceChildren(frag); + + const parts = []; + if (preview.file_count > 0) { + parts.push(`${preview.file_count} file${preview.file_count === 1 ? "" : "s"}`); + } + if (preview.dir_count > 0) { + parts.push(`${preview.dir_count} folder${preview.dir_count === 1 ? "" : "s"}`); + } + if (summary) { + summary.textContent = parts.length + ? `Contents to be deleted (${parts.join(" + ")})` + : "Contents to be deleted"; + } + + if (preview.truncated) { + const shown = preview.entries.length; + const total = (preview.file_count || 0) + (preview.dir_count || 0); + const extra = Math.max(0, total - shown); + extraEl.textContent = String(extra); + truncated.hidden = false; + } + container.hidden = false; + } + + async function openDelete(targetPath, kind, name) { if (!deleteDialog) return; deleteDialog.querySelector(".files-delete-name").textContent = name; const errEl = deleteDialog.querySelector(".files-delete-error"); errEl.hidden = true; errEl.textContent = ""; - deleteState = { path: targetPath, kind, errEl }; + resetPreview(); + // Default to non-recursive; flipped to true only when the preview + // confirms there are entries inside. + deleteState = { path: targetPath, kind, errEl, recursive: false }; + + if (kind === "dir") { + const url = `${baseUrl}/files/delete_preview?path=${encodeURIComponent(targetPath)}`; + const r = await fetchJson(url); + if (r.ok && r.body && Array.isArray(r.body.entries) && r.body.entries.length > 0) { + renderPreview(r.body); + deleteState.recursive = true; + } + // On preview-fetch failure (or empty result), fall through to + // the compact modal — the existing rmdir path still handles + // empty dirs without recursive=1. + } + openModal(deleteDialog); } @@ -119,10 +205,11 @@ const btn = event.target?.closest?.(".files-delete-confirm"); if (!btn) return; if (!deleteState) return; - const { path, errEl } = deleteState; + const { path, errEl, recursive } = deleteState; const fd = new FormData(); fd.append("path", path); fd.append("csrf_token", csrfToken); + if (recursive) fd.append("recursive", "1"); const r = await postForm(`${baseUrl}/files/delete`, fd); if (r.ok) { deleteState = null; @@ -130,12 +217,17 @@ scheduleRefresh(parentOf(path)); } else { errEl.hidden = false; + // rawText fallback surfaces the server's text-body errors + // (e.g. "directory is not empty") — mirrors editor.js. errEl.textContent = - (r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`; + (r.body && r.body.error) || + r.rawText || + `Delete failed (HTTP ${r.status}).`; } }); deleteDialog.addEventListener("close", () => { deleteState = null; + resetPreview(); }); } diff --git a/l4d2web/l4d2web/static/js/files-overlay/editor.js b/l4d2web/l4d2web/static/js/files-overlay/editor.js index 320a9c8..690af0a 100644 --- a/l4d2web/l4d2web/static/js/files-overlay/editor.js +++ b/l4d2web/l4d2web/static/js/files-overlay/editor.js @@ -69,17 +69,18 @@ // Enables Save (labeled "Replace" in binary mode) when either a file // is queued OR the filename input has been edited. Rename-only is a - // valid Replace and routes to /files/move below. + // valid Replace and routes to /files/move below. The filename input + // holds the full relative path (phone-friendly move-via-edit), so the + // "changed?" check compares against relPath, not its basename. function updateRoutedBinarySaveEnabled(modalContent) { const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]"); const saveBtn = modalContent.querySelector(".files-editor-save"); if (!panel || !saveBtn) return; const relPath = panel.dataset.relPath || ""; - const originalLeaf = relPath.split("/").pop() || relPath; const filenameInput = modalContent.querySelector("[data-editor-filename]"); const filenameChanged = filenameInput && filenameInput.value.trim() && - filenameInput.value.trim() !== originalLeaf; + filenameInput.value.trim() !== relPath; saveBtn.disabled = !routedReplacement && !filenameChanged; } @@ -227,13 +228,12 @@ return; } - // Edit mode: rename-on-save (sibling rename only — parent stays). - const originalLeaf = relPath.split("/").pop() || relPath; - const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ""; - let newPath = null; - if (editedFilename && editedFilename !== originalLeaf) { - newPath = parent ? `${parent}/${editedFilename}` : editedFilename; - } + // Edit mode: the filename input holds the FULL relative path, not + // just the leaf — editing it moves or renames the file. This is + // the phone-friendly alternative to drag-and-drop. /files/save + + // safe_resolve_for_move validate the destination, so we forward + // the input verbatim. + const newPath = (editedFilename && editedFilename !== relPath) ? editedFilename : null; const payload = { path: relPath, content }; if (newPath) payload.new_path = newPath; const r = await postJson(`${baseUrl}/files/save`, payload); @@ -257,12 +257,10 @@ const filenameInput = modalContent.querySelector("[data-editor-filename]"); const editedFilename = filenameInput ? filenameInput.value.trim() : ""; - const originalLeaf = relPath.split("/").pop() || relPath; - const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ""; - const renaming = !!editedFilename && editedFilename !== originalLeaf; - const newPath = renaming - ? (parent ? `${parent}/${editedFilename}` : editedFilename) - : null; + // Filename input is the full relative path; see routedSaveClicked + // for the rationale. + const renaming = !!editedFilename && editedFilename !== relPath; + const newPath = renaming ? editedFilename : null; if (routedReplacement) { const fd = new FormData(); diff --git a/l4d2web/l4d2web/templates/overlay_detail.html b/l4d2web/l4d2web/templates/overlay_detail.html index 3ca1e2f..f342bfc 100644 --- a/l4d2web/l4d2web/templates/overlay_detail.html +++ b/l4d2web/l4d2web/templates/overlay_detail.html @@ -215,6 +215,16 @@