left4me/l4d2web/routes/files_routes.py
mwiegand a11d030edd
feat(l4d2-web): overlay detail Files section with HTMX file tree + downloads
Adds a server-rendered collapsible file tree section to the overlay
detail page so users can verify what their script/workshop overlays
produced and pull individual artifacts (VPKs, configs) without SSH.
HTMX-driven lazy folder expansion with click-to-download via send_file;
symlinks land anywhere under LEFT4ME_ROOT (so workshop addons stream
from the shared cache) but escapes are refused. Same access rule as the
rest of the page (admin or owner). 39 new tests; full web suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:16:25 +02:00

102 lines
3.1 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
from l4d2web.services.overlay_files import (
list_directory,
safe_resolve_for_download,
safe_resolve_for_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,
)
@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)
)