Compare commits
11 commits
1166e13e44
...
c2cf723911
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2cf723911 | ||
|
|
a4e9f6cd26 | ||
|
|
dec4fed809 | ||
|
|
01760a31f5 | ||
|
|
7b31390b4c | ||
|
|
4619a91f45 | ||
|
|
caa8b83cf0 | ||
|
|
c958d0352a | ||
|
|
2ab54a3800 | ||
|
|
a11d030edd | ||
|
|
76bd6e8d4d |
23 changed files with 1813 additions and 30 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ __pycache__/
|
||||||
l4d2web.db*
|
l4d2web.db*
|
||||||
# CocoIndex Code (ccc)
|
# CocoIndex Code (ccc)
|
||||||
/.cocoindex_code/
|
/.cocoindex_code/
|
||||||
|
.superpowers/
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ Do not invent architecture outside these plans unless explicitly requested.
|
||||||
- Do not use git worktrees.
|
- 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.
|
- 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
|
### Naming and boundaries
|
||||||
|
|
||||||
- Use `l4d2` naming consistently.
|
- Use `l4d2` naming consistently.
|
||||||
|
|
|
||||||
|
|
@ -154,17 +154,18 @@ $sudo_cmd install -m 0644 -o root -g root \
|
||||||
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
|
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
|
||||||
/etc/left4me/sandbox-resolv.conf
|
/etc/left4me/sandbox-resolv.conf
|
||||||
|
|
||||||
if [ ! -f /etc/left4me/web.env ]; then
|
# Stomp the file every deploy so newly added vars reach existing boxes.
|
||||||
secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
# SECRET_KEY is derived from /etc/machine-id so it stays stable across
|
||||||
tmp_web_env="$remote_tmp/web.env"
|
# redeploys (no session invalidation) without persisting state in /etc.
|
||||||
{
|
secret_key=$(sha256sum < /etc/machine-id | awk '{print $1}')
|
||||||
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
|
tmp_web_env="$remote_tmp/web.env"
|
||||||
printf 'SECRET_KEY=%s\n' "$secret_key"
|
{
|
||||||
printf 'JOB_WORKER_THREADS=4\n'
|
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
|
||||||
printf 'SESSION_COOKIE_SECURE=false\n'
|
printf 'SECRET_KEY=%s\n' "$secret_key"
|
||||||
} > "$tmp_web_env"
|
printf 'JOB_WORKER_THREADS=4\n'
|
||||||
$sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env
|
printf 'SESSION_COOKIE_SECURE=false\n'
|
||||||
fi
|
} > "$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
|
if [ ! -x /opt/left4me/.venv/bin/python ]; then
|
||||||
run_as_left4me python3 -m venv /opt/left4me/.venv
|
run_as_left4me python3 -m venv /opt/left4me/.venv
|
||||||
|
|
|
||||||
161
docs/superpowers/plans/2026-05-08-overlay-file-tree.md
Normal file
161
docs/superpowers/plans/2026-05-08-overlay-file-tree.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
115
docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md
Normal file
115
docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md
Normal 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 100–500 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.
|
||||||
|
|
@ -11,6 +11,7 @@ from l4d2web.db import init_db
|
||||||
from l4d2web.routes.blueprint_routes import bp as blueprint_bp
|
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 bp as auth_bp
|
||||||
from l4d2web.routes.auth_routes import reset_login_rate_limits
|
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.job_routes import bp as job_bp
|
||||||
from l4d2web.routes.log_routes import bp as log_bp
|
from l4d2web.routes.log_routes import bp as log_bp
|
||||||
from l4d2web.routes.overlay_routes import bp as overlay_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.before_request(load_current_user)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(overlay_bp)
|
app.register_blueprint(overlay_bp)
|
||||||
|
app.register_blueprint(files_bp)
|
||||||
app.register_blueprint(workshop_bp)
|
app.register_blueprint(workshop_bp)
|
||||||
app.register_blueprint(blueprint_bp)
|
app.register_blueprint(blueprint_bp)
|
||||||
app.register_blueprint(server_bp)
|
app.register_blueprint(server_bp)
|
||||||
|
|
|
||||||
102
l4d2web/routes/files_routes.py
Normal file
102
l4d2web/routes/files_routes.py
Normal 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)
|
||||||
|
)
|
||||||
|
|
@ -15,6 +15,10 @@ from l4d2web.models import (
|
||||||
User,
|
User,
|
||||||
WorkshopItem,
|
WorkshopItem,
|
||||||
)
|
)
|
||||||
|
from l4d2web.services.overlay_files import (
|
||||||
|
list_directory,
|
||||||
|
safe_resolve_for_listing,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("pages", __name__)
|
bp = Blueprint("pages", __name__)
|
||||||
|
|
@ -219,15 +223,38 @@ def overlay_detail(overlay_id: int):
|
||||||
.order_by(Job.created_at.desc())
|
.order_by(Job.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"overlay_detail.html",
|
"overlay_detail.html",
|
||||||
overlay=overlay,
|
overlay=overlay,
|
||||||
using_blueprints=using_blueprints,
|
using_blueprints=using_blueprints,
|
||||||
workshop_items=workshop_items,
|
workshop_items=workshop_items,
|
||||||
latest_build_job=latest_build_job,
|
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")
|
@bp.get("/blueprints")
|
||||||
@require_login
|
@require_login
|
||||||
def blueprints_page() -> str:
|
def blueprints_page() -> str:
|
||||||
|
|
@ -270,12 +297,15 @@ def blueprint_page(blueprint_id: int):
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
|
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(
|
return render_template(
|
||||||
"blueprint_detail.html",
|
"blueprint_detail.html",
|
||||||
blueprint=blueprint,
|
blueprint=blueprint,
|
||||||
selected_overlays=selected_overlays,
|
selected_overlays=selected_overlays,
|
||||||
|
available_overlays=available_overlays,
|
||||||
all_overlays=all_overlays,
|
all_overlays=all_overlays,
|
||||||
selected_overlay_ids={overlay.id for overlay in selected_overlays},
|
selected_overlay_ids=selected_ids,
|
||||||
overlay_positions=overlay_positions,
|
overlay_positions=overlay_positions,
|
||||||
arguments=json.loads(blueprint.arguments),
|
arguments=json.loads(blueprint.arguments),
|
||||||
config_lines=json.loads(blueprint.config),
|
config_lines=json.loads(blueprint.config),
|
||||||
|
|
|
||||||
131
l4d2web/services/overlay_files.py
Normal file
131
l4d2web/services/overlay_files.py
Normal 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
|
||||||
|
|
@ -50,6 +50,14 @@ textarea {
|
||||||
padding: var(--space-s) var(--space-m);
|
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,
|
input:focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
|
|
@ -188,3 +196,160 @@ dialog.modal::backdrop {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-s);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
159
l4d2web/static/js/blueprint-overlay-picker.js
Normal file
159
l4d2web/static/js/blueprint-overlay-picker.js
Normal 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();
|
||||||
|
})();
|
||||||
40
l4d2web/static/js/file-tree.js
Normal file
40
l4d2web/static/js/file-tree.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
22
l4d2web/templates/_overlay_file_node.html
Normal file
22
l4d2web/templates/_overlay_file_node.html
Normal 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 %}
|
||||||
8
l4d2web/templates/_overlay_file_tree.html
Normal file
8
l4d2web/templates/_overlay_file_tree.html
Normal 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>
|
||||||
|
|
@ -40,5 +40,6 @@
|
||||||
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
|
<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/sse.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,30 @@
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
|
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
|
||||||
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
|
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
|
||||||
<table class="table">
|
<div class="overlay-picker">
|
||||||
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
|
<ol class="overlay-picker-list" data-overlay-list>
|
||||||
<tbody>
|
{% for overlay in selected_overlays %}
|
||||||
{% for overlay in all_overlays %}
|
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
|
||||||
<tr>
|
<span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
|
||||||
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
|
<span class="overlay-picker-name">{{ overlay.name }}</span>
|
||||||
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
|
<button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
|
||||||
<td>{{ overlay.name }}</td>
|
<input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
|
||||||
</tr>
|
</li>
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</ol>
|
||||||
</table>
|
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
|
||||||
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
|
<label class="overlay-picker-add">
|
||||||
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
|
<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>
|
<button type="submit">Save blueprint</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -48,4 +56,5 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<label>Name <input name="name" required></label>
|
<label>Name <input name="name" required></label>
|
||||||
<label>Arguments <textarea name="arguments"></textarea></label>
|
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false"></textarea></label>
|
||||||
<label>Config <textarea name="config"></textarea></label>
|
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,18 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<section class="panel">
|
||||||
<h2>Used by</h2>
|
<h2>Used by</h2>
|
||||||
{% if using_blueprints %}
|
{% if using_blueprints %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.app import create_app
|
from l4d2web.app import create_app
|
||||||
from l4d2web.auth import hash_password
|
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.status_code == 302
|
||||||
assert update.headers["Location"] == "/blueprints/1"
|
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
|
||||||
|
|
|
||||||
267
l4d2web/tests/test_overlay_files.py
Normal file
267
l4d2web/tests/test_overlay_files.py
Normal 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"
|
||||||
385
l4d2web/tests/test_overlay_files_routes.py
Normal file
385
l4d2web/tests/test_overlay_files_routes.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -459,7 +459,8 @@ def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> N
|
||||||
assert 'name="arguments"' in text
|
assert 'name="arguments"' in text
|
||||||
assert 'name="config"' in text
|
assert 'name="config"' in text
|
||||||
assert 'name="overlay_ids"' 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:
|
def test_admin_jobs_page_renders_system_job(tmp_path, monkeypatch) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue