diff --git a/l4d2web/app.py b/l4d2web/app.py index 8ffd67a..fa9e172 100644 --- a/l4d2web/app.py +++ b/l4d2web/app.py @@ -11,6 +11,7 @@ from l4d2web.db import init_db from l4d2web.routes.blueprint_routes import bp as blueprint_bp from l4d2web.routes.auth_routes import bp as auth_bp from l4d2web.routes.auth_routes import reset_login_rate_limits +from l4d2web.routes.files_routes import bp as files_bp from l4d2web.routes.job_routes import bp as job_bp from l4d2web.routes.log_routes import bp as log_bp from l4d2web.routes.overlay_routes import bp as overlay_bp @@ -70,6 +71,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: app.before_request(load_current_user) app.register_blueprint(auth_bp) app.register_blueprint(overlay_bp) + app.register_blueprint(files_bp) app.register_blueprint(workshop_bp) app.register_blueprint(blueprint_bp) app.register_blueprint(server_bp) diff --git a/l4d2web/routes/files_routes.py b/l4d2web/routes/files_routes.py new file mode 100644 index 0000000..a234c5f --- /dev/null +++ b/l4d2web/routes/files_routes.py @@ -0,0 +1,102 @@ +"""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//files?path=` — HTML fragment listing one + directory level. Used both for the initial server-rendered root and + for HTMX swaps when a folder expands. +- `GET /overlays//files/download?path=` — 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//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//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) + ) diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index d796525..9051dee 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -15,6 +15,10 @@ from l4d2web.models import ( User, WorkshopItem, ) +from l4d2web.services.overlay_files import ( + list_directory, + safe_resolve_for_listing, +) bp = Blueprint("pages", __name__) @@ -219,15 +223,38 @@ def overlay_detail(overlay_id: int): .order_by(Job.created_at.desc()) .limit(1) ) + + file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay) + return render_template( "overlay_detail.html", overlay=overlay, using_blueprints=using_blueprints, workshop_items=workshop_items, latest_build_job=latest_build_job, + 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, ) +def _root_file_tree(overlay: Overlay) -> tuple[list[dict] | None, int]: + """Return (entries, truncated_count) for the overlay's runtime directory, + or (None, 0) if the directory doesn't exist or the path is unresolvable + (e.g. legacy absolute `overlay.path` values that pre-date the current + `path == str(id)` convention).""" + try: + overlay_root = safe_resolve_for_listing(overlay.path, "") + except ValueError: + return None, 0 + if not overlay_root.is_dir(): + return None, 0 + entries, truncated_count = list_directory(overlay_root, overlay_root) + return entries, truncated_count + + @bp.get("/blueprints") @require_login def blueprints_page() -> str: diff --git a/l4d2web/services/overlay_files.py b/l4d2web/services/overlay_files.py new file mode 100644 index 0000000..bf75fc1 --- /dev/null +++ b/l4d2web/services/overlay_files.py @@ -0,0 +1,131 @@ +"""Filesystem helpers backing the overlay 'Files' section. + +`safe_resolve_for_listing` and `safe_resolve_for_download` translate a +user-supplied sub-path within an overlay into a real filesystem path, +rejecting anything that would escape the overlay root (listing) or +`LEFT4ME_ROOT` (download). `list_directory` walks one level and shapes +the rows the templates render. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from l4d2host.paths import get_left4me_root, overlay_path as resolve_overlay_root + +from l4d2web.services.security import validate_overlay_ref + + +def _is_under(child: Path, parent: Path) -> bool: + return child == parent or parent in child.parents + + +def safe_resolve_for_listing(overlay_path_value: str, sub_path: str) -> Path: + """Resolve `overlay_root / sub_path` and refuse anything that escapes the + overlay root after symlink resolution. Used to render directory listings.""" + overlay_root = resolve_overlay_root(overlay_path_value).resolve() + if sub_path == "": + return overlay_root + validate_overlay_ref(sub_path) + candidate = (overlay_root / sub_path).resolve(strict=False) + if not _is_under(candidate, overlay_root): + raise ValueError("path escapes overlay root") + return candidate + + +DEFAULT_MAX_ENTRIES = 500 + + +def list_directory( + target: Path, + overlay_root: Path, + *, + max_entries: int | None = None, +) -> tuple[list[dict], int]: + """List one directory level. Returns (entries, truncated_count) where + truncated_count is the number of entries elided after the cap. + Directories come first, then files; both case-insensitive alphabetical. + + `max_entries` falls back to module-level `DEFAULT_MAX_ENTRIES` at call + time so tests can monkeypatch the cap without re-instantiating routes.""" + cap = max_entries if max_entries is not None else DEFAULT_MAX_ENTRIES + + rows: list[dict] = [] + with os.scandir(target) as it: + for entry in it: + rows.append(_entry_dict(entry, overlay_root)) + + rows.sort(key=lambda r: (0 if r["kind"] == "dir" else 1, r["name"].casefold())) + + if len(rows) > cap: + truncated = len(rows) - cap + rows = rows[:cap] + else: + truncated = 0 + return rows, truncated + + +def _entry_dict(entry: "os.DirEntry[str]", overlay_root: Path) -> dict: + is_symlink = entry.is_symlink() + broken = False + try: + is_dir = entry.is_dir(follow_symlinks=True) + except OSError: + is_dir = False + broken = True + + if is_dir: + kind = "dir" + size: int | None = None + else: + kind = "file" + if broken: + size = None + else: + try: + size = entry.stat(follow_symlinks=True).st_size + except OSError: + size = None + broken = True + + rel_str = "/".join(Path(entry.path).relative_to(overlay_root).parts) + + return { + "name": entry.name, + "rel": rel_str, + "kind": kind, + "is_symlink": is_symlink, + "broken": broken, + "size": size, + "size_human": _format_size(size) if size is not None else "", + } + + +def _format_size(num: int) -> str: + if num < 1024: + return f"{num} B" + units = ["KB", "MB", "GB", "TB"] + value = float(num) + unit = "B" + for u in units: + value /= 1024.0 + unit = u + if value < 1024: + break + return f"{value:.1f} {unit}" + + +def safe_resolve_for_download(overlay_path_value: str, sub_path: str) -> Path: + """Resolve a file path the user wants to download. Allows symlink targets + anywhere inside `LEFT4ME_ROOT` (so workshop addons stream from the shared + cache) but blocks any escape outside it.""" + if sub_path == "": + raise ValueError("download requires a file path") + validate_overlay_ref(sub_path) + overlay_root = resolve_overlay_root(overlay_path_value).resolve() + candidate = overlay_root / sub_path + real = Path(os.path.realpath(candidate)) + left4me_root = get_left4me_root().resolve() + if not _is_under(real, left4me_root): + raise ValueError("path escapes LEFT4ME_ROOT") + return real diff --git a/l4d2web/static/css/components.css b/l4d2web/static/css/components.css index 3c29cee..bf9bf93 100644 --- a/l4d2web/static/css/components.css +++ b/l4d2web/static/css/components.css @@ -188,3 +188,68 @@ dialog.modal::backdrop { display: grid; gap: var(--space-s); } + +.file-tree { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: var(--space-xs); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.875rem; +} + +.file-tree .file-tree { + padding-left: var(--space-l); + margin-top: var(--space-xs); +} + +.file-tree-row { + display: flex; + gap: var(--space-s); + align-items: baseline; + flex-wrap: wrap; +} + +.file-tree-toggle { + background: none; + border: 0; + color: inherit; + cursor: pointer; + padding: 0; + font: inherit; + text-align: left; +} + +.file-tree-toggle .chevron { + display: inline-block; + transition: transform 120ms ease; + margin-right: var(--space-xs); +} + +.file-tree-toggle[aria-expanded="true"] .chevron { + transform: rotate(90deg); +} + +.file-tree-children[hidden] { + display: none; +} + +.file-tree-badge { + font-size: 0.75rem; + padding: 0 0.4em; + border-radius: var(--radius-s); + background: var(--color-surface-muted); + color: var(--color-muted); + border: var(--line); +} + +.file-tree-badge-warn { + background: transparent; + color: var(--color-danger); + border-color: var(--color-danger); +} + +.file-tree-row-truncated { + font-style: italic; +} diff --git a/l4d2web/static/js/file-tree.js b/l4d2web/static/js/file-tree.js new file mode 100644 index 0000000..ecd6463 --- /dev/null +++ b/l4d2web/static/js/file-tree.js @@ -0,0 +1,15 @@ +// Toggle expand/collapse for file-tree folder rows. HTMX handles the +// initial fetch (hx-trigger="click once"); this script handles every +// subsequent click without re-fetching. +(function () { + document.addEventListener("click", function (event) { + const button = event.target.closest(".file-tree-toggle"); + if (!button) return; + const expanded = button.getAttribute("aria-expanded") === "true"; + button.setAttribute("aria-expanded", expanded ? "false" : "true"); + const children = button.nextElementSibling; + if (children && children.classList.contains("file-tree-children")) { + children.hidden = expanded; + } + }); +})(); diff --git a/l4d2web/templates/_overlay_file_node.html b/l4d2web/templates/_overlay_file_node.html new file mode 100644 index 0000000..41ae652 --- /dev/null +++ b/l4d2web/templates/_overlay_file_node.html @@ -0,0 +1,25 @@ +{% if entry.kind == 'dir' %} +
  • + + +
  • +{% else %} +
  • + {% if entry.broken %} + {{ entry.name }} + broken link + {% else %} + {{ entry.name }} + {% if entry.is_symlink %}link{% endif %} + {{ entry.size_human }} + {% endif %} +
  • +{% endif %} diff --git a/l4d2web/templates/_overlay_file_tree.html b/l4d2web/templates/_overlay_file_tree.html new file mode 100644 index 0000000..2ae7795 --- /dev/null +++ b/l4d2web/templates/_overlay_file_tree.html @@ -0,0 +1,8 @@ +
      + {% for entry in entries %}{% include "_overlay_file_node.html" %}{% endfor %} + {% if truncated %} +
    • + + {{ truncated_count }} more (truncated) +
    • + {% endif %} +
    diff --git a/l4d2web/templates/base.html b/l4d2web/templates/base.html index 6f9e015..49e438f 100644 --- a/l4d2web/templates/base.html +++ b/l4d2web/templates/base.html @@ -40,5 +40,6 @@ + diff --git a/l4d2web/templates/overlay_detail.html b/l4d2web/templates/overlay_detail.html index 460fed2..8aa3984 100644 --- a/l4d2web/templates/overlay_detail.html +++ b/l4d2web/templates/overlay_detail.html @@ -129,6 +129,18 @@ {% endif %} +
    +

    Files

    + {% if file_tree_root_entries is none %} +

    No files yet — build this overlay to populate it.

    + {% else %} + {% set entries = file_tree_root_entries %} + {% set truncated = file_tree_truncated %} + {% set truncated_count = file_tree_truncated_count %} + {% include "_overlay_file_tree.html" %} + {% endif %} +
    +

    Used by

    {% if using_blueprints %} diff --git a/l4d2web/tests/test_overlay_files.py b/l4d2web/tests/test_overlay_files.py new file mode 100644 index 0000000..e02e216 --- /dev/null +++ b/l4d2web/tests/test_overlay_files.py @@ -0,0 +1,267 @@ +"""Pure-helper tests for l4d2web.services.overlay_files. + +Covers path safety (listing vs download), directory listing semantics, +symlink handling, and the children cap. Flask-free; only exercises the +filesystem. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + + +@pytest.fixture +def overlay_root(monkeypatch, tmp_path: Path) -> Path: + """LEFT4ME_ROOT/overlays/7/ ready for tests to populate. Returns the overlay root.""" + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + root = tmp_path / "overlays" / "7" + root.mkdir(parents=True) + return root + + +def test_safe_resolve_for_listing_returns_overlay_root_when_sub_path_empty( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_listing + + resolved = safe_resolve_for_listing("7", "") + + assert resolved == overlay_root.resolve() + + +def test_safe_resolve_for_listing_joins_sub_path_under_overlay_root( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_listing + + (overlay_root / "left4dead2" / "addons").mkdir(parents=True) + + resolved = safe_resolve_for_listing("7", "left4dead2/addons") + + assert resolved == (overlay_root / "left4dead2" / "addons").resolve() + + +def test_safe_resolve_for_listing_rejects_dotdot(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_listing + + with pytest.raises(ValueError): + safe_resolve_for_listing("7", "../../etc") + + +def test_safe_resolve_for_listing_rejects_absolute_path(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_listing + + with pytest.raises(ValueError): + safe_resolve_for_listing("7", "/etc/passwd") + + +def test_safe_resolve_for_listing_rejects_empty_component(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_listing + + with pytest.raises(ValueError): + safe_resolve_for_listing("7", "foo//bar") + + +def test_safe_resolve_for_listing_rejects_symlink_escaping_overlay_root( + overlay_root: Path, tmp_path: Path +) -> None: + """A directory inside the overlay that is itself a symlink to somewhere + outside the overlay must be refused — even if the target sits within + LEFT4ME_ROOT/workshop_cache.""" + from l4d2web.services.overlay_files import safe_resolve_for_listing + + cache = tmp_path / "workshop_cache" / "shared" + cache.mkdir(parents=True) + (overlay_root / "shared").symlink_to(cache) + + with pytest.raises(ValueError): + safe_resolve_for_listing("7", "shared") + + +def test_safe_resolve_for_download_rejects_empty_path(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_download + + with pytest.raises(ValueError): + safe_resolve_for_download("7", "") + + +def test_safe_resolve_for_download_returns_real_path_for_regular_file( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_download + + target = overlay_root / "cfg" / "server.cfg" + target.parent.mkdir() + target.write_text("hostname test\n") + + resolved = safe_resolve_for_download("7", "cfg/server.cfg") + + assert resolved == target.resolve() + + +def test_safe_resolve_for_download_follows_symlink_into_workshop_cache( + overlay_root: Path, tmp_path: Path +) -> None: + """Workshop overlays populate addons/*.vpk as symlinks into + LEFT4ME_ROOT/workshop_cache/. Download must follow the symlink and + return the cache target.""" + from l4d2web.services.overlay_files import safe_resolve_for_download + + cache_file = tmp_path / "workshop_cache" / "12345.vpk" + cache_file.parent.mkdir(parents=True) + cache_file.write_bytes(b"vpk-bytes") + + addons = overlay_root / "left4dead2" / "addons" + addons.mkdir(parents=True) + (addons / "deathcraft.vpk").symlink_to(cache_file) + + resolved = safe_resolve_for_download("7", "left4dead2/addons/deathcraft.vpk") + + assert resolved == cache_file.resolve() + + +def test_safe_resolve_for_download_rejects_symlink_outside_left4me_root( + overlay_root: Path, tmp_path: Path +) -> None: + """A malicious script overlay that plants a symlink at evil → /etc/passwd + must be refused.""" + from l4d2web.services.overlay_files import safe_resolve_for_download + + outside = tmp_path.parent / "outside-left4me-root.txt" + outside.write_text("nope") + (overlay_root / "evil").symlink_to(outside) + + with pytest.raises(ValueError): + safe_resolve_for_download("7", "evil") + + +def test_safe_resolve_for_download_rejects_dotdot(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_download + + with pytest.raises(ValueError): + safe_resolve_for_download("7", "../../etc/passwd") + + +def test_safe_resolve_for_download_rejects_absolute_path(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_download + + with pytest.raises(ValueError): + safe_resolve_for_download("7", "/etc/passwd") + + +def test_list_directory_returns_empty_for_empty_dir(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + entries, truncated = list_directory(overlay_root, overlay_root) + + assert entries == [] + assert truncated == 0 + + +def test_list_directory_returns_dirs_before_files_alphabetically( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import list_directory + + (overlay_root / "Zeta").mkdir() + (overlay_root / "alpha").mkdir() + (overlay_root / "Beta-file.txt").write_text("b") + (overlay_root / "alpha-file.txt").write_text("a") + + entries, _ = list_directory(overlay_root, overlay_root) + names = [e["name"] for e in entries] + + # Dirs first (case-insensitive alpha), then files (case-insensitive alpha). + assert names == ["alpha", "Zeta", "alpha-file.txt", "Beta-file.txt"] + + +def test_list_directory_marks_kind_dir_or_file(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + (overlay_root / "subdir").mkdir() + (overlay_root / "leaf.txt").write_text("hi") + + entries, _ = list_directory(overlay_root, overlay_root) + by_name = {e["name"]: e for e in entries} + + assert by_name["subdir"]["kind"] == "dir" + assert by_name["leaf.txt"]["kind"] == "file" + + +def test_list_directory_includes_relative_path_under_overlay_root( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import list_directory + + addons = overlay_root / "left4dead2" / "addons" + addons.mkdir(parents=True) + (addons / "foo.vpk").write_text("v") + + entries, _ = list_directory(addons, overlay_root) + + assert entries[0]["rel"] == "left4dead2/addons/foo.vpk" + + +def test_list_directory_marks_symlinks_with_resolved_size( + overlay_root: Path, tmp_path: Path +) -> None: + from l4d2web.services.overlay_files import list_directory + + cache_file = tmp_path / "workshop_cache" / "12345.vpk" + cache_file.parent.mkdir(parents=True) + cache_file.write_bytes(b"x" * 4096) + + addons = overlay_root / "left4dead2" / "addons" + addons.mkdir(parents=True) + (addons / "deathcraft.vpk").symlink_to(cache_file) + + entries, _ = list_directory(addons, overlay_root) + entry = entries[0] + + assert entry["is_symlink"] is True + assert entry["broken"] is False + assert entry["size"] == 4096 + assert entry["kind"] == "file" + + +def test_list_directory_marks_broken_symlinks(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + (overlay_root / "missing.vpk").symlink_to(overlay_root / "does-not-exist.vpk") + + entries, _ = list_directory(overlay_root, overlay_root) + entry = entries[0] + + assert entry["is_symlink"] is True + assert entry["broken"] is True + assert entry["size"] is None + assert entry["kind"] == "file" + + +def test_list_directory_truncates_at_cap(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + for i in range(7): + (overlay_root / f"f{i:02d}.txt").write_text("x") + + entries, truncated = list_directory(overlay_root, overlay_root, max_entries=5) + + assert len(entries) == 5 + assert truncated == 2 + assert [e["name"] for e in entries] == [f"f{i:02d}.txt" for i in range(5)] + + +def test_list_directory_includes_size_human_for_files(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + (overlay_root / "tiny.txt").write_text("hello") + (overlay_root / "big.bin").write_bytes(b"x" * (3 * 1024 * 1024)) + + entries, _ = list_directory(overlay_root, overlay_root) + by_name = {e["name"]: e for e in entries} + + # Files only — directories don't have size_human. + assert by_name["tiny.txt"]["size_human"] == "5 B" + assert by_name["big.bin"]["size_human"] == "3.0 MB" diff --git a/l4d2web/tests/test_overlay_files_routes.py b/l4d2web/tests/test_overlay_files_routes.py new file mode 100644 index 0000000..d3181b6 --- /dev/null +++ b/l4d2web/tests/test_overlay_files_routes.py @@ -0,0 +1,367 @@ +"""HTTP-level tests for the overlay 'Files' tree-fragment + download routes.""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from l4d2web.app import create_app +from l4d2web.auth import hash_password +from l4d2web.db import init_db, session_scope +from l4d2web.models import Overlay, User + + +@pytest.fixture +def app(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'files-routes.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + flask_app = create_app( + {"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"} + ) + init_db() + return flask_app + + +@pytest.fixture +def left4me_root(tmp_path) -> Path: + return tmp_path + + +def _client_for(app, user_id: int): + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user_id + sess["csrf_token"] = "test-token" + return client + + +def _make_user(*, username: str = "alice", admin: bool = False) -> int: + with session_scope() as s: + user = User( + username=username, password_digest=hash_password("x"), admin=admin + ) + s.add(user) + s.flush() + return user.id + + +def _make_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int: + """Create an Overlay row + the matching `LEFT4ME_ROOT/overlays/{id}/` + directory, mirroring what `overlay_creation.create_overlay_directory` + would do in production.""" + with session_scope() as s: + overlay = Overlay(name=name, path="", type="script", user_id=user_id) + s.add(overlay) + s.flush() + overlay.path = str(overlay.id) + overlay_id = overlay.id + (left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True) + return overlay_id + + +def test_files_fragment_lists_root_directory(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "left4dead2").mkdir() + (overlay_dir / "readme.txt").write_text("hi") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files") + + assert response.status_code == 200 + text = response.get_data(as_text=True) + assert "left4dead2" in text + assert "readme.txt" in text + + +def test_files_fragment_lists_subdirectory(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + addons = overlay_dir / "left4dead2" / "addons" + addons.mkdir(parents=True) + (addons / "deathcraft.vpk").write_text("vpk") + + client = _client_for(app, user_id) + response = client.get( + f"/overlays/{overlay_id}/files?path=left4dead2/addons" + ) + + assert response.status_code == 200 + text = response.get_data(as_text=True) + assert "deathcraft.vpk" in text + # Fragment, no full document. + assert " None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files?path=../../etc") + + assert response.status_code == 400 + + +def test_files_fragment_returns_400_on_absolute_path(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files?path=/etc/passwd") + + assert response.status_code == 400 + + +def test_files_fragment_returns_404_for_unknown_overlay(app) -> None: + user_id = _make_user() + client = _client_for(app, user_id) + response = client.get("/overlays/9999/files") + + assert response.status_code == 404 + + +def test_files_fragment_returns_404_for_missing_subdir(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files?path=ghost") + + assert response.status_code == 404 + + +def test_files_fragment_returns_403_for_other_users_overlay( + app, left4me_root: Path +) -> None: + owner_id = _make_user(username="owner") + other_id = _make_user(username="other") + overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private") + + client = _client_for(app, other_id) + response = client.get(f"/overlays/{overlay_id}/files") + + assert response.status_code == 403 + + +def test_admin_can_view_files_for_other_users_overlay( + app, left4me_root: Path +) -> None: + owner_id = _make_user(username="owner") + admin_id = _make_user(username="admin", admin=True) + overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "secret.cfg").write_text("k=v") + + client = _client_for(app, admin_id) + response = client.get(f"/overlays/{overlay_id}/files") + + assert response.status_code == 200 + assert "secret.cfg" in response.get_data(as_text=True) + + +def test_download_streams_regular_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + cfg = overlay_dir / "cfg" / "server.cfg" + cfg.parent.mkdir() + cfg.write_bytes(b"hostname test") + + client = _client_for(app, user_id) + response = client.get( + f"/overlays/{overlay_id}/files/download?path=cfg/server.cfg" + ) + + assert response.status_code == 200 + assert response.headers["Content-Disposition"].startswith("attachment") + assert "filename=server.cfg" in response.headers["Content-Disposition"] + assert response.get_data() == b"hostname test" + + +def test_download_follows_workshop_cache_symlink( + app, left4me_root: Path +) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + + cache_file = left4me_root / "workshop_cache" / "12345.vpk" + cache_file.parent.mkdir(parents=True) + cache_file.write_bytes(b"vpk-content") + + addons = overlay_dir / "left4dead2" / "addons" + addons.mkdir(parents=True) + (addons / "deathcraft.vpk").symlink_to(cache_file) + + client = _client_for(app, user_id) + response = client.get( + f"/overlays/{overlay_id}/files/download?path=left4dead2/addons/deathcraft.vpk" + ) + + assert response.status_code == 200 + assert response.get_data() == b"vpk-content" + + +def test_download_rejects_symlink_outside_left4me_root( + app, left4me_root: Path, tmp_path_factory +) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + + outside = tmp_path_factory.mktemp("outside") / "secret.txt" + outside.write_text("nope") + (overlay_dir / "evil").symlink_to(outside) + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files/download?path=evil") + + assert response.status_code == 400 + + +def test_download_rejects_directory_target(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "left4dead2").mkdir() + + client = _client_for(app, user_id) + response = client.get( + f"/overlays/{overlay_id}/files/download?path=left4dead2" + ) + + assert response.status_code == 400 + + +def test_download_returns_404_for_missing_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + + client = _client_for(app, user_id) + response = client.get( + f"/overlays/{overlay_id}/files/download?path=ghost.txt" + ) + + assert response.status_code == 404 + + +def test_download_returns_403_for_other_users_overlay( + app, left4me_root: Path +) -> None: + owner_id = _make_user(username="owner") + other_id = _make_user(username="other") + overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "secret.cfg").write_text("nope") + + client = _client_for(app, other_id) + response = client.get( + f"/overlays/{overlay_id}/files/download?path=secret.cfg" + ) + + assert response.status_code == 403 + + +def test_files_fragment_truncates_at_cap(app, left4me_root: Path, monkeypatch) -> None: + """The cap default is 500 — exercise it via the public route by lowering + it for this test through the helper module.""" + from l4d2web.services import overlay_files as overlay_files_module + + monkeypatch.setattr(overlay_files_module, "DEFAULT_MAX_ENTRIES", 5) + + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + for i in range(8): + (overlay_dir / f"f{i}.txt").write_text("x") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "+ 3 more" in text + + +def test_overlay_detail_renders_files_section_with_tree( + app, left4me_root: Path +) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "left4dead2").mkdir() + (overlay_dir / "readme.txt").write_text("hi") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Files" in text + assert "left4dead2" in text + assert "readme.txt" in text + + +def test_overlay_detail_shows_empty_state_when_overlay_dir_missing( + app, left4me_root: Path +) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + # Wipe the directory created by _make_overlay so the on-disk dir is gone. + overlay_dir = left4me_root / "overlays" / str(overlay_id) + overlay_dir.rmdir() + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "No files yet" in text + + +def test_overlay_detail_files_section_present_for_workshop_overlays( + app, left4me_root: Path +) -> None: + user_id = _make_user() + # Create a workshop overlay manually since _make_overlay defaults to script. + with session_scope() as s: + overlay = Overlay(name="ws", path="", type="workshop", user_id=user_id) + s.add(overlay) + s.flush() + overlay.path = str(overlay.id) + overlay_id = overlay.id + (left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True) + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + # Section heading present even when the overlay dir is empty. + assert "Files" in text + + +def test_files_fragment_renders_broken_symlink_without_download_link( + app, left4me_root: Path +) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "missing.vpk").symlink_to(overlay_dir / "does-not-exist.vpk") + + client = _client_for(app, user_id) + response = client.get(f"/overlays/{overlay_id}/files") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "missing.vpk" in text + assert "broken" in text + # No download link for broken symlinks. + assert ( + f'href="/overlays/{overlay_id}/files/download?path=missing.vpk"' not in text + )