From 294b5b8489ff8a6701256a9898d8718995ec2b68 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 16:11:17 +0200 Subject: [PATCH] feat(files): add binary-file support to edit route + template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 7/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md. overlay_file_edit_page now renders the binary-replace UI instead of returning 415 for non-editable files. Two cases bridge to the same binary template: the up-front is_editable() check and the late-binding UnicodeDecodeError on read (8-KiB sniff said yes but the tail had non-UTF-8 bytes). Both paths route through a small _render_binary_editor helper that fills in: * is_binary=True * content="" (no inline editor) * byte_count=target.stat().st_size * mime_type from mimetypes.guess_type, falling back to application/octet-stream Template (overlay_file_editor.html) gains an is_binary branch in the modal body: hides .files-editor-text (CM6 textarea + language dropdown), renders .files-editor-binary instead with file-info note, replace-zone (drop area + browse button + hidden file input), and the per-state idle/queued labels. The Save button reads "Replace" in binary mode and starts disabled — Step 8 enables it via JS when a replacement file is queued. Delete and Download stay visible in binary mode (binary files can be deleted and downloaded the same way text files can). The /files/new route gets is_binary=False + mime_type="" passed explicitly so the template's binary branch never fires there. Server-side only — no JS changes in Step 7. Step 8 wires up the binary-replace handlers (replace-zone drag, browse/clear clicks, Replace button → POST /files/replace). Tests: * Removed test_edit_route_415s_for_non_editable_file — the route no longer returns 415 for non-editable files; the binary template is the new contract. * Added test_edit_route_renders_binary_template_for_non_editable (asserts 200 + is_binary markup + Save button label + disabled state + .files-editor-text absent + byte count rendered). * Added test_binary_template_has_replace_zone (asserts the replace- zone markup: drop zone, idle/queued labels, browse button, hidden file input, and that Download stays visible). pytest: 578 → 579 passed, 1 skipped, 3 deselected. The pre-existing "still 404 / still 400" cases the plan asks for are covered by the existing test_edit_route_404s_for_missing_file and test_edit_route_400s_for_path_traversal tests — left alone rather than duplicated. Verified live: GET /overlays/2/files/edit?path=test.png (an actual binary file in the demo overlay) returns the binary template; DOM inspection confirms .files-editor-binary present with data-rel-path, "Replace" save button starts disabled, Download href points at the file's download endpoint, MIME type and byte count match, .files- editor-text is absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/routes/files_routes.py | 31 +++++++++++++- .../templates/overlay_file_editor.html | 25 ++++++++++- l4d2web/tests/test_url_addressable_modals.py | 42 +++++++++++++++++-- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/l4d2web/l4d2web/routes/files_routes.py b/l4d2web/l4d2web/routes/files_routes.py index a53becc..07dffd2 100644 --- a/l4d2web/l4d2web/routes/files_routes.py +++ b/l4d2web/l4d2web/routes/files_routes.py @@ -31,6 +31,7 @@ Mutating endpoints (only `overlay.type == 'files'`, owner or admin): from __future__ import annotations import io +import mimetypes import os import shutil import tempfile @@ -256,15 +257,18 @@ def overlay_file_edit_page(overlay_id: int): if not target.exists() or not target.is_file(): return Response(status=404) + if not is_editable(target): - return Response("not editable", status=415) + return _render_binary_editor(overlay, sub_path, target) try: content = target.read_text(encoding="utf-8") except OSError: return Response("read failed", status=500) except UnicodeDecodeError: - return Response("not editable", status=415) + # is_editable's 8-KiB sniff said yes but the tail was binary. + # Fall back to the binary template path. + return _render_binary_editor(overlay, sub_path, target) return render_template( "overlay_file_editor.html", @@ -273,7 +277,28 @@ def overlay_file_edit_page(overlay_id: int): content=content, byte_count=len(content.encode("utf-8")), is_new=False, + is_binary=False, at_folder="", + mime_type="", + ) + + +def _render_binary_editor(overlay, sub_path, target): + """Render overlay_file_editor.html in binary-replace mode. Used by + overlay_file_edit_page when the target file isn't a UTF-8 text file. + The template hides the CM6 textarea + language dropdown and shows a + Replace zone + Download link; the save button reads 'Replace'.""" + mime, _enc = mimetypes.guess_type(target.name) + return render_template( + "overlay_file_editor.html", + overlay=overlay, + rel_path=sub_path, + content="", + byte_count=target.stat().st_size, + is_new=False, + is_binary=True, + at_folder="", + mime_type=mime or "application/octet-stream", ) @@ -314,7 +339,9 @@ def overlay_file_new_page(overlay_id: int): content="", byte_count=0, is_new=True, + is_binary=False, at_folder=at, + mime_type="", ) diff --git a/l4d2web/l4d2web/templates/overlay_file_editor.html b/l4d2web/l4d2web/templates/overlay_file_editor.html index c2a0638..d7567e7 100644 --- a/l4d2web/l4d2web/templates/overlay_file_editor.html +++ b/l4d2web/l4d2web/templates/overlay_file_editor.html @@ -16,6 +16,7 @@ + {% if not is_binary %}
+ {% else %} +
+
+ ⛌ Inline editing not available + · {{ byte_count }} bytes · {{ mime_type }} +
+ +
+

↑ Drop a file here to replace · + · + single file only · keeps the filename +

+ + +
+
+ {% endif %} {% endblock %} diff --git a/l4d2web/tests/test_url_addressable_modals.py b/l4d2web/tests/test_url_addressable_modals.py index 5c38d95..336ba53 100644 --- a/l4d2web/tests/test_url_addressable_modals.py +++ b/l4d2web/tests/test_url_addressable_modals.py @@ -95,15 +95,49 @@ def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch): assert response.status_code == 404 -def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch): - client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db") - # Forge a non-editable file by writing binary garbage. +def test_edit_route_renders_binary_template_for_non_editable(tmp_path, monkeypatch): + """Phase B Step 7: non-editable files no longer return 415; they render + the editor template with is_binary=True so the modal shows the + replace-zone UI instead of an error.""" + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-binary.db") from pathlib import Path overlay_root = tmp_path / "overlays" / str(overlay_id) Path(overlay_root).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024) response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin") - assert response.status_code == 415 + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert 'class="files-editor-binary"' in text + # Save button is labeled "Replace" and starts disabled (no file queued). + assert ">Replace" in text + assert 'class="files-editor-save" disabled' in text + # Byte count + MIME type rendered. + assert "4096 bytes" in text # 4 bytes × 1024 + # The CM6 text editor isn't rendered in binary mode. + assert 'class="files-editor-text"' not in text + + +def test_binary_template_has_replace_zone(tmp_path, monkeypatch): + """Spot-check the binary panel's replace-zone markup: drop zone, + idle and queued labels, browse button, and a hidden file input.""" + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-binary-zone.db") + from pathlib import Path + overlay_root = tmp_path / "overlays" / str(overlay_id) + Path(overlay_root).joinpath("blob.bin").write_bytes(b"\xff" * 100) + + response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert 'class="files-editor-replace-zone"' in text + assert 'class="files-editor-replace-idle"' in text + assert 'class="files-editor-replace-queued"' in text + assert 'class="link-button files-editor-replace-browse"' in text + assert 'class="files-editor-replace-input" hidden' in text + # Download link stays visible in binary mode (it's how the user + # gets the existing file out). + assert "files-editor-download" in text def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):