From ed12280cf0007f5bf1f7d51cf2ee601792dda0cf Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sat, 9 May 2026 01:35:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(l4d2-web):=20server=20detail=20=E2=80=94?= =?UTF-8?q?=20directory=20tree=20of=20the=20runtime=20merged=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Files section at the bottom of the server detail page that lists the kernel-overlayfs merged view at runtime//merged/. Reuses the overlay file-tree partial via two new template variables: - files_base_url: parent passes "/overlays/" or "/servers/" - 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//files?path= returns the lazy-load file-tree fragment, gated to the server owner. No download counterpart. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/routes/files_routes.py | 43 ++++++++++++- l4d2web/routes/page_routes.py | 20 ++++++ l4d2web/services/overlay_files.py | 18 ++++++ l4d2web/templates/_overlay_file_node.html | 8 ++- l4d2web/templates/overlay_detail.html | 2 + l4d2web/templates/server_detail.html | 12 ++++ l4d2web/tests/test_overlay_files_routes.py | 75 +++++++++++++++++++++- 7 files changed, 174 insertions(+), 4 deletions(-) diff --git a/l4d2web/routes/files_routes.py b/l4d2web/routes/files_routes.py index a234c5f..fae1cd2 100644 --- a/l4d2web/routes/files_routes.py +++ b/l4d2web/routes/files_routes.py @@ -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//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, + ) diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 783a38e..6ed1efc 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -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//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//actions") @require_login def server_actions_fragment(server_id: int): diff --git a/l4d2web/services/overlay_files.py b/l4d2web/services/overlay_files.py index bf75fc1..911f24b 100644 --- a/l4d2web/services/overlay_files.py +++ b/l4d2web/services/overlay_files.py @@ -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//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 diff --git a/l4d2web/templates/_overlay_file_node.html b/l4d2web/templates/_overlay_file_node.html index f1eee95..0f6df3a 100644 --- a/l4d2web/templates/_overlay_file_node.html +++ b/l4d2web/templates/_overlay_file_node.html @@ -3,7 +3,7 @@ @@ -14,7 +14,11 @@ {{ entry.name }} broken link {% else %} - {{ entry.name }} + {% if download_supported %} + {{ entry.name }} + {% else %} + {{ entry.name }} + {% endif %} {% if entry.is_symlink %}link{% endif %} {{ entry.size_human }} {% endif %} diff --git a/l4d2web/templates/overlay_detail.html b/l4d2web/templates/overlay_detail.html index 63cbba6..8a839a1 100644 --- a/l4d2web/templates/overlay_detail.html +++ b/l4d2web/templates/overlay_detail.html @@ -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 %} diff --git a/l4d2web/templates/server_detail.html b/l4d2web/templates/server_detail.html index 03d5cd6..d65934c 100644 --- a/l4d2web/templates/server_detail.html +++ b/l4d2web/templates/server_detail.html @@ -18,6 +18,18 @@

Server Log


+
+  

Files

+ {% if not file_tree_root_entries %} +

No files yet — start the server to mount its runtime.

+ {% 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 %}