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>
102 lines
3.1 KiB
Python
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)
|
|
)
|