feat(modals): GET /overlays/<id>/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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 11:43:18 +02:00
parent a26b4cc34e
commit 60e79683fc
No known key found for this signature in database
2 changed files with 150 additions and 0 deletions

View file

@ -234,6 +234,47 @@ def overlay_file_content(overlay_id: int):
return jsonify({"path": sub_path, "content": content}) return jsonify({"path": sub_path, "content": content})
@bp.get("/overlays/<int:overlay_id>/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: def _validate_save_content(content: str) -> Response | None:
if len(content.encode("utf-8")) > _SAVE_MAX_BYTES: if len(content.encode("utf-8")) > _SAVE_MAX_BYTES:
return Response("content exceeds 1 MiB", status=413) return Response("content exceeds 1 MiB", status=413)

View file

@ -1,6 +1,11 @@
from datetime import UTC, datetime
from flask import render_template_string from flask import render_template_string
from l4d2web.app import create_app 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): 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") app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db")
with app.test_request_context("/", headers={"HX-Request": "true"}): with app.test_request_context("/", headers={"HX-Request": "true"}):
assert render_template_string("{{ base_layout }}") == "base.html" 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 "<!doctype html>" 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 "<html" not in text # layoutless
assert 'class="primary-nav"' not in text
assert 'class="files-editor-content"' in text
assert "hostname" in text # content pre-filled
def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-404.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=nonexistent.cfg")
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.
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
def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-400.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=../../etc/passwd")
assert response.status_code == 400
def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'edit-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/edit?path=anything.cfg")
assert response.status_code == 404