From 6b0231970c52bcaad4241781b26e5d7ba34645a2 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 16:03:01 +0200 Subject: [PATCH] feat(files): add GET /overlays//files/new + extend editor template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md. Adds a server-rendered editor page for creating a new file in a target folder. Renders the same overlay_file_editor.html as the edit route, but with is_new=True so the template: * Hides the Delete button * Hides the Download link (no file to download yet) * Changes the Save button label to "Create" * Emits data-at-folder on the textarea for the JS save handler to compose path = at_folder + "/" + filename * Updates the title block (browser tab + heading) to "New file" / "/…new file" The existing edit route now passes is_new=False and at_folder="" explicitly so both call sites are explicit about the contract. Path validation: ?at may be empty (overlay root) or a relative folder path. safe_resolve_for_listing rejects traversal attempts (400). A missing or non-directory at returns 404. Reused the helper to keep the safety story identical to the listing / edit routes. Phase B Step 5 is server-side only — no JS changes here. Step 6 migrates openEditorTextNew in editor.js to use this route via window.modals.openRouted(). Tests added (tests/test_url_addressable_modals.py): * test_new_route_renders_with_empty_content * test_new_route_renders_with_target_folder_attribute * test_new_route_renders_create_button_not_save * test_new_route_400s_for_invalid_at_path * test_new_route_404s_for_non_files_overlay pytest: 573 → 578 passed, 1 skipped, 3 deselected. Verified live: curl /overlays/2/files/new returns markup with data-at-folder="", "UTF-8 · 0 bytes" byte count, save button label "Create", and no Delete / Download buttons. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/routes/files_routes.py | 43 +++++++++++ .../templates/overlay_file_editor.html | 12 +-- l4d2web/tests/test_url_addressable_modals.py | 73 +++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/l4d2web/l4d2web/routes/files_routes.py b/l4d2web/l4d2web/routes/files_routes.py index 0faa365..a53becc 100644 --- a/l4d2web/l4d2web/routes/files_routes.py +++ b/l4d2web/l4d2web/routes/files_routes.py @@ -272,6 +272,49 @@ def overlay_file_edit_page(overlay_id: int): rel_path=sub_path, content=content, byte_count=len(content.encode("utf-8")), + is_new=False, + at_folder="", + ) + + +@bp.get("/overlays//files/new") +@require_login +def overlay_file_new_page(overlay_id: int): + """Server-rendered editor page for creating a new file under `?at=`. + Renders the same overlay_file_editor.html template as the edit route, + with `is_new=True` so the save button label is "Create" (not "Save"), + the Delete and Download buttons are hidden, and the filename input + starts empty. The textarea carries `data-at-folder` so the JS save + handler can compose `/` on submit. + + `?at` may be empty (= overlay root) or a relative folder path. Path- + traversal attempts return 400; a missing or non-directory `at` + returns 404.""" + user = current_user() + assert user is not None + at = request.args.get("at", "") + + result = _load_files_overlay(overlay_id, user) + if isinstance(result, Response): + return result + overlay = result + + if at: + try: + target = safe_resolve_for_listing(overlay.path, at) + except ValueError: + return Response("invalid path", status=400) + if not target.exists() or not target.is_dir(): + return Response(status=404) + + return render_template( + "overlay_file_editor.html", + overlay=overlay, + rel_path="", + content="", + byte_count=0, + is_new=True, + at_folder=at, ) diff --git a/l4d2web/l4d2web/templates/overlay_file_editor.html b/l4d2web/l4d2web/templates/overlay_file_editor.html index cc3d3a0..c2a0638 100644 --- a/l4d2web/l4d2web/templates/overlay_file_editor.html +++ b/l4d2web/l4d2web/templates/overlay_file_editor.html @@ -1,11 +1,11 @@ {% extends base_layout %} -{% block title %}Edit {{ rel_path }} · {{ overlay.name }}{% endblock %} +{% block title %}{% if is_new %}New file{% else %}Edit {{ rel_path }}{% endif %} · {{ overlay.name }}{% endblock %} {% block extra_head %}{% include "_editor_assets.html" %}{% endblock %} {% block content %}
@@ -28,7 +28,7 @@
UTF-8 · {{ byte_count }} bytes @@ -37,11 +37,11 @@
{% endblock %} diff --git a/l4d2web/tests/test_url_addressable_modals.py b/l4d2web/tests/test_url_addressable_modals.py index 2f96244..5c38d95 100644 --- a/l4d2web/tests/test_url_addressable_modals.py +++ b/l4d2web/tests/test_url_addressable_modals.py @@ -136,3 +136,76 @@ def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch): response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg") assert response.status_code == 404 + + +# ---------------------------------------------------------------- /files/new + + +def test_new_route_renders_with_empty_content(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-empty.db") + response = client.get(f"/overlays/{overlay_id}/files/new") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + # Empty content + empty filename + zero byte count. + assert 'class="files-editor-content"' in text + assert 'value=""' in text # filename input + assert "UTF-8 · 0 bytes" in text + + +def test_new_route_renders_with_target_folder_attribute(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-atfolder.db") + # Create a subfolder to target. + (tmp_path / "overlays" / str(overlay_id) / "cfg").mkdir() + response = client.get(f"/overlays/{overlay_id}/files/new?at=cfg") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert 'data-at-folder="cfg"' in text + # Title text reflects the target folder. + assert "cfg/…new file" in text + + +def test_new_route_renders_create_button_not_save(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-button.db") + response = client.get(f"/overlays/{overlay_id}/files/new") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + # Save button shows "Create" instead of "Save"; Delete + Download + # buttons are absent on a new file. + assert 'class="files-editor-save">Create' in text + assert 'class="danger-outline files-editor-delete"' not in text + assert 'class="button-secondary files-editor-download"' not in text + + +def test_new_route_400s_for_invalid_at_path(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-400.db") + response = client.get(f"/overlays/{overlay_id}/files/new?at=../../etc") + assert response.status_code == 400 + + +def test_new_route_404s_for_non_files_overlay(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'new-script-overlay.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + with session_scope() as s: + user = User(username="alice", password_digest=hash_password("secret"), admin=False) + s.add(user) + s.flush() + overlay = Overlay(name="scripted", path="", type="script", user_id=user.id) + s.add(overlay) + s.flush() + overlay.path = str(overlay.id) + (tmp_path / "overlays" / str(overlay.id)).mkdir(parents=True) + user_id = user.id + overlay_id = overlay.id + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user_id + sess["pw_changed_at"] = datetime.now(UTC).isoformat() + + response = client.get(f"/overlays/{overlay_id}/files/new") + assert response.status_code == 404