From c16e7802830cf6867c2ec4d4260adbeab5deec02 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sat, 9 May 2026 01:40:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(l4d2-web):=20server=20file=20tree=20?= =?UTF-8?q?=E2=80=94=20enable=20download=20symmetric=20with=20overlay=20tr?= =?UTF-8?q?ee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /servers//files/download route mirroring the overlay download endpoint. Same safety rules: real-path must resolve under LEFT4ME_ROOT (merged view threads through `installation/` and overlay layers, all already inside the root). The server file-tree partial now renders download links. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/routes/files_routes.py | 30 +++++++++++++++- l4d2web/services/overlay_files.py | 19 ++++++++++ l4d2web/templates/server_detail.html | 2 +- l4d2web/tests/test_overlay_files_routes.py | 41 ++++++++++++++++++++-- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/l4d2web/routes/files_routes.py b/l4d2web/routes/files_routes.py index fae1cd2..06f7936 100644 --- a/l4d2web/routes/files_routes.py +++ b/l4d2web/routes/files_routes.py @@ -25,6 +25,7 @@ from l4d2web.services.overlay_files import ( list_directory, safe_resolve_for_download, safe_resolve_for_listing, + safe_resolve_for_server_download, safe_resolve_for_server_listing, ) @@ -139,5 +140,32 @@ def server_files_fragment(server_id: int): truncated=truncated_count > 0, truncated_count=truncated_count, files_base_url=f"/servers/{server_id}", - download_supported=False, + download_supported=True, + ) + + +@bp.get("/servers//files/download") +@require_login +def server_files_download(server_id: int): + user = current_user() + assert user is not None + sub_path = request.args.get("path", "") + + with session_scope() as db: + server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id)) + if server is None: + return Response(status=404) + + try: + real = safe_resolve_for_server_download(server_id, sub_path) + except ValueError: + return Response("invalid path", status=400) + + if real is None or not real.exists(): + return Response(status=404) + if real.is_dir(): + return Response("not a file", status=400) + + return send_file( + str(real), as_attachment=True, download_name=os.path.basename(real) ) diff --git a/l4d2web/services/overlay_files.py b/l4d2web/services/overlay_files.py index 911f24b..aa9d837 100644 --- a/l4d2web/services/overlay_files.py +++ b/l4d2web/services/overlay_files.py @@ -147,3 +147,22 @@ def safe_resolve_for_download(overlay_path_value: str, sub_path: str) -> Path: if not _is_under(real, left4me_root): raise ValueError("path escapes LEFT4ME_ROOT") return real + + +def safe_resolve_for_server_download(server_id: int, sub_path: str) -> Path | None: + """Resolve a file inside `runtime//merged` for download. Follows + symlinks (the merged view threads through `installation/` and overlay + layers, all under LEFT4ME_ROOT). Refuses anything escaping LEFT4ME_ROOT. + Returns None when the merged dir doesn't exist yet.""" + if sub_path == "": + raise ValueError("download requires a file path") + validate_overlay_ref(sub_path) + merged_root = (get_left4me_root() / "runtime" / str(server_id) / "merged").resolve() + if not merged_root.is_dir(): + return None + candidate = merged_root / sub_path + real = Path(os.path.realpath(candidate)) + left4me_root = get_left4me_root().resolve() + if not _is_under(real, left4me_root): + raise ValueError("path escapes LEFT4ME_ROOT") + return real diff --git a/l4d2web/templates/server_detail.html b/l4d2web/templates/server_detail.html index d65934c..3643ace 100644 --- a/l4d2web/templates/server_detail.html +++ b/l4d2web/templates/server_detail.html @@ -27,7 +27,7 @@ {% set truncated = file_tree_truncated %} {% set truncated_count = file_tree_truncated_count %} {% set files_base_url = "/servers/" ~ server.id %} - {% set download_supported = False %} + {% set download_supported = True %} {% include "_overlay_file_tree.html" %} {% endif %} diff --git a/l4d2web/tests/test_overlay_files_routes.py b/l4d2web/tests/test_overlay_files_routes.py index 6d11a2c..dfdfafa 100644 --- a/l4d2web/tests/test_overlay_files_routes.py +++ b/l4d2web/tests/test_overlay_files_routes.py @@ -410,8 +410,45 @@ def test_server_files_fragment_lists_root(app, left4me_root: Path) -> None: text = response.get_data(as_text=True) assert "left4dead2" in text assert "srcds_run" in text - # Listing-only — no download link. - assert "/files/download" not in text + # Download links are now rendered (mirrors overlay tree). + assert f"/servers/{server_id}/files/download?path=srcds_run" in text + + +def test_server_download_streams_regular_file(app, left4me_root: Path) -> None: + user_id = _make_user() + server_id = _make_server(left4me_root, user_id=user_id, port=27050) + merged = left4me_root / "runtime" / str(server_id) / "merged" + cfg = merged / "left4dead2" / "cfg" / "server.cfg" + cfg.parent.mkdir(parents=True) + cfg.write_bytes(b"hostname zonemod") + + client = _client_for(app, user_id) + response = client.get( + f"/servers/{server_id}/files/download?path=left4dead2/cfg/server.cfg" + ) + + assert response.status_code == 200 + assert response.headers["Content-Disposition"].startswith("attachment") + assert "filename=server.cfg" in response.headers["Content-Disposition"] + assert response.get_data() == b"hostname zonemod" + + +def test_server_download_rejects_symlink_outside_left4me_root( + app, left4me_root: Path, tmp_path_factory +) -> None: + user_id = _make_user() + server_id = _make_server(left4me_root, user_id=user_id, port=27060) + merged = left4me_root / "runtime" / str(server_id) / "merged" + merged.mkdir(parents=True) + + outside = tmp_path_factory.mktemp("outside-server") / "secret.txt" + outside.write_text("nope") + (merged / "evil").symlink_to(outside) + + client = _client_for(app, user_id) + response = client.get(f"/servers/{server_id}/files/download?path=evil") + + assert response.status_code == 400 def test_server_files_fragment_returns_404_when_merged_missing(app, left4me_root: Path) -> None: