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:
mwiegand 2026-05-09 01:35:09 +02:00
parent fa686f11e3
commit ed12280cf0
No known key found for this signature in database
7 changed files with 174 additions and 4 deletions

View file

@ -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,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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