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,
|
list_directory,
|
||||||
safe_resolve_for_download,
|
safe_resolve_for_download,
|
||||||
safe_resolve_for_listing,
|
safe_resolve_for_listing,
|
||||||
|
safe_resolve_for_server_download,
|
||||||
safe_resolve_for_server_listing,
|
safe_resolve_for_server_listing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -139,5 +140,32 @@ def server_files_fragment(server_id: int):
|
||||||
truncated=truncated_count > 0,
|
truncated=truncated_count > 0,
|
||||||
truncated_count=truncated_count,
|
truncated_count=truncated_count,
|
||||||
files_base_url=f"/servers/{server_id}",
|
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):
|
if not _is_under(real, left4me_root):
|
||||||
raise ValueError("path escapes LEFT4ME_ROOT")
|
raise ValueError("path escapes LEFT4ME_ROOT")
|
||||||
return real
|
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 = file_tree_truncated %}
|
||||||
{% set truncated_count = file_tree_truncated_count %}
|
{% set truncated_count = file_tree_truncated_count %}
|
||||||
{% set files_base_url = "/servers/" ~ server.id %}
|
{% set files_base_url = "/servers/" ~ server.id %}
|
||||||
{% set download_supported = False %}
|
{% set download_supported = True %}
|
||||||
{% include "_overlay_file_tree.html" %}
|
{% include "_overlay_file_tree.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -410,8 +410,45 @@ def test_server_files_fragment_lists_root(app, left4me_root: Path) -> None:
|
||||||
text = response.get_data(as_text=True)
|
text = response.get_data(as_text=True)
|
||||||
assert "left4dead2" in text
|
assert "left4dead2" in text
|
||||||
assert "srcds_run" in text
|
assert "srcds_run" in text
|
||||||
# Listing-only — no download link.
|
# Download links are now rendered (mirrors overlay tree).
|
||||||
assert "/files/download" not in text
|
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:
|
def test_server_files_fragment_returns_404_when_merged_missing(app, left4me_root: Path) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue