Compare commits

..

11 commits

Author SHA1 Message Date
mwiegand
c2cf723911
docs(agents): require specs and plans to live in this repo
Make explicit that design specs go in docs/superpowers/specs/ and
implementation plans go in docs/superpowers/plans/, both committed
to git, with the YYYY-MM-DD-<topic>[-design].md naming already used
elsewhere in the tree. The plan-mode scratch file under
~/.claude/plans/ is fine while plan mode is open, but the persisted
artifact must end up inside the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:37:17 +02:00
mwiegand
a4e9f6cd26
feat(l4d2-web): blueprint overlay picker — drag-list + add-dropdown
Replace the per-row checkbox + numeric Order table on the blueprint
detail page with a drag-to-reorder list of selected overlays plus a
native <select> for adding more. Removing uses an × button per row;
the option sorted-inserts back into the dropdown alphabetically.

Native HTML5 drag-and-drop, no library, no JS-disabled fallback.
Server contract is unchanged: each list row owns one hidden
<input name="overlay_ids">, DOM order = submission order, and the
existing fallback_position branch in ordered_overlay_ids_from_form
absorbs the now-omitted overlay_position_<id> fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:37:11 +02:00
mwiegand
dec4fed809
docs(specs): blueprint overlay picker — drag-list + add-dropdown
Replace per-row checkbox + numeric Order inputs with a drag-to-reorder
list of selected overlays plus a native <select> for adding more.
Native HTML5 DnD; no library, no JS-disabled fallback. Server contract
unchanged (overlay_ids in DOM order; existing fallback_position branch
absorbs the omitted overlay_position_<id> fields).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:32:45 +02:00
mwiegand
01760a31f5
fix(l4d2-web): textareas — monospace font, consistent rows on blueprint forms
Bash script, Arguments and Config are all structured text — render them
in a monospace font with tab-size: 4 and resize: vertical via a base
'textarea' rule in components.css. Add rows="8" + spellcheck="false"
to the blueprint Arguments/Config textareas (both edit and create
forms) so they're a sensible size and consistent with each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:52:12 +02:00
mwiegand
7b31390b4c
fix(l4d2-web): file tree — uniform vertical spacing across all rows
The flex 'gap' shorthand on .file-tree-row was setting row-gap as well
as column-gap, so when the .file-tree-children div wrapped to a new
line the row-gap (--space-s) added on top of the nested ul's
margin-top (--space-xs) — making the button-to-first-child gap visibly
bigger than the sibling-row gap. Switch to 'gap: 0 var(--space-s)' so
only column-gap applies; vertical rhythm is now owned exclusively by
the outer grid gap (--space-xs) and the nested ul margin-top
(--space-xs), both equal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:49:05 +02:00
mwiegand
4619a91f45
fix(l4d2-web): file tree layout — wrap children to next line, align names
Two CSS fixes that together turn the rendered file tree from
'everything on one line' into an actual tree:

- .file-tree-children: flex-basis: 100% so an expanded folder's children
  wrap to the next line of the parent <li> flex container instead of
  flowing inline next to the toggle button.
- .file-tree-row-file: padding-left = chevron width, so file rows align
  visually with sibling folder names (folder names are offset by their
  chevron; files have no chevron, so without padding they'd start at
  the chevron column instead of the name column). Chevron itself
  pinned to width: 1ch so rotated/un-rotated states have identical
  layout.
2026-05-08 20:44:41 +02:00
mwiegand
caa8b83cf0
chore(deploy): rewrite web.env every deploy with machine-id-derived SECRET_KEY
Drops the 'only on first creation' guard so newly added env vars reach
existing boxes (today's SESSION_COOKIE_SECURE=false rake). SECRET_KEY
is now sha256(/etc/machine-id) — stable per host, no session
invalidation across redeploys, no state persisted in /etc that the
deploy has to tiptoe around. Single-operator test deployment; the
secret being machine-id-derivable is acceptable per deploy/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:39:02 +02:00
mwiegand
c958d0352a
fix(l4d2-web): show empty-state when overlay dir is empty, not just missing
Tickrate and other seeded examples whose overlay directory exists but
hasn't been built yet rendered a visually blank Files panel — entries
was [] (not None), so the template fell through to an empty <ul>. Use
'not file_tree_root_entries' so both None (dir missing) and []
(dir empty) trigger the 'No files yet' message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:32:09 +02:00
mwiegand
2ab54a3800
fix(l4d2-web): file tree fetches in plain JS — vendored htmx is a stub
The vendored static/vendor/htmx.min.js turned out to be a 33-byte
placeholder, so the hx-get/hx-target/hx-trigger attributes on the
overlay file tree's folder buttons were inert: clicks rotated the
chevron (own JS) but never fetched. Switch the lazy-load to a
~30-line plain-JS handler in static/js/file-tree.js that fetches
button.dataset.filesUrl on first expand and dedupes via dataset.loaded.
Update the spec/plan to match. Route + partial contracts unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:23:04 +02:00
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
mwiegand
76bd6e8d4d
docs(specs): overlay file tree — design + implementation plan
Captures the design rationale for the new overlay-detail Files section
(verify build output, click-to-download for individual files via Flask
send_file, HTMX-driven lazy folder expansion) and the paired
implementation plan that produced it. Adds .superpowers/ to .gitignore
so brainstorm session artifacts never sneak into a future commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:16:10 +02:00
23 changed files with 1813 additions and 30 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ __pycache__/
l4d2web.db*
# CocoIndex Code (ccc)
/.cocoindex_code/
.superpowers/

View file

@ -23,6 +23,13 @@ Do not invent architecture outside these plans unless explicitly requested.
- Do not use git worktrees.
- Local Python venv is direnv-managed via `.envrc` (Python 3.13). After fresh checkout: `direnv allow`, then `pip install -e ./l4d2host -e ./l4d2web pytest`. See README **Local development** for details.
### Planning artifacts
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
- Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned).
- Commit both to git as soon as the user approves them.
- Do not leave specs or plans outside this repo. The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable while plan mode is open; the persisted artifact must end up under `docs/superpowers/` and be committed.
### Naming and boundaries
- Use `l4d2` naming consistently.

View file

