feat(l4d2-web): server detail — directory tree of the runtime merged view
Adds a Files section at the bottom of the server detail page that lists the kernel-overlayfs merged view at runtime/<server_id>/merged/. Reuses the overlay file-tree partial via two new template variables: - files_base_url: parent passes "/overlays/<id>" or "/servers/<id>" - download_supported: false for servers (runtime holds large game binaries; no download endpoint), true for overlays (existing behavior) New service helper safe_resolve_for_server_listing() rejects path traversal beyond the merged root and returns None when the overlayfs mount doesn't exist (server never started or just reset). New route GET /servers/<id>/files?path=<rel> returns the lazy-load file-tree fragment, gated to the server owner. No download counterpart. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa686f11e3
commit
ed12280cf0
7 changed files with 174 additions and 4 deletions
|
|
@ -20,11 +20,12 @@ from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_login
|
from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Overlay
|
from l4d2web.models import Overlay, Server
|
||||||
from l4d2web.services.overlay_files import (
|
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_listing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +73,8 @@ def overlay_files_fragment(overlay_id: int):
|
||||||
entries=entries,
|
entries=entries,
|
||||||
truncated=truncated_count > 0,
|
truncated=truncated_count > 0,
|
||||||
truncated_count=truncated_count,
|
truncated_count=truncated_count,
|
||||||
|
files_base_url=f"/overlays/{overlay_id}",
|
||||||
|
download_supported=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,3 +103,41 @@ def overlay_files_download(overlay_id: int):
|
||||||
return send_file(
|
return send_file(
|
||||||
str(real), as_attachment=True, download_name=os.path.basename(real)
|
str(real), as_attachment=True, download_name=os.path.basename(real)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/servers/<int:server_id>/files")
|
||||||
|
@require_login
|
||||||
|
def server_files_fragment(server_id: int):
|
||||||
|
"""Listing-only file tree for a server's runtime merged view. No download
|
||||||
|
counterpart — runtime holds large game binaries the user doesn't need to
|
||||||
|
pull down through the web app.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
target = safe_resolve_for_server_listing(server_id, sub_path)
|
||||||
|
merged_root = safe_resolve_for_server_listing(server_id, "")
|
||||||
|
except ValueError:
|
||||||
|
return Response("invalid path", status=400)
|
||||||
|
|
||||||
|
if target is None or merged_root is None:
|
||||||
|
return Response(status=404)
|
||||||
|
if not target.is_dir():
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
entries, truncated_count = list_directory(target, merged_root)
|
||||||
|
return render_template(
|
||||||
|
"_overlay_file_tree.html",
|
||||||
|
entries=entries,
|
||||||
|
truncated=truncated_count > 0,
|
||||||
|
truncated_count=truncated_count,
|
||||||
|
files_base_url=f"/servers/{server_id}",
|
||||||
|
download_supported=False,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from l4d2web.models import (
|
||||||
from l4d2web.services.overlay_files import (
|
from l4d2web.services.overlay_files import (
|
||||||
list_directory,
|
list_directory,
|
||||||
safe_resolve_for_listing,
|
safe_resolve_for_listing,
|
||||||
|
safe_resolve_for_server_listing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -195,16 +196,35 @@ def server_detail(server_id: int):
|
||||||
ctx = _build_server_actions_context(db, server)
|
ctx = _build_server_actions_context(db, server)
|
||||||
|
|
||||||
connect_host = request.host.split(":")[0]
|
connect_host = request.host.split(":")[0]
|
||||||
|
file_tree_root_entries, file_tree_truncated_count = _root_server_file_tree(server_id)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"server_detail.html",
|
"server_detail.html",
|
||||||
server=server,
|
server=server,
|
||||||
blueprint=blueprint,
|
blueprint=blueprint,
|
||||||
connect_host=connect_host,
|
connect_host=connect_host,
|
||||||
|
file_tree_root_entries=file_tree_root_entries,
|
||||||
|
file_tree_truncated=file_tree_truncated_count > 0
|
||||||
|
if file_tree_root_entries is not None
|
||||||
|
else False,
|
||||||
|
file_tree_truncated_count=file_tree_truncated_count,
|
||||||
**ctx,
|
**ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _root_server_file_tree(server_id: int) -> tuple[list[dict] | None, int]:
|
||||||
|
"""Root listing of `runtime/<server_id>/merged`. Returns (None, 0) when
|
||||||
|
the merged dir doesn't exist yet (server never started or just reset)."""
|
||||||
|
try:
|
||||||
|
merged_root = safe_resolve_for_server_listing(server_id, "")
|
||||||
|
except ValueError:
|
||||||
|
return None, 0
|
||||||
|
if merged_root is None:
|
||||||
|
return None, 0
|
||||||
|
entries, truncated_count = list_directory(merged_root, merged_root)
|
||||||
|
return entries, truncated_count
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/servers/<int:server_id>/actions")
|
@bp.get("/servers/<int:server_id>/actions")
|
||||||
@require_login
|
@require_login
|
||||||
def server_actions_fragment(server_id: int):
|
def server_actions_fragment(server_id: int):
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,24 @@ def safe_resolve_for_listing(overlay_path_value: str, sub_path: str) -> Path:
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def safe_resolve_for_server_listing(server_id: int, sub_path: str) -> Path | None:
|
||||||
|
"""Resolve a path inside `runtime/<server_id>/merged` (the kernel-overlayfs
|
||||||
|
composed view of a running server). Returns None if the merged dir doesn't
|
||||||
|
exist yet (server has never started, or was just reset). Refuses paths
|
||||||
|
that escape the merged root after symlink resolution.
|
||||||
|
"""
|
||||||
|
merged_root = (get_left4me_root() / "runtime" / str(server_id) / "merged").resolve()
|
||||||
|
if not merged_root.is_dir():
|
||||||
|
return None
|
||||||
|
if sub_path == "":
|
||||||
|
return merged_root
|
||||||
|
validate_overlay_ref(sub_path)
|
||||||
|
candidate = (merged_root / sub_path).resolve(strict=False)
|
||||||
|
if not _is_under(candidate, merged_root):
|
||||||
|
raise ValueError("path escapes server merged root")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_ENTRIES = 500
|
DEFAULT_MAX_ENTRIES = 500
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="file-tree-toggle"
|
class="file-tree-toggle"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
data-files-url="/overlays/{{ overlay.id }}/files?path={{ entry.rel|urlencode }}">
|
data-files-url="{{ files_base_url }}/files?path={{ entry.rel|urlencode }}">
|
||||||
<span class="chevron" aria-hidden="true">›</span>{{ entry.name }}/
|
<span class="chevron" aria-hidden="true">›</span>{{ entry.name }}/
|
||||||
</button>
|
</button>
|
||||||
<div class="file-tree-children" hidden></div>
|
<div class="file-tree-children" hidden></div>
|
||||||
|
|
@ -14,7 +14,11 @@
|
||||||
<span>{{ entry.name }}</span>
|
<span>{{ entry.name }}</span>
|
||||||
<span class="file-tree-badge file-tree-badge-warn">broken link</span>
|
<span class="file-tree-badge file-tree-badge-warn">broken link</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/overlays/{{ overlay.id }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a>
|
{% if download_supported %}
|
||||||
|
<a href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span>{{ entry.name }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
|
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
|
||||||
<span class="muted">{{ entry.size_human }}</span>
|
<span class="muted">{{ entry.size_human }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@
|
||||||
{% set entries = file_tree_root_entries %}
|
{% set entries = file_tree_root_entries %}
|
||||||
{% 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 = "/overlays/" ~ overlay.id %}
|
||||||
|
{% set download_supported = True %}
|
||||||
{% include "_overlay_file_tree.html" %}
|
{% include "_overlay_file_tree.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,18 @@
|
||||||
|
|
||||||
<h2 class="section-title">Server Log</h2>
|
<h2 class="section-title">Server Log</h2>
|
||||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
|
|
||||||
|
<h2 class="section-title">Files</h2>
|
||||||
|
{% if not file_tree_root_entries %}
|
||||||
|
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
||||||
|
{% else %}
|
||||||
|
{% set entries = file_tree_root_entries %}
|
||||||
|
{% set truncated = file_tree_truncated %}
|
||||||
|
{% set truncated_count = file_tree_truncated_count %}
|
||||||
|
{% set files_base_url = "/servers/" ~ server.id %}
|
||||||
|
{% set download_supported = False %}
|
||||||
|
{% include "_overlay_file_tree.html" %}
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="page-footer-actions">
|
<div class="page-footer-actions">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import pytest
|
||||||
from l4d2web.app import create_app
|
from l4d2web.app import create_app
|
||||||
from l4d2web.auth import hash_password
|
from l4d2web.auth import hash_password
|
||||||
from l4d2web.db import init_db, session_scope
|
from l4d2web.db import init_db, session_scope
|
||||||
from l4d2web.models import Overlay, User
|
from l4d2web.models import Blueprint, Overlay, Server, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -383,3 +383,76 @@ def test_files_fragment_renders_broken_symlink_without_download_link(
|
||||||
assert (
|
assert (
|
||||||
f'href="/overlays/{overlay_id}/files/download?path=missing.vpk"' not in text
|
f'href="/overlays/{overlay_id}/files/download?path=missing.vpk"' not in text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_server(left4me_root: Path, *, user_id: int, port: int = 27015) -> int:
|
||||||
|
with session_scope() as s:
|
||||||
|
bp = Blueprint(user_id=user_id, name="bp", arguments="[]", config="[]")
|
||||||
|
s.add(bp)
|
||||||
|
s.flush()
|
||||||
|
server = Server(user_id=user_id, blueprint_id=bp.id, name="alpha", port=port)
|
||||||
|
s.add(server)
|
||||||
|
s.flush()
|
||||||
|
return server.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_files_fragment_lists_root(app, left4me_root: Path) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
server_id = _make_server(left4me_root, user_id=user_id)
|
||||||
|
merged = left4me_root / "runtime" / str(server_id) / "merged"
|
||||||
|
(merged / "left4dead2").mkdir(parents=True)
|
||||||
|
(merged / "srcds_run").write_text("#!/bin/sh\n")
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
response = client.get(f"/servers/{server_id}/files")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_files_fragment_returns_404_when_merged_missing(app, left4me_root: Path) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
server_id = _make_server(left4me_root, user_id=user_id, port=27020)
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
response = client.get(f"/servers/{server_id}/files")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_files_fragment_returns_404_for_unknown_server(app) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
response = client.get("/servers/9999/files")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_files_fragment_returns_400_on_dotdot(app, left4me_root: Path) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
server_id = _make_server(left4me_root, user_id=user_id, port=27030)
|
||||||
|
(left4me_root / "runtime" / str(server_id) / "merged").mkdir(parents=True)
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
response = client.get(f"/servers/{server_id}/files?path=../../etc")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_detail_renders_files_section(app, left4me_root: Path) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
server_id = _make_server(left4me_root, user_id=user_id, port=27040)
|
||||||
|
merged = left4me_root / "runtime" / str(server_id) / "merged"
|
||||||
|
(merged / "left4dead2").mkdir(parents=True)
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
response = client.get(f"/servers/{server_id}")
|
||||||
|
text = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert ">Files<" in text
|
||||||
|
assert "left4dead2" in text
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue