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):