@ -154,17 +154,18 @@ $sudo_cmd install -m 0644 -o root -g root \
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
/etc/left4me/sandbox-resolv.conf
if [ ! -f /etc/left4me/web.env ]; then
secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
tmp_web_env="$remote_tmp/web.env"
{
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
printf 'SECRET_KEY=%s\n' "$secret_key"
printf 'JOB_WORKER_THREADS=4\n'
printf 'SESSION_COOKIE_SECURE=false\n'
} > "$tmp_web_env"
$sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env
fi
# Stomp the file every deploy so newly added vars reach existing boxes.
# SECRET_KEY is derived from /etc/machine-id so it stays stable across
# redeploys (no session invalidation) without persisting state in /etc.
secret_key=$(sha256sum < /etc/machine-id | awk '{print $1}')
tmp_web_env="$remote_tmp/web.env"
{
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
printf 'SECRET_KEY=%s\n' "$secret_key"
printf 'JOB_WORKER_THREADS=4\n'
printf 'SESSION_COOKIE_SECURE=false\n'
} > "$tmp_web_env"
$sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env
if [ ! -x /opt/left4me/.venv/bin/python ]; then
run_as_left4me python3 -m venv /opt/left4me/.venv

View file

@ -0,0 +1,161 @@
# Overlay File Tree Implementation Plan
> **Approval status:** User-approved 2026-05-08; implemented + deployed in the same session. This plan is committed retrospectively to record the work.
**Goal:** Build the overlay-detail "Files" section per `docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md` — a server-rendered collapsible tree of `${LEFT4ME_ROOT}/overlays/{overlay.id}/` with HTMX lazy expansion and click-to-download for individual files. Read-only; same access rule as the rest of the overlay detail page.
**Architecture:** A new `files_bp` blueprint exposes two GETs: `/overlays/<id>/files?path=<rel>` returns the listing as an HTML fragment (used both for first paint at the root level via `page_routes.overlay_detail` context, and for HTMX swaps when a folder expands), and `/overlays/<id>/files/download?path=<rel>` streams a single file. Pure helpers live in `l4d2web/services/overlay_files.py`: `safe_resolve_for_listing` (refuses symlink escape from overlay root), `safe_resolve_for_download` (allows symlink targets anywhere under `LEFT4ME_ROOT` — workshop addons stream from the shared cache; absolute symlinks to `/etc/passwd` are still blocked), and `list_directory` (one-level scan, dirs-first sort, 500-entry cap, symlink + broken-symlink markers, resolved size for files). Two Jinja partials (`_overlay_file_tree.html`, `_overlay_file_node.html`) plus a 12-line event-delegated `static/js/file-tree.js` for collapse/re-expand handle the UI; styles append to `static/css/components.css` against existing tokens.
---
## Locked Decisions
See the design doc for rationale. Implementation-relevant summary:
- New blueprint `files_bp` registered in `l4d2web/app.py` next to `overlay_bp`.
- Path resolution chains through `l4d2host.paths.overlay_path()` (already validates the overlay ref + resolves under `LEFT4ME_ROOT/overlays/`) and `l4d2web.services.security.validate_overlay_ref` (rejects empty/`.`/`..`/absolute/whitespace/backslash for the sub-path component).
- Listing rule: target must be a descendant of `overlay_root` after `Path.resolve()`. Download rule: real path must be a descendant of `LEFT4ME_ROOT` after `os.path.realpath()`.
- Tree shape: single recursive partial. `_overlay_file_tree.html` renders `<ul>`; `_overlay_file_node.html` renders one folder or file `<li>`. Folder buttons carry `data-files-url="/overlays/{id}/files?path=…"`. `static/js/file-tree.js` handles every click — toggles `aria-expanded` + `hidden`, fetches once on first expand, dedupes rapid clicks via `dataset.loaded`.
- `DEFAULT_MAX_ENTRIES = 500` in the helper module; re-resolved per call so tests can monkeypatch.
- No changes to `l4d2host`, builders, or workshop/script edit flows.
---
## Task 1: Pure helpers — path safety + directory listing
**Files:**
- Create: `l4d2web/services/overlay_files.py``safe_resolve_for_listing`, `safe_resolve_for_download`, `list_directory`, `_format_size`, `DEFAULT_MAX_ENTRIES`.
- Create: `l4d2web/tests/test_overlay_files.py` — 20 tests (path safety, listing semantics, symlink + broken-symlink handling, sort order, truncation cap, human-size formatting).
Test plan (RED first):
1. Listing returns overlay root for empty sub-path; joins under root for nested sub-path; rejects `..`, absolute path, empty component (`foo//bar`); rejects symlink escaping the overlay root even when target sits in `workshop_cache/`.
2. Download rejects empty path; returns real path for a regular file; follows a symlink into `workshop_cache/`; rejects a symlink to a path outside `LEFT4ME_ROOT`; rejects `..` and absolute paths.
3. `list_directory`: empty dir → empty list, truncated 0; dirs-first then files, both case-insensitive alphabetical; `kind ∈ {"dir", "file"}`; `rel` is forward-slash relative to overlay root; symlinks marked with `is_symlink=True` and resolved-target size; broken symlinks marked `broken=True` with `size=None`; truncation at supplied cap returns first N + `truncated_count`; `size_human` formats `5 B` and `3.0 MB` correctly.
**Implementation:**
- `safe_resolve_for_listing` calls `l4d2host.paths.overlay_path(overlay_path_value).resolve()` for the overlay root, short-circuits on empty `sub_path`, validates the sub-path via `validate_overlay_ref`, then `(overlay_root / sub_path).resolve(strict=False)` and asserts the result is the overlay root or a descendant.
- `safe_resolve_for_download` rejects empty `sub_path`, validates, builds `overlay_root / sub_path`, applies `os.path.realpath()`, asserts the result is under `get_left4me_root().resolve()`.
- `list_directory(target, overlay_root, *, max_entries=None)` uses `os.scandir` (free `stat` cache, `follow_symlinks` toggle). Per entry: `is_symlink = entry.is_symlink()`; `is_dir = entry.is_dir(follow_symlinks=True)` inside a try (OSError → broken=True, kind=file, size=None); regular files use `entry.stat(follow_symlinks=True).st_size`. `rel` is `"/".join(Path(entry.path).relative_to(overlay_root).parts)`. Sort by `(0 if dir else 1, name.casefold())`. Truncate to `max_entries or DEFAULT_MAX_ENTRIES`.
- `_format_size`: bytes (`N B`, no decimal) up to 1024, then KB/MB/GB/TB at one decimal place.
**Verification:**
```
pytest l4d2web/tests/test_overlay_files.py -q
```
**Commit:** part of Task 4's bundled `feat` commit.
---
## Task 2: HTTP routes — files_bp blueprint
**Files:**
- Create: `l4d2web/routes/files_routes.py``files_bp` with `GET /overlays/<id>/files` (fragment) and `GET /overlays/<id>/files/download` (stream).
- Modify: `l4d2web/app.py``from l4d2web.routes.files_routes import bp as files_bp` and `app.register_blueprint(files_bp)` next to `overlay_bp`.
- Create: `l4d2web/tests/test_overlay_files_routes.py` — 16 HTTP-level tests at this stage (3 more added in Task 4).
Test plan (RED first):
- Fragment: 200 + entries for root listing; 200 + entries for sub-directory; 400 on `..`, absolute path, empty component; 404 on unknown overlay; 404 on missing sub-dir; 403 on foreign user's overlay; 200 for admin viewing foreign overlay; truncation cap exposes "+ N more" footer (monkeypatch `DEFAULT_MAX_ENTRIES`); broken symlink rendered with `broken` badge and no `<a>` link.
- Download: 200 + `Content-Disposition: attachment` + exact byte match for regular file; 200 + cache content for workshop-cache symlink; 400 for symlink resolving outside `LEFT4ME_ROOT`; 400 for directory target; 404 for missing file; 403 for foreign user's overlay.
**Implementation:**
- Decorator stack: `@files_bp.get(...)` + `@require_login`. Auth gate inside the handler mirrors `page_routes.overlay_detail:194` (`g.user.admin or overlay.user_id is None or overlay.user_id == g.user.id`).
- Shared `_load_overlay_for_user(overlay_id, user)` does the lookup, the auth gate, and `db.expunge(overlay)` so the route can read scalar attributes after the session closes.
- `ValueError` from either resolver → `Response("invalid path", status=400)`. `target.is_dir()` failure on the listing route → 404. `real.exists()` / `real.is_dir()` failure on the download route → 404 / 400.
- `send_file(str(real), as_attachment=True, download_name=os.path.basename(real))`.
- The fragment renders `_overlay_file_tree.html` only — no `base.html` shell — so HTMX swaps inject just the `<ul>` content.
**Verification:**
```
pytest l4d2web/tests/test_overlay_files_routes.py -q
```
**Commit:** part of Task 4's bundled `feat` commit.
---
## Task 3: Templates + page-routes integration
**Files:**
- Create: `l4d2web/templates/_overlay_file_tree.html``<ul class="file-tree" role="group">` + per-entry `_overlay_file_node.html` include + optional truncated-footer `<li>`.
- Create: `l4d2web/templates/_overlay_file_node.html` — folder row (button + HTMX attrs + empty `<div class="file-tree-children" hidden>`) or file row (`<a>` for regular/symlink files; `<span>` for broken symlinks; `link` / `broken link` badges; `size_human`).
- Modify: `l4d2web/templates/overlay_detail.html` — add `<section class="panel"><h2>Files</h2>…</section>` between the type-specific sections and the existing "Used by" section. Renders empty-state `<p class="muted">No files yet — build this overlay to populate it.</p>` when `file_tree_root_entries is none`, else includes the partial.
- Modify: `l4d2web/routes/page_routes.py` — import the helpers, add `_root_file_tree(overlay)` (returns `(entries, truncated_count)` or `(None, 0)` on `ValueError` / missing dir / legacy absolute `overlay.path`), pass `file_tree_root_entries` + `file_tree_truncated` + `file_tree_truncated_count` into `render_template("overlay_detail.html", …)`.
Test plan (RED first, added to `test_overlay_files_routes.py`):
- `test_overlay_detail_renders_files_section_with_tree` — page contains "Files" header + entry names.
- `test_overlay_detail_shows_empty_state_when_overlay_dir_missing` — wipe directory, page shows "No files yet".
- `test_overlay_detail_files_section_present_for_workshop_overlays` — workshop type also gets the section.
**Implementation:**
- Section placement matters: `<section><h2>Files</h2>…</section>` is inserted before the existing "Used by" `<section>`.
- The partial uses `{% set entries = file_tree_root_entries %}` etc. so the same partial works whether called from the page (with full context) or from the HTMX route (rendering directly with named kwargs).
- `_root_file_tree` swallows `ValueError` and missing-dir cases into `(None, 0)`, and the template's `{% if file_tree_root_entries is none %}` renders the empty state.
- Use `overlay.path` (not `str(overlay.id)`) so legacy/seeded rows whose path differs still work correctly when resolvable.
**Verification:**
```
pytest l4d2web/tests/test_overlay_files_routes.py -q -k overlay_detail
pytest l4d2web/tests/ -q # no regressions across the full suite
```
**Commit:** part of Task 4's bundled `feat` commit.
---
## Task 4: CSS + JS + base.html script wiring
**Files:**
- Create: `l4d2web/static/js/file-tree.js` — event-delegated `click` handler that toggles `aria-expanded` on `.file-tree-toggle` and `hidden` on the next `.file-tree-children` sibling, and on first expand fires `fetch(button.dataset.filesUrl)` and innerHTMLs the response. `dataset.loaded` flag dedupes rapid clicks; cleared on error to allow retry.
- Modify: `l4d2web/templates/base.html``<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>` next to the existing `csrf.js` / `sse.js` / `modal.js` lines.
- Modify: `l4d2web/static/css/components.css` — append `~50` lines: `.file-tree`, `.file-tree-row`, `.file-tree-toggle` (transparent button, inherits color), `.file-tree-toggle .chevron` rotation transform on `aria-expanded="true"`, `.file-tree-children[hidden]`, `.file-tree-badge` + `.file-tree-badge-warn`. All against existing tokens (`--space-xs`, `--space-l`, `--color-surface-muted`, `--color-muted`, `--color-danger`, `--radius-s`).
**Implementation:**
- The JS handler fires on every click. First-expand path: read `button.dataset.filesUrl`, set `dataset.loaded="1"` optimistically, `fetch(url, {credentials: "same-origin"})`, replace `.file-tree-children` innerHTML with the response. Subsequent clicks just toggle `aria-expanded` + `hidden` — no re-fetch since `dataset.loaded` is set. On fetch error: `delete dataset.loaded` so a future click retries.
- The CSS chevron is a Unicode `` inside a `<span class="chevron">`; rotated 90° on expanded via `transform: rotate(90deg)` with a 120ms transition.
**Verification:**
```
pytest l4d2web/tests/ -q # 293 passed, 1 skipped
```
Manual smoke (post-deploy on `ckn@10.0.4.128`):
- Navigate to an overlay detail page with a populated runtime directory.
- Confirm the "Files" section renders the root level.
- Click a folder: HTMX request fires once, children appear, chevron rotates.
- Click again: children hide; no second request in DevTools network tab.
- Click a file: browser downloads it with the correct filename.
- Visit another user's overlay as a non-admin: 403.
**Commit:** `feat(l4d2-web): overlay detail Files section with HTMX file tree + downloads` — covers all four tasks (services helper + routes + templates + CSS/JS), since the feature is small and the tasks share a single set of integration tests.
---
## End-to-end verification
After all tasks committed:
```
pytest l4d2web/tests/ -q # 293 passed, 1 skipped
deploy/deploy-test-server.sh ckn@10.0.4.128
ssh ckn@10.0.4.128 'systemctl status left4me-web --no-pager | head -10'
curl -s http://10.0.4.128:8000/health # {"status":"ok"}
```
Then exercise the manual smoke checklist from Task 4 against the deployed instance.

View file

@ -0,0 +1,118 @@
# L4D2 Blueprint Overlay Picker Design
**Goal:** Replace the checkbox + numeric-Order table on the blueprint detail page with a drag-to-reorder list and a single dropdown to add overlays. Drag-and-drop is the primary reorder mechanic; per-row Order text inputs are removed.
**Approval status:** User-approved. No companion implementation plan — small surface, implemented directly.
## Context
`templates/blueprint_detail.html:14-28` currently renders one HTML table for the blueprint's overlays. Each row carries a `Use` checkbox, a numeric `Order` text input, and the overlay name. To enable an overlay you check it; to reorder you type integers into per-row text fields. Adding a new overlay between existing ones means renumbering everything below it by hand.
This spec replaces that table with a single ordered list of *selected* overlays plus a `<select>` dropdown for adding more. Drag-to-reorder is the only reorder interaction. A ✕ button on each row removes it (returning it to the dropdown). Picking an entry from the dropdown appends it to the list (and removes it from the dropdown).
The change is intentionally scoped small: no two-panel layout, no filter widget, no touch / keyboard reorder support, no JS-disabled fallback. The native `<select>` element supplies typeahead-by-letter and keyboard navigation for free, which covers the no-drag path. The page is desktop-primary.
## Locked Decisions
1. **Single ordered list of selected overlays only.** No second pane. The "available" set lives in the `<select>`. Adding via dropdown is one click; removing via ✕ is one click; reordering is one drag.
2. **Native HTML5 drag-and-drop.** No vendored library, no polyfill. Touch-screen drag is unsupported on Android and rough on iOS — accepted because the page is desktop-primary. Add and remove still work on touch via the `<select>` and the `<button>`.
3. **JS-required UI.** If JS does not load, the page is unusable. No degradation to the old checkbox table.
4. **Server contract unchanged.** Each list row owns one `<input type="hidden" name="overlay_ids" value="{id}">`. Form-submission order = DOM order. The existing `ordered_overlay_ids_from_form` handler in `routes/blueprint_routes.py` already falls back to enumerate index when no `overlay_position_<id>` field is present, so it accepts the new shape with no Python edit.
5. **Dropdown re-sorted alphabetically on remove.** When ✕ removes a row, the corresponding `<option>` is sorted-inserted back into the `<select>` (case-insensitive name compare). The dropdown stays predictable.
6. **Drop-indicator visual.** A 2px focus-color bar drawn via `box-shadow … inset` on the row under the cursor: top-bar = "drop will land before this row", bottom-bar = "drop will land after this row". The hover side is computed by comparing `event.clientY` to the row's vertical midpoint.
7. **Drop on empty space inside the list = append.** Drop directly on the dragged row (or with no movement) = no-op. Escape during drag triggers `dragend`, which clears all visual classes.
8. **Out of scope:** keyboard reorder, ARIA live announcements, touch DnD polyfill, server-side cleanup of the now-unused `overlay_position_<id>` form-field path.
## Architecture
```
GET /blueprints/<id>
page_routes.blueprint_page
├─▶ selected_overlays (ordered by BlueprintOverlay.position)
└─▶ available_overlays = all_overlays \ selected_overlays
(alphabetical)
templates/blueprint_detail.html
<ol data-overlay-list> ← drag target, hidden inputs
<li data-overlay-id draggable> × ⋮⋮ name </li>
</ol>
<select data-overlay-add> ← add path
<option>Pick a name…</option>
<option value=overlay.id>name</option> ← available_overlays
</select>
static/js/blueprint-overlay-picker.js
├─ dragstart/over/leave/drop/end → reorder DOM under [data-overlay-list]
├─ click [data-action="remove"] → remove row + sorted-insert <option>
├─ change [data-overlay-add] → append <li>, remove <option>
└─ refreshEmpty() → toggle [data-overlay-empty][hidden]
POST /blueprints/<id>
form-encoded body: overlay_ids=<id>&overlay_ids=<id>&… (in DOM order)
blueprint_routes.update_blueprint_form
→ ordered_overlay_ids_from_form (existing; fallback_position branch)
→ replace_blueprint_overlays (existing)
```
## Form-contract details
The new template emits one hidden input per selected row, colocated as a child of the `<li>`:
```html
<li data-overlay-id="3" draggable="true">
<span class="overlay-picker-handle">⋮⋮</span>
<span class="overlay-picker-name">workshop_maps</span>
<button type="button" data-action="remove">×</button>
<input type="hidden" name="overlay_ids" value="3">
</li>
```
Browser form serialization preserves DOM order across multiple inputs that share a `name`. Werkzeug's `request.form.getlist("overlay_ids")` returns them in submission order. `ordered_overlay_ids_from_form` then assigns each id its enumerate-index position via the `fallback_position` branch (lines 19-31 of `routes/blueprint_routes.py`) and feeds the result to `replace_blueprint_overlays`.
The JSON path (`POST /blueprints` with `application/json`) already takes `overlay_ids` list order at line 64 of the same file — this spec does not affect it.
## UI / UX details
- **Empty state.** When no overlays are selected, a `[data-overlay-empty]` paragraph reads "No overlays selected. Pick one below to add." JS toggles its `hidden` attribute on every list mutation.
- **Drag handle.** Visual only (`⋮⋮` glyph). The whole row is `draggable="true"`; the user does not have to grab the handle specifically.
- **Drop indicator math.** During `dragover`, compute `event.clientY < rect.top + rect.height/2`; that boolean picks `drop-before` (bar at top) vs `drop-after` (bar at bottom). On `drop`, read which class is set and `insertBefore` or `insertBefore(…, target.nextSibling)` accordingly.
- **Sorted insert on remove.** Walk `<select>` children comparing `option.dataset.overlayName` (lowercased) against the removed name; `insertBefore` the new option ahead of the first option whose name sorts later, or append if none.
- **Reset select after add.** Set `select.value = ""` so the placeholder reappears after each add.
## Files
| Path | Change |
|---|---|
| `l4d2web/routes/page_routes.py` | Compute `available_overlays`; pass to template. |
| `l4d2web/templates/blueprint_detail.html` | Replace overlay table with `<ol>` + `<select>`; add `<script defer>`. |
| `l4d2web/static/css/components.css` | Append `.overlay-picker-*` rules. Reuse existing tokens. |
| `l4d2web/static/js/blueprint-overlay-picker.js` | New IIFE. ~150 LOC. |
| `l4d2web/tests/test_blueprints.py` | Two new GET-page assertions. |
| `l4d2web/tests/test_pages.py` | Update `test_blueprint_detail_has_ordered_overlay_form` to match new shape. |
## Verification
Manual browser flow (`/blueprints/<id>`):
1. Initial render shows the saved selection in saved order; dropdown holds the rest. No console errors.
2. Drag a row up/down. Focus-colored bar appears at the top or bottom of the hover-target row (depending on which half is hovered). On drop, the row moves; hidden inputs reflect the new order.
3. Click ✕ on a row. Row vanishes; the same name appears in the dropdown in alphabetical position.
4. Pick from the dropdown. New row appears at the end of the list; the option leaves the dropdown; the placeholder is reselected.
5. Save the blueprint, reload. Order survives the round-trip.
6. Press Escape mid-drag. Drop indicators clear; source row regains opacity; nothing moved.
Test commands:
```
pytest l4d2web/tests/test_blueprints.py -q
pytest l4d2web/tests -q
```
## Out of scope / future follow-ups
- **Drop the `overlay_position_<id>` server-side path.** Once no client emits those fields, `ordered_overlay_ids_from_form` collapses to `[int(v) for v in request.form.getlist("overlay_ids")]`. Test `test_form_update_preserves_ordered_overlays_and_multiline_fields` (`l4d2web/tests/test_blueprints.py:220`) gets simplified accordingly.
- **Touch-friendly DnD.** Vendor a polyfill (`drag-drop-touch`) or rewrite the picker on pointer events if mobile editing becomes a real use case.
- **Keyboard reorder.** Space-to-grab + arrow-keys + ARIA live announcements. Currently only add/remove are keyboard-accessible.
- **Filter on the selected list.** Not needed at v1's overlay counts; revisit if blueprints commonly carry 20+ overlays.

