feat(l4d2-web): server file tree — enable download symmetric with overlay tree

Adds a /servers/<id>/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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-09 01:40:04 +02:00
parent aacd95012e
commit c16e780283
No known key found for this signature in database
4 changed files with 88 additions and 4 deletions

View file

@ -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/<int:server_id>/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)
)

View file

@ -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/<server_id>/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

View file

@ -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 %}
</section>

View file

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