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.db import session_scope
|
||||
from l4d2web.models import Overlay
|
||||
from l4d2web.models import Overlay, Server
|
||||
from l4d2web.services.overlay_files import (
|
||||
list_directory,
|
||||
safe_resolve_for_download,
|
||||
safe_resolve_for_listing,
|
||||
safe_resolve_for_server_listing,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -72,6 +73,8 @@ def overlay_files_fragment(overlay_id: int):
|
|||
entries=entries,
|
||||
truncated=truncated_count > 0,
|
||||
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(
|
||||
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 (
|
||||
list_directory,
|
||||
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)
|
||||
|
||||
connect_host = request.host.split(":")[0]
|
||||
file_tree_root_entries, file_tree_truncated_count = _root_server_file_tree(server_id)
|
||||
|
||||
return render_template(
|
||||
"server_detail.html",
|
||||
server=server,
|
||||
blueprint=blueprint,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
@require_login
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<button type="button"
|
||||
class="file-tree-toggle"
|
||||
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 }}/
|
||||
</button>
|
||||
<div class="file-tree-children" hidden></div>
|
||||
|
|
@ -14,7 +14,11 @@
|
|||
<span>{{ entry.name }}</span>
|
||||
<span class="file-tree-badge file-tree-badge-warn">broken link</span>
|
||||
{% 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 %}
|
||||
<span class="muted">{{ entry.size_human }}</span>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
{% set entries = file_tree_root_entries %}
|
||||
{% set truncated = file_tree_truncated %}
|
||||
{% set truncated_count = file_tree_truncated_count %}
|
||||
{% set files_base_url = "/overlays/" ~ overlay.id %}
|
||||
{% set download_supported = True %}
|
||||
{% include "_overlay_file_tree.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,18 @@
|
|||
|
||||
<h2 class="section-title">Server Log</h2>
|
||||
<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>
|
||||
|
||||
<div class="page-footer-actions">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import pytest
|
|||
from l4d2web.app import create_app
|
||||
from l4d2web.auth import hash_password
|
||||
from l4d2web.db import init_db, session_scope
|
||||
from l4d2web.models import Overlay, User
|
||||
from l4d2web.models import Blueprint, Overlay, Server, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -383,3 +383,76 @@ def test_files_fragment_renders_broken_symlink_without_download_link(
|
|||
assert (
|
||||
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