View file

@ -0,0 +1,115 @@
# Overlay File Tree Section Design
**Goal:** Add a "Files" section to the overlay detail page (`/overlays/<id>`) that renders a collapsible tree of the overlay's runtime directory at `${LEFT4ME_ROOT}/overlays/{overlay.id}/`, with lazy expansion of folders (one fetch per first-time expand) and click-to-download for individual files. Same access rule as the rest of the overlay detail page (admin or `overlay.user_id == g.user.id`). Read-only; no rename/delete/upload in v1.
**Approval status:** User-approved 2026-05-08 (visual companion brainstorm + plan-mode review). Implemented + deployed in the same session. The lazy-load originally targeted HTMX (vendored in `base.html`), but the post-deploy smoke uncovered that `static/vendor/htmx.min.js` was a 33-byte placeholder — the real library was never vendored. Rather than vendoring full HTMX for one feature, the lazy-load was switched to plain JS using the same fetch + innerHTML pattern (~30 lines in `static/js/file-tree.js`). The route + partial contracts are unchanged.
## Context
Today, the overlay detail page shows the row's metadata (name, type, scope, path, last build status), a workshop-items table or script editor depending on `overlay.type`, and links to the build-job stream. It never shows what's actually inside the overlay directory on disk. To verify "did my script actually produce what I expected?" or "did the right VPKs land in `addons/`?" the user has to SSH into the host and `ls /var/lib/left4me/overlays/{id}/`.
Click-to-download is a secondary nice-to-have: workshop overlays' `addons/*.vpk` are absolute symlinks into the shared `${LEFT4ME_ROOT}/workshop_cache/`, and pulling a single VPK to a dev box otherwise means scping with the right path translation.
## Locked Decisions
1. **All overlay types show the section.** Script + workshop + system/managed. Consistency over a tighter scope; even workshop's predictable `addons/*.vpk` layout is worth confirming.
2. **Collapsible tree, lazy load on expand.** Tree can get large; only the root level is rendered server-side at first paint. Each folder click fires `GET /overlays/<id>/files?path=<rel>` and innerHTMLs the response into that folder's `.file-tree-children` div. The no-JS path still shows the root level (the same partial is server-rendered) — folders just won't expand.
3. **Single delegated JS handler.** `static/js/file-tree.js` listens for `click` on `document`, finds the closest `.file-tree-toggle` button, toggles `aria-expanded` + `hidden`, and on first expand fires a `fetch()` against the URL in the button's `data-files-url`. Subsequent toggles never re-fetch (`button.dataset.loaded` flag, set optimistically before the fetch to dedupe rapid clicks; cleared on error to allow retry).
4. **Single-file download in v1.** No bulk archive (e.g., "download whole overlay as `.tar.gz`"). Files are streamed via Flask `send_file(..., as_attachment=True)`. No size cap — VPKs are commonly 100500 MB and that's the whole point.
5. **No auto-refresh.** The tree reflects what was on disk at page render. After a build, the user reloads the page. Polling/SSE would duplicate the existing live-log mechanism on the build-job page for negligible benefit.
6. **Same access rule as the rest of the page.** `g.user.admin or overlay.user_id is None or overlay.user_id == g.user.id`. GETs need no CSRF (`l4d2web/app.py:56`).
7. **`overlay.path` not `overlay.id`.** The runtime directory is reached via `overlay.path` (current creation flow guarantees `path == str(id)`, but legacy/seeded rows may differ). Path resolution happens through the existing `l4d2host.paths.overlay_path()` helper, which already validates the ref string and resolves+verifies it stays under `${LEFT4ME_ROOT}/overlays/`.
8. **Empty / unresolvable → empty state.** If the overlay's path is unresolvable (legacy absolute-path rows) or the directory doesn't exist (overlay never built), the section renders "No files yet — build this overlay to populate it." rather than crashing.
9. **500-entry cap per folder.** Folders with more than 500 children render the alphabetical-first 500 plus a `+ M more (truncated)` footer. Tunable at runtime via `l4d2web.services.overlay_files.DEFAULT_MAX_ENTRIES` (re-resolved per call so tests can monkeypatch).
10. **Hidden files shown.** No filtering of `.git`, `.DS_Store`, etc. Users want ground truth.
11. **One dedicated blueprint, `files_bp`.** Not folded into `overlay_routes.py` (which is exclusively POST mutations) or `page_routes.py` (top-level pages, not embedded fragments). `files_bp` owns both the tree fragment and the download endpoint.
## Architecture
```text
GET /overlays/<id> (page_routes.overlay_detail)
▼ computes (file_tree_root_entries, truncated_count) via
│ _root_file_tree(overlay) → safe_resolve_for_listing(overlay.path, "")
│ → list_directory(overlay_root, overlay_root)
▼ renders overlay_detail.html, which includes _overlay_file_tree.html
for the root level (or the empty-state <p>).
GET /overlays/<id>/files?path=<rel> (files_routes.overlay_files_fragment)
▼ auth gate (admin or owner)
▼ safe_resolve_for_listing(overlay.path, rel) → Path under overlay_root
▼ list_directory(target, overlay_root) → entries[], truncated_count
▼ renders _overlay_file_tree.html (partial only — no base.html)
GET /overlays/<id>/files/download?path=<rel> (files_routes.overlay_files_download)
▼ auth gate
▼ safe_resolve_for_download(overlay.path, rel) → real Path under LEFT4ME_ROOT
│ (follows symlinks; allows targets anywhere in LEFT4ME_ROOT, e.g. workshop_cache)
▼ Flask send_file(real, as_attachment=True, download_name=basename(real))
```
### File tree fragment shape
`_overlay_file_tree.html` produces a `<ul class="file-tree">` containing one `_overlay_file_node.html` row per entry plus an optional truncated-footer `<li>`. A folder row is a `<button class="file-tree-toggle" data-files-url="…">` followed by an empty `<div class="file-tree-children" hidden>` that becomes the fetch target. A file row is an `<a href=".../files/download?path=…">` (or a plain `<span>` for broken symlinks) plus optional badges (`link`, `broken link`) and the resolved size.
Nesting after expand:
```html
<li class="file-tree-row file-tree-row-dir">
<button class="file-tree-toggle" aria-expanded="true" data-files-url="…"></button>
<div class="file-tree-children">
<ul class="file-tree" role="group"></ul> <!-- inserted by file-tree.js -->
</div>
</li>
```
## Path Safety
Two resolvers in `l4d2web/services/overlay_files.py`:
- `safe_resolve_for_listing(overlay.path, sub_path)` — resolves `overlay_root / sub_path`, applies `Path.resolve(strict=False)`, requires the result to be the overlay root or a descendant. Used by the tree-fragment route. **Refuses to recurse through symlinks that leave the overlay root**, including symlinks pointing into `workshop_cache/` — listing has no need to follow them, since workshop addons are leaf files, not directories we'd descend into.
- `safe_resolve_for_download(overlay.path, sub_path)` — resolves the candidate path, applies `os.path.realpath()`, requires the result to be under `${LEFT4ME_ROOT}` (anywhere — overlay dir, `workshop_cache/`, future siblings). This is the relaxed gate that lets workshop addons stream from the shared cache while still blocking absolute symlinks to `/etc/passwd` planted by a malicious script overlay.
Both resolvers re-use `l4d2host.paths.overlay_path()` (which itself calls `validate_overlay_ref`) for the overlay-root resolution, and `l4d2web.services.security.validate_overlay_ref` for the sub-path component (rejects empty / `.` / `..` / absolute / whitespace / backslash). Empty `sub_path` is valid for listing (means "the overlay root") and invalid for download.
Listing: `target.is_dir()` check after resolution; non-directory → 404.
Download: `real.exists()` check (404), `real.is_dir()` rejection (400 — "not a file").
## Symlink Behaviour
`list_directory` uses `os.scandir()` with explicit `follow_symlinks` flags:
- `is_symlink = entry.is_symlink()`
- `kind`: `entry.is_dir(follow_symlinks=True)` inside a try block. Raised `OSError` → broken symlink, treated as `kind="file"` with `broken=True` and no `<a>` download link.
- `size`: `entry.stat(follow_symlinks=True).st_size` for files (resolved target's size — what users care about for VPKs); `None` for dirs and broken symlinks.
Symlinked directories pointing inside the overlay root are rendered as folders and remain expandable; the listing-time safety check rejects expansion if the symlink resolves outside the overlay root.
Concurrent build vs listing race: a build mid-symlink-rewrite can yield a transient broken-symlink view. Acceptable — page is a snapshot; the visible "broken link" badge tells the user to refresh.
## Test Strategy
Two test modules, both following existing fixture patterns (`tests/test_script_overlay_routes.py` style — `monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))`, app fixture with `TESTING=True`).
- `tests/test_overlay_files.py` — pure-helper unit tests (Flask-free): listing-resolver happy/sad paths (root, sub-path, `..`, absolute, empty component, symlink-out-of-overlay), download-resolver happy/sad paths (regular file, workshop-cache symlink, outside-LEFT4ME_ROOT symlink, traversal, absolute, empty), `list_directory` behaviour (empty, dir-first sort, kind detection, rel paths, symlink markers, broken-symlink markers, truncation cap, human-size formatting).
- `tests/test_overlay_files_routes.py` — HTTP integration tests: tree-fragment 200 / 400 / 403 / 404 across the same axes; download 200 / 400 / 403 / 404 + content-disposition + byte-exact body for both regular files and workshop-cache symlinks; admin-can-view-foreign overlay; truncation-via-route (monkeypatching `DEFAULT_MAX_ENTRIES`); broken-symlink rendering omits the `<a>` download link; the page-level `overlay_detail` integration shows the section with entries when populated and the empty state when the directory is missing.
39 tests total. The full web suite (`pytest l4d2web/tests/ -q`) must remain green.
## Out of Scope
- Bulk download (e.g., "download overlay as tar.gz").
- Inline file preview (text peek, image thumbnail).
- File deletion / rename / upload from the UI.
- Auto-refresh while a build is active.
- Filtering hidden files or applying a `.gitignore`-style rule.
- Reusable file-tree component for things outside overlays.

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:
@ -270,12 +297,15 @@ def blueprint_page(blueprint_id: int):
).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
selected_ids = {overlay.id for overlay in selected_overlays}
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
return render_template(
"blueprint_detail.html",
blueprint=blueprint,
selected_overlays=selected_overlays,
available_overlays=available_overlays,
all_overlays=all_overlays,
selected_overlay_ids={overlay.id for overlay in selected_overlays},
selected_overlay_ids=selected_ids,
overlay_positions=overlay_positions,
arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config),

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

@ -50,6 +50,14 @@ textarea {
padding: var(--space-s) var(--space-m);
}
textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
tab-size: 4;
}
input:focus,
select:focus,
textarea:focus,
@ -188,3 +196,160 @@ 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;
/* Row-gap 0 so the wrapped .file-tree-children div sits directly under
the toggle button; vertical spacing is owned exclusively by the
outer grid's gap and the nested ul's margin-top. */
gap: 0 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;
width: 1ch;
text-align: center;
transition: transform 120ms ease;
margin-right: var(--space-xs);
}
.file-tree-row-file {
padding-left: calc(1ch + var(--space-xs));
}
.file-tree-toggle[aria-expanded="true"] .chevron {
transform: rotate(90deg);
}
.file-tree-children {
flex-basis: 100%;
}
.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;
}
.overlay-picker {
display: grid;
gap: var(--space-m);
}
.overlay-picker-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-height: 3rem;
}
.overlay-picker-row {
display: flex;
align-items: center;
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
background: var(--color-surface-muted);
border: var(--line);
border-radius: var(--radius-s);
cursor: grab;
user-select: none;
}
.overlay-picker-row:active {
cursor: grabbing;
}
.overlay-picker-row.is-dragging {
opacity: 0.4;
}
.overlay-picker-row.drop-before {
box-shadow: 0 2px 0 0 var(--color-focus) inset;
}
.overlay-picker-row.drop-after {
box-shadow: 0 -2px 0 0 var(--color-focus) inset;
}
.overlay-picker-handle {
color: var(--color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: -0.1em;
}
.overlay-picker-name {
flex: 1;
}
.overlay-picker-remove {
background: none;
color: var(--color-muted);
padding: 0 var(--space-s);
font-size: 1.1rem;
line-height: 1;
}
.overlay-picker-remove:hover {
color: var(--color-danger);
}
.overlay-picker-empty {
margin: 0;
}
.overlay-picker-add {
display: flex;
align-items: center;
gap: var(--space-s);
}
.overlay-picker-add select {
flex: 1;
}

View file

@ -0,0 +1,159 @@
(() => {
const root = document.querySelector("[data-overlay-list]");
if (!root) return;
const select = document.querySelector("[data-overlay-add]");
const empty = document.querySelector("[data-overlay-empty]");
let dragRow = null;
const clearDropMarkers = () => {
root
.querySelectorAll(".drop-before, .drop-after")
.forEach((el) => el.classList.remove("drop-before", "drop-after"));
};
const refreshEmpty = () => {
if (!empty) return;
empty.hidden = root.children.length > 0;
};
const buildHiddenInput = (overlayId) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = "overlay_ids";
input.value = overlayId;
return input;
};
const buildRow = (overlayId, overlayName) => {
const li = document.createElement("li");
li.className = "overlay-picker-row";
li.draggable = true;
li.dataset.overlayId = overlayId;
li.dataset.overlayName = overlayName;
const handle = document.createElement("span");
handle.className = "overlay-picker-handle";
handle.setAttribute("aria-hidden", "true");
handle.textContent = "⋮⋮";
const name = document.createElement("span");
name.className = "overlay-picker-name";
name.textContent = overlayName;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "overlay-picker-remove";
remove.dataset.action = "remove";
remove.setAttribute("aria-label", `Remove ${overlayName}`);
remove.textContent = "×";
li.append(handle, name, remove, buildHiddenInput(overlayId));
return li;
};
const insertOptionSorted = (overlayId, overlayName) => {
if (!select) return;
const option = document.createElement("option");
option.value = overlayId;
option.dataset.overlayName = overlayName;
option.textContent = overlayName;
const lower = overlayName.toLowerCase();
const existing = Array.from(select.querySelectorAll("option[value]:not([value=''])"));
const next = existing.find(
(opt) => (opt.dataset.overlayName || opt.textContent).toLowerCase() > lower,
);
if (next) {
select.insertBefore(option, next);
} else {
select.appendChild(option);
}
};
root.addEventListener("dragstart", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (!row) return;
dragRow = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", row.dataset.overlayId || "");
}
});
root.addEventListener("dragend", () => {
if (dragRow) {
dragRow.classList.remove("is-dragging");
}
clearDropMarkers();
dragRow = null;
});
root.addEventListener("dragover", (event) => {
if (!dragRow) return;
const row = event.target.closest(".overlay-picker-row");
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
clearDropMarkers();
if (row && row !== dragRow) {
const rect = row.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2;
row.classList.add(before ? "drop-before" : "drop-after");
}
});
root.addEventListener("dragleave", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (row) {
row.classList.remove("drop-before", "drop-after");
}
});
root.addEventListener("drop", (event) => {
if (!dragRow) return;
event.preventDefault();
const row = event.target.closest(".overlay-picker-row");
if (row && row !== dragRow) {
const before = row.classList.contains("drop-before");
row.classList.remove("drop-before", "drop-after");
if (before) {
root.insertBefore(dragRow, row);
} else {
root.insertBefore(dragRow, row.nextSibling);
}
} else if (!row) {
root.appendChild(dragRow);
}
clearDropMarkers();
});
root.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-action='remove']");
if (!trigger) return;
const row = trigger.closest(".overlay-picker-row");
if (!row) return;
const overlayId = row.dataset.overlayId;
const overlayName = row.dataset.overlayName || "";
row.remove();
if (overlayId) insertOptionSorted(overlayId, overlayName);
refreshEmpty();
});
if (select) {
select.addEventListener("change", () => {
const value = select.value;
if (!value) return;
const option = select.querySelector(`option[value="${CSS.escape(value)}"]`);
const overlayName = option ? option.dataset.overlayName || option.textContent : "";
root.appendChild(buildRow(value, overlayName));
if (option) option.remove();
select.value = "";
refreshEmpty();
});
}
refreshEmpty();
})();

View file

@ -0,0 +1,40 @@
// Lazy-loaded collapsible file tree on overlay detail pages.
//
// One delegated click handler on document. Each `.file-tree-toggle` button
// carries `data-files-url`. First expand fires a fetch and innerHTMLs the
// returned partial into the next `.file-tree-children`; subsequent clicks
// just toggle visibility — no re-fetch.
(function () {
document.addEventListener("click", function (event) {
const button = event.target.closest(".file-tree-toggle");
if (!button) return;
const children = button.nextElementSibling;
if (!children || !children.classList.contains("file-tree-children")) return;
const wasExpanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", wasExpanded ? "false" : "true");
children.hidden = wasExpanded;
if (wasExpanded) return; // collapsing — nothing to fetch
if (button.dataset.loaded === "1") return; // already populated
const url = button.dataset.filesUrl;
if (!url) return;
button.dataset.loaded = "1"; // optimistic — prevents duplicate fetches on rapid clicks
fetch(url, { headers: { Accept: "text/html" }, credentials: "same-origin" })
.then(function (response) {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.text();
})
.then(function (html) {
children.innerHTML = html;
})
.catch(function (err) {
delete button.dataset.loaded; // allow retry
children.innerHTML =
'<p class="muted">Failed to load directory contents.</p>';
console.error("file-tree:", err);
});
});
})();

