From 60e79683fc7464003f966ad2c9214876d74b5fe3 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 11:43:18 +0200 Subject: [PATCH] feat(modals): GET /overlays//files/edit route Server-renders the file editor as a real page. With HX-Modal:1 returns a layoutless fragment for modal embedding; without it returns the full standalone page. Mirrors overlay_file_content's path/editability checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/routes/files_routes.py | 41 +++++++ l4d2web/tests/test_url_addressable_modals.py | 109 +++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/l4d2web/l4d2web/routes/files_routes.py b/l4d2web/l4d2web/routes/files_routes.py index 75291cc..0faa365 100644 --- a/l4d2web/l4d2web/routes/files_routes.py +++ b/l4d2web/l4d2web/routes/files_routes.py @@ -234,6 +234,47 @@ def overlay_file_content(overlay_id: int): return jsonify({"path": sub_path, "content": content}) +@bp.get("/overlays//files/edit") +@require_login +def overlay_file_edit_page(overlay_id: int): + """Server-rendered editor page. Renders full-page by default or as a + layoutless modal fragment when the HX-Modal header is set (see the + inject_base_layout context processor in app.py).""" + user = current_user() + assert user is not None + sub_path = request.args.get("path", "") + + result = _load_files_overlay(overlay_id, user) + if isinstance(result, Response): + return result + overlay = result + + try: + target = safe_resolve_for_listing(overlay.path, sub_path) + except ValueError: + return Response("invalid path", status=400) + + if not target.exists() or not target.is_file(): + return Response(status=404) + if not is_editable(target): + return Response("not editable", status=415) + + try: + content = target.read_text(encoding="utf-8") + except OSError: + return Response("read failed", status=500) + except UnicodeDecodeError: + return Response("not editable", status=415) + + return render_template( + "overlay_file_editor.html", + overlay=overlay, + rel_path=sub_path, + content=content, + byte_count=len(content.encode("utf-8")), + ) + + def _validate_save_content(content: str) -> Response | None: if len(content.encode("utf-8")) > _SAVE_MAX_BYTES: return Response("content exceeds 1 MiB", status=413) diff --git a/l4d2web/tests/test_url_addressable_modals.py b/l4d2web/tests/test_url_addressable_modals.py index 6fec021..2f96244 100644 --- a/l4d2web/tests/test_url_addressable_modals.py +++ b/l4d2web/tests/test_url_addressable_modals.py @@ -1,6 +1,11 @@ +from datetime import UTC, datetime + from flask import render_template_string from l4d2web.app import create_app +from l4d2web.auth import hash_password +from l4d2web.db import init_db, session_scope +from l4d2web.models import Overlay, User def _make_app(tmp_path, monkeypatch, db_name: str): @@ -27,3 +32,107 @@ def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeyp app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db") with app.test_request_context("/", headers={"HX-Request": "true"}): assert render_template_string("{{ base_layout }}") == "base.html" + + +def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str): + db_url = f"sqlite:///{tmp_path/db_name}" + 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 session: + user = User(username="alice", password_digest=hash_password("secret"), admin=False) + session.add(user) + session.flush() + overlay = Overlay(name="cfgs", path="", type="files", user_id=user.id) + session.add(overlay) + session.flush() + overlay.path = str(overlay.id) + overlay_root = tmp_path / "overlays" / str(overlay.id) + overlay_root.mkdir(parents=True) + (overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8") + 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() + return client, overlay_id + + +def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db") + response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "" in text.lower() # full base.html rendered + assert 'href="/dashboard"' in text # nav present + assert 'class="files-editor-content"' in text + assert 'rcon_password' in text # content pre-filled + + +def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db") + response = client.get( + f"/overlays/{overlay_id}/files/edit?path=server.cfg", + headers={"HX-Modal": "1"}, + ) + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "