left4me/l4d2web/routes/files_routes.py
mwiegand ed12280cf0
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>
2026-05-09 01:35:09 +02:00

143 lines
4.5 KiB
Python

"""Routes for the overlay 'Files' section.
Two GETs, both gated to the overlay's owner or any admin (mirrors the
overlay detail page rule):
- `GET /overlays/<id>/files?path=<rel>` — HTML fragment listing one
directory level. Used both for the initial server-rendered root and
for HTMX swaps when a folder expands.
- `GET /overlays/<id>/files/download?path=<rel>` — streams a single file.
Symlinks resolving anywhere under `LEFT4ME_ROOT` are allowed (so
workshop addons stream from the shared cache); anything escaping it
is refused.
"""
from __future__ import annotations
import os
from flask import Blueprint, Response, render_template, request, send_file
from sqlalchemy import select
from l4d2web.auth import current_user, require_login
from l4d2web.db import session_scope
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,
)
bp = Blueprint("files", __name__)
def _load_overlay_for_user(overlay_id: int, user) -> Overlay | Response:
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
if not user.admin and overlay.user_id is not None and overlay.user_id != user.id:
return Response(status=403)
# Detach by expunging — caller only reads scalar columns we already
# populated, so lazy loads aren't a concern.
db.expunge(overlay)
return overlay
@bp.get("/overlays/<int:overlay_id>/files")
@require_login
def overlay_files_fragment(overlay_id: int):
user = current_user()
assert user is not None
sub_path = request.args.get("path", "")
result = _load_overlay_for_user(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
target = safe_resolve_for_listing(overlay.path, sub_path)
overlay_root = safe_resolve_for_listing(overlay.path, "")
except ValueError:
return Response("invalid path", status=400)
if not target.is_dir():
return Response(status=404)
entries, truncated_count = list_directory(target, overlay_root)
return render_template(
"_overlay_file_tree.html",
overlay=overlay,
entries=entries,
truncated=truncated_count > 0,
truncated_count=truncated_count,
files_base_url=f"/overlays/{overlay_id}",
download_supported=True,
)
@bp.get("/overlays/<int:overlay_id>/files/download")
@require_login
def overlay_files_download(overlay_id: int):
user = current_user()
assert user is not None
sub_path = request.args.get("path", "")
result = _load_overlay_for_user(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
real = safe_resolve_for_download(overlay.path, sub_path)
except ValueError:
return Response("invalid path", status=400)
if 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)
)
@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,
)