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>
This commit is contained in:
mwiegand 2026-05-08 20:16:25 +02:00
parent 76bd6e8d4d
commit a11d030edd
No known key found for this signature in database
12 changed files with 1022 additions and 0 deletions

View file

@ -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)

View file

@ -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/<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)
)

View file

@ -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:

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}
});
})();

View file

@ -0,0 +1,25 @@
{% if entry.kind == 'dir' %}
<li class="file-tree-row file-tree-row-dir">
<button type="button"
class="file-tree-toggle"
aria-expanded="false"
hx-get="/overlays/{{ overlay.id }}/files?path={{ entry.rel|urlencode }}"
hx-target="next .file-tree-children"
hx-swap="innerHTML"
hx-trigger="click once">
<span class="chevron" aria-hidden="true"></span>{{ entry.name }}/
</button>
<div class="file-tree-children" hidden></div>
</li>
{% else %}
<li class="file-tree-row file-tree-row-file">
{% if entry.broken %}
<span>{{ entry.name }}</span>
<span class="file-tree-badge file-tree-badge-warn">broken link</span>
{% else %}
<a href="/overlays/{{ overlay.id }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a>
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
<span class="muted">{{ entry.size_human }}</span>
{% endif %}
</li>
{% endif %}

View file

@ -0,0 +1,8 @@
<ul class="file-tree" role="group">
{% for entry in entries %}{% include "_overlay_file_node.html" %}{% endfor %}
{% if truncated %}
<li class="file-tree-row file-tree-row-truncated muted">
+ {{ truncated_count }} more (truncated)
</li>
{% endif %}
</ul>

View file

@ -40,5 +40,6 @@
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
</body>
</html>

View file

@ -129,6 +129,18 @@
</section>
{% endif %}
<section class="panel">
<h2>Files</h2>
{% if file_tree_root_entries is none %}
<p class="muted">No files yet — build this overlay to populate it.</p>
{% 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 %}
</section>
<section class="panel">
<h2>Used by</h2>
{% if using_blueprints %}

View file

@ -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"

View file

@ -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 "<html" not in text
def test_files_fragment_returns_400_on_dotdot(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")
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
)