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:
parent
aacd95012e
commit
c16e780283
4 changed files with 88 additions and 4 deletions
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue