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*
|
||||
# CocoIndex Code (ccc)
|
||||
/.cocoindex_code/
|
||||
.superpowers/
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ Do not invent architecture outside these plans unless explicitly requested.
|
|||
- Do not use git worktrees.
|
||||
- Local Python venv is direnv-managed via `.envrc` (Python 3.13). After fresh checkout: `direnv allow`, then `pip install -e ./l4d2host -e ./l4d2web pytest`. See README **Local development** for details.
|
||||
|
||||
### Planning artifacts
|
||||
|
||||
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
|
||||
- Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned).
|
||||
- Commit both to git as soon as the user approves them.
|
||||
- Do not leave specs or plans outside this repo. The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable while plan mode is open; the persisted artifact must end up under `docs/superpowers/` and be committed.
|
||||
|
||||
### Naming and boundaries
|
||||
|
||||
- Use `l4d2` naming consistently.
|
||||
|
|
|
|||
|
|
@ -154,17 +154,18 @@ $sudo_cmd install -m 0644 -o root -g root \
|
|||
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
|
||||
/etc/left4me/sandbox-resolv.conf
|
||||
|
||||
if [ ! -f /etc/left4me/web.env ]; then
|
||||
secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
||||
tmp_web_env="$remote_tmp/web.env"
|
||||
{
|
||||
# Stomp the file every deploy so newly added vars reach existing boxes.
|
||||
# SECRET_KEY is derived from /etc/machine-id so it stays stable across
|
||||
# redeploys (no session invalidation) without persisting state in /etc.
|
||||
secret_key=$(sha256sum < /etc/machine-id | awk '{print $1}')
|
||||
tmp_web_env="$remote_tmp/web.env"
|
||||
{
|
||||
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
|
||||
printf 'SECRET_KEY=%s\n' "$secret_key"
|
||||
printf 'JOB_WORKER_THREADS=4\n'
|
||||
printf 'SESSION_COOKIE_SECURE=false\n'
|
||||
} > "$tmp_web_env"
|
||||
$sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env
|
||||
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
|
||||
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.auth_routes import bp as auth_bp
|
||||
from l4d2web.routes.auth_routes import reset_login_rate_limits
|
||||
from l4d2web.routes.files_routes import bp as files_bp
|
||||
from l4d2web.routes.job_routes import bp as job_bp
|
||||
from l4d2web.routes.log_routes import bp as log_bp
|
||||
from l4d2web.routes.overlay_routes import bp as overlay_bp
|
||||
|
|
@ -70,6 +71,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|||
app.before_request(load_current_user)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(overlay_bp)
|
||||
app.register_blueprint(files_bp)
|
||||
app.register_blueprint(workshop_bp)
|
||||
app.register_blueprint(blueprint_bp)
|
||||
app.register_blueprint(server_bp)
|
||||
|
|
|
|||
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,
|
||||
WorkshopItem,
|
||||
)
|
||||
from l4d2web.services.overlay_files import (
|
||||
list_directory,
|
||||
safe_resolve_for_listing,
|
||||
)
|
||||
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
|
|
@ -219,15 +223,38 @@ def overlay_detail(overlay_id: int):
|
|||
.order_by(Job.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
|
||||
|
||||
return render_template(
|
||||
"overlay_detail.html",
|
||||
overlay=overlay,
|
||||
using_blueprints=using_blueprints,
|
||||
workshop_items=workshop_items,
|
||||
latest_build_job=latest_build_job,
|
||||
file_tree_root_entries=file_tree_root_entries,
|
||||
file_tree_truncated=file_tree_truncated_count > 0
|
||||
if file_tree_root_entries is not None
|
||||
else False,
|
||||
file_tree_truncated_count=file_tree_truncated_count,
|
||||
)
|
||||
|
||||
|
||||
def _root_file_tree(overlay: Overlay) -> tuple[list[dict] | None, int]:
|
||||
"""Return (entries, truncated_count) for the overlay's runtime directory,
|
||||
or (None, 0) if the directory doesn't exist or the path is unresolvable
|
||||
(e.g. legacy absolute `overlay.path` values that pre-date the current
|
||||
`path == str(id)` convention)."""
|
||||
try:
|
||||
overlay_root = safe_resolve_for_listing(overlay.path, "")
|
||||
except ValueError:
|
||||
return None, 0
|
||||
if not overlay_root.is_dir():
|
||||
return None, 0
|
||||
entries, truncated_count = list_directory(overlay_root, overlay_root)
|
||||
return entries, truncated_count
|
||||
|
||||
|
||||
@bp.get("/blueprints")
|
||||
@require_login
|
||||
def blueprints_page() -> str:
|
||||
|
|
@ -270,12 +297,15 @@ def blueprint_page(blueprint_id: int):
|
|||
).all()
|
||||
|
||||
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
|
||||
selected_ids = {overlay.id for overlay in selected_overlays}
|
||||
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
|
||||
return render_template(
|
||||
"blueprint_detail.html",
|
||||
blueprint=blueprint,
|
||||
selected_overlays=selected_overlays,
|
||||
available_overlays=available_overlays,
|
||||
all_overlays=all_overlays,
|
||||
selected_overlay_ids={overlay.id for overlay in selected_overlays},
|
||||
selected_overlay_ids=selected_ids,
|
||||
overlay_positions=overlay_positions,
|
||||
arguments=json.loads(blueprint.arguments),
|
||||
config_lines=json.loads(blueprint.config),
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
|
|
@ -188,3 +196,160 @@ dialog.modal::backdrop {
|
|||
display: grid;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-tree .file-tree {
|
||||
padding-left: var(--space-l);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.file-tree-row {
|
||||
display: flex;
|
||||
/* Row-gap 0 so the wrapped .file-tree-children div sits directly under
|
||||
the toggle button; vertical spacing is owned exclusively by the
|
||||
outer grid's gap and the nested ul's margin-top. */
|
||||
gap: 0 var(--space-s);
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-tree-toggle {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-tree-toggle .chevron {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
text-align: center;
|
||||
transition: transform 120ms ease;
|
||||
margin-right: var(--space-xs);
|
||||
}
|
||||
|
||||
.file-tree-row-file {
|
||||
padding-left: calc(1ch + var(--space-xs));
|
||||
}
|
||||
|
||||
.file-tree-toggle[aria-expanded="true"] .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.file-tree-children {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.file-tree-children[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-tree-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-muted);
|
||||
border: var(--line);
|
||||
}
|
||||
|
||||
.file-tree-badge-warn {
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.file-tree-row-truncated {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.overlay-picker {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
|
||||
.overlay-picker-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.overlay-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
background: var(--color-surface-muted);
|
||||
border: var(--line);
|
||||
border-radius: var(--radius-s);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.overlay-picker-row:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.overlay-picker-row.is-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.overlay-picker-row.drop-before {
|
||||
box-shadow: 0 2px 0 0 var(--color-focus) inset;
|
||||
}
|
||||
|
||||
.overlay-picker-row.drop-after {
|
||||
box-shadow: 0 -2px 0 0 var(--color-focus) inset;
|
||||
}
|
||||
|
||||
.overlay-picker-handle {
|
||||
color: var(--color-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: -0.1em;
|
||||
}
|
||||
|
||||
.overlay-picker-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.overlay-picker-remove {
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
padding: 0 var(--space-s);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.overlay-picker-remove:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.overlay-picker-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-picker-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.overlay-picker-add select {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
|||
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/sse.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -12,22 +12,30 @@
|
|||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
|
||||
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
|
||||
<tbody>
|
||||
{% for overlay in all_overlays %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
|
||||
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
|
||||
<td>{{ overlay.name }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
|
||||
<div class="overlay-picker">
|
||||
<ol class="overlay-picker-list" data-overlay-list>
|
||||
{% for overlay in selected_overlays %}
|
||||
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
|
||||
<span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
|
||||
<span class="overlay-picker-name">{{ overlay.name }}</span>
|
||||
<button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
|
||||
<input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
|
||||
</li>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
|
||||
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
|
||||
</ol>
|
||||
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
|
||||
<label class="overlay-picker-add">
|
||||
<span>Add overlay</span>
|
||||
<select data-overlay-add>
|
||||
<option value="">Pick a name…</option>
|
||||
{% for overlay in available_overlays %}
|
||||
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
|
||||
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label>
|
||||
<button type="submit">Save blueprint</button>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -48,4 +56,5 @@
|
|||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@
|
|||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" required></label>
|
||||
<label>Arguments <textarea name="arguments"></textarea></label>
|
||||
<label>Config <textarea name="config"></textarea></label>
|
||||
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false"></textarea></label>
|
||||
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
|
|
|
|||
|
|
@ -129,6 +129,18 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Files</h2>
|
||||
{% if not file_tree_root_entries %}
|
||||
<p class="muted">No files yet — build this overlay to populate it.</p>
|
||||
{% else %}
|
||||
{% set entries = file_tree_root_entries %}
|
||||
{% set truncated = file_tree_truncated %}
|
||||
{% set truncated_count = file_tree_truncated_count %}
|
||||
{% include "_overlay_file_tree.html" %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Used by</h2>
|
||||
{% if using_blueprints %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.app import create_app
|
||||
from l4d2web.auth import hash_password
|
||||
|
|
@ -247,3 +248,48 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client
|
|||
|
||||
assert update.status_code == 302
|
||||
assert update.headers["Location"] == "/blueprints/1"
|
||||
|
||||
|
||||
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
|
||||
user = session.scalar(select(User).where(User.username == "alice"))
|
||||
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||||
session.add(blueprint)
|
||||
session.flush()
|
||||
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0))
|
||||
blueprint_id = blueprint.id
|
||||
|
||||
response = user_client.get(f"/blueprints/{blueprint_id}")
|
||||
assert response.status_code == 200
|
||||
body = response.get_data(as_text=True)
|
||||
assert "data-overlay-list" in body
|
||||
assert "data-overlay-add" in body
|
||||
assert body.count('data-overlay-id="1"') == 1
|
||||
assert 'data-overlay-id="2"' not in body
|
||||
assert '<option value="2"' in body
|
||||
assert '<option value="3"' in body
|
||||
assert '<option value="1"' not in body
|
||||
|
||||
|
||||
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
|
||||
with session_scope() as session:
|
||||
user = session.scalar(select(User).where(User.username == "alice"))
|
||||
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||||
session.add(blueprint)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=2, position=0),
|
||||
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=1),
|
||||
]
|
||||
)
|
||||
blueprint_id = blueprint.id
|
||||
|
||||
response = user_client.get(f"/blueprints/{blueprint_id}")
|
||||
assert response.status_code == 200
|
||||
body = response.get_data(as_text=True)
|
||||
first = body.find('name="overlay_ids" value="2"')
|
||||
second = body.find('name="overlay_ids" value="1"')
|
||||
assert first != -1 and second != -1
|
||||
assert first < second
|
||||
|
|
|
|||
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="config"' in text
|
||||
assert 'name="overlay_ids"' in text
|
||||
assert 'name="overlay_position_1"' in text
|
||||
assert "data-overlay-list" in text
|
||||
assert "data-overlay-add" in text
|
||||
|
||||
|
||||
def test_admin_jobs_page_renders_system_job(tmp_path, monkeypatch) -> None:
|
||||
|
|
|
|||
Loading…
Reference in a new issue