View file

@ -0,0 +1,22 @@
{% if entry.kind == 'dir' %}
<li class="file-tree-row file-tree-row-dir">
<button type="button"
class="file-tree-toggle"
aria-expanded="false"
data-files-url="/overlays/{{ overlay.id }}/files?path={{ entry.rel|urlencode }}">
<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

@ -12,22 +12,30 @@
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
<table class="table">
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
<tbody>
{% for overlay in all_overlays %}
<tr>
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
<td>{{ overlay.name }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
<div class="overlay-picker">
<ol class="overlay-picker-list" data-overlay-list>
{% for overlay in selected_overlays %}
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
<span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
<span class="overlay-picker-name">{{ overlay.name }}</span>
<button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
<input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
</li>
{% endfor %}
</tbody>
</table>
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
</ol>
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
<label class="overlay-picker-add">
<span>Add overlay</span>
<select data-overlay-add>
<option value="">Pick a name…</option>
{% for overlay in available_overlays %}
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
{% endfor %}
</select>
</label>
</div>
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label>
<button type="submit">Save blueprint</button>
</form>
</section>
@ -48,4 +56,5 @@
</form>
</div>
</dialog>
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
{% endblock %}

View file

@ -34,8 +34,8 @@
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments"></textarea></label>
<label>Config <textarea name="config"></textarea></label>
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false"></textarea></label>
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>

View file

@ -129,6 +129,18 @@
</section>
{% endif %}
<section class="panel">
<h2>Files</h2>
{% if not file_tree_root_entries %}
<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

@ -1,6 +1,7 @@
import json
import pytest
from sqlalchemy import select
from l4d2web.app import create_app
from l4d2web.auth import hash_password
@ -247,3 +248,48 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client
assert update.status_code == 302
assert update.headers["Location"] == "/blueprints/1"
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
with session_scope() as session:
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0))
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "data-overlay-list" in body
assert "data-overlay-add" in body
assert body.count('data-overlay-id="1"') == 1
assert 'data-overlay-id="2"' not in body
assert '<option value="2"' in body
assert '<option value="3"' in body
assert '<option value="1"' not in body
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add_all(
[
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=2, position=0),
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=1),
]
)
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
first = body.find('name="overlay_ids" value="2"')
second = body.find('name="overlay_ids" value="1"')
assert first != -1 and second != -1
assert first < second

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,385 @@
"""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_shows_empty_state_when_overlay_dir_is_empty(
app, left4me_root: Path
) -> None:
"""A built overlay whose directory has been wiped (or seeded but never
built) should also fall back to the empty-state message not render an
invisible empty <ul>."""
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
# _make_overlay leaves the directory in place but empty.
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
)

View file

@ -459,7 +459,8 @@ def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> N
assert 'name="arguments"' in text
assert 'name="config"' in text
assert 'name="overlay_ids"' in text
assert 'name="overlay_position_1"' in text
assert "data-overlay-list" in text
assert "data-overlay-add" in text
def test_admin_jobs_page_renders_system_job(tmp_path, monkeypatch) -> None: