Adds Overlay.type='files' whose source-of-truth IS the overlay directory
itself. Users can:
* upload arbitrary files / whole folders by dragging from the OS onto a
folder row in the file tree (one POST per file, queue with
concurrency 3, per-file progress in a floating Uploads panel)
* move via drag-and-drop inside the tree (same gesture, source
distinguishes; refuses cycles)
* create / edit / rename / replace through a single editor modal
(text flavor for editable files, binary flavor with replace-upload
for everything else; filename input is the rename surface)
* mkdir empty folders (slashes allowed for nested intermediates)
* stream a folder as a zip download
* delete files and empty folders
Backend is type-agnostic past the new files_routes endpoints, so the
existing mount / spec / overlayfs / expose_server_cfg pipeline is reused
unchanged. is_editable gates the row's edit affordance and the /save
content rules. Three new safe-resolve helpers (write/delete/move) cover
the new operations with the same anchor-and-resolve pattern as listing
and download. FilesBuilder is a no-op so the build subsystem can
dispatch uniformly.
Spec: docs/superpowers/specs/2026-05-09-files-overlay-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
Files overlay (user-managed file content)
Context
In the prior ckn-bw setup, per-server config-style files (admins.txt, motd.txt, mapcycle, etc.) lived under bundles/left4dead2/files/scripts/overlays/standard. left4me has no equivalent: today an overlay's contents come from either Steam Workshop (workshop type) or a user-authored bash build script (script type). Both have an external source-of-truth, so neither is the right home for files the user owns directly. The user wants both online editing of text files and arbitrary file upload, and we unify them into a single mechanism.
Goal
Add a third overlay type files whose source-of-truth IS the overlay directory itself. Provide a web UI to:
- Upload any file or whole folder by dragging it onto a folder row in the tree (drag from the OS).
- Move files and folders by dragging rows inside the tree (internal drag).
- Create / edit / rename / replace files through a single modal editor, opened from row buttons. Modal adapts to text or binary content.
- Download files (or zip an entire folder).
- Delete files and empty folders.
- Create new folders explicitly (including nested intermediates in one shot).
Reuse the existing overlayfs / spec / mount / expose_server_cfg pipeline unchanged: a files overlay is a normal overlay attached to blueprints.
Non-goals (v1)
- Per-server overrides (servers still bind to a blueprint without per-instance file changes).
- Concurrency policing when an overlay is in use by a running server. Overlayfs technically calls lower-layer mutation undefined behavior, but L4D2 reads most config at boot, so "edits visible on next start" is acceptable.
- Versioning / undo / history.
- Syntax highlighting (CodeMirror-style). Plain
<textarea>; can add later. - "Save As" copy. The filename input is Save-As.
- Recursive directory delete from the UI.
- Multi-file drop into the binary "replace" zone (single file only).
Approach
Data model
Overlay.type accepts a new value: "files" (in addition to "workshop" and "script"). No schema change needed — Overlay.type is already String(16). The script column stays empty for files overlays; last_build_status is set to "ok" on creation and not otherwise managed. Privacy follows the existing user_id rules unchanged.
BlueprintOverlay and the expose_server_cfg checkbox keep working as-is: a files overlay containing a server.cfg is exposed via the same alias mechanism the 2026-05-08 plan introduced.
Filesystem layout
A files overlay lives at ${LEFT4ME_ROOT}/overlays/{overlay.path}/ like every other overlay. Example contents:
overlays/{id}/
left4dead2/
cfg/
server.cfg
motd.txt
mapcycle.txt
addons/
sourcemod/configs/admins_simple.ini
custom_map.vpk
The InstanceSpec / OverlayRef shape already supports this. The spec builder in l4d2web/services/l4d2_facade.py doesn't need to learn about overlay types, only to keep emitting path (and alias when expose_server_cfg is set).
Builder registration
l4d2web/services/overlay_builders.py::BUILDERS gains a "files" entry whose build() is a no-op that ensures _overlay_root(overlay) exists. The route layer also short-circuits: there is no "rebuild" concept for a files overlay — every save / upload / move / mkdir / delete is immediately authoritative.
Safety helpers
l4d2web/services/overlay_files.py already has safe_resolve_for_listing and safe_resolve_for_download (anchor-and-resolve, refuse .. traversal and symlink-target escapes). Add three siblings using the same pattern:
safe_resolve_for_write(overlay_path_value, sub_path) -> Path— destination path. Refuses emptysub_path, refuses any escape, refuses to overwrite an existing symlink, refuses a path whose parent resolves to a non-directory.safe_resolve_for_delete(overlay_path_value, sub_path) -> Path— same root-escape rules; allows deleting files and empty directories. Non-empty directory delete returns an error.safe_resolve_for_move(overlay_path_value, src, dst) -> tuple[Path, Path]— both endpoints inside the overlay root. Refusesdstinsidesrc(cycle). Refuses ifsrcdoesn't exist. Refuses ifdstparent is missing or not a directory. Refuses overwriting a symlink atdst.
Plus a small predicate:
is_editable(path: Path) -> bool— true iffpathis a regular file (not symlink), size ≤ 1 MiB, and first 8 KiB decodes as strict UTF-8. Surfaced via_entry_dictin listings aseditable: bool.
UI design
The file-manager lives inside the existing overlay detail page, only when overlay.type == "files". Layout follows the existing <ul class="file-tree"> pattern, extended as below.
Tree row buttons (hover-reveal, CSS :hover)
| Row | Buttons (left-to-right) | Click on row body | Draggable |
|---|---|---|---|
| Folder (incl. overlay root) | + new file · + new folder · ⬇ zip · ✕ |
toggle expand/collapse | yes (move subtree) |
| File (any) | edit · ⬇ · ✕ |
nothing | yes (move file) |
Files always show edit regardless of editability — the modal adapts. Touch devices fall back to always-visible buttons via a (hover: none) media query.
Drag-and-drop on tree rows — single gesture, source distinguishes
| Drag source | Action | Visual on hovered row | Endpoint |
|---|---|---|---|
OS file/folder (dataTransfer.files / webkitGetAsEntry) |
upload | green outline + ↑ Release to upload N items here |
POST /overlays/{id}/files/upload |
| Tree row (file or folder) | move | green outline + ↦ Move {name} here |
POST /overlays/{id}/files/move |
Refused drops (UI rejects without server round-trip): drop on self, drop on own ancestor (cycle), drop where parent doesn't exist. Conflict at destination → server returns 409 → overwrite/keep-both modal.
Upload progress panel
Each dropped item becomes one POST /files/upload request (one file part, target_path set to the dropped row's path, webkitRelativePath preserved). A floating "Uploads" panel docks to the bottom-right of the page while there is at least one in-flight or queued upload, and auto-collapses when the queue is empty.
- Per-file rows in the panel: filename, target path (subtle), progress bar driven by
XMLHttpRequest.upload.onprogress, queue position, per-file cancel button. - Concurrency: at most 3 uploads in flight; remainder queue. Drop-while-uploading appends to the queue with no special UI.
- Cancel mid-flight: aborts the XHR; server cleans up any partial file in a
finallyblock. - Conflicts: a 409 on an individual file pauses just that upload (panel row shows "conflict — overwrite / keep both") and opens the existing overwrite/keep-both modal scoped to that one path. The rest of the queue keeps running.
- Errors: per-file error states (413 too large, 415 bad content, 422 path validation, 5xx) stay sticky in the panel until the user dismisses them. The panel has a "clear done" toggle.
- Tree refresh: when an upload finishes, the affected parent folder's listing partial is re-fetched (
hx-geton the folder row). Debounced (50 ms) so many siblings finishing in one tick coalesce into one fetch.
Editor modal — single <dialog> with two flavors
The editor modal opens via the row's edit button or the folder's + new file button.
Common chrome (both flavors):
- Title = full path (e.g.
left4dead2/cfg/motd.txt). For new files:addons/sourcemod/configs/…new file. - Filename input — single line, slashes rejected. Diverging from the original shows an inline
↻ Save will rename foo.txt → bar.txthint. - Footer —
Deleteon the left (only for existing files), then⬇ Download,Cancel,Save/Createon the right.
Text flavor (file is editable, or new file):
- Content
<textarea>, 1 MiB cap on save, UTF-8 only. - Footer hint:
UTF-8 · {n} bytes+Ctrl+S to save.
Binary flavor (existing file is not editable):
- Replaces the textarea with a "Replace file" panel: a label noting
⛌ Inline editing not available · {size} · binary content, plus a drop zone (↑ Drop a file here to replace) with abrowselink as fallback. Single file only. - Once a replacement is queued, the drop zone shows
↻ {newName} · {size} · queuedwith an✕to clear the queue.
Save semantics (atomic per call; rename + content change happen in one server operation):
| Mode | Filename unchanged | Filename changed |
|---|---|---|
| Text | write content | rename + write content |
| Binary, no replacement queued | (Save disabled) | rename only |
| Binary, replacement queued | overwrite content | rename + overwrite content |
Rename target collision → 409 → overwrite/keep-both modal (same modal as upload conflicts).
+ new folder dialog
A small dedicated <dialog> separate from the editor. Single text input for the folder name. Slashes allowed → creates intermediate dirs (mkdir(parents=True, exist_ok=False)).
+ new file flow
Reuses the editor modal in text flavor with empty content; the filename input is empty and focused, the title shows the source folder + …new file.
Web routes
In l4d2web/routes/files_routes.py (alongside the existing overlay_files_fragment and download endpoints):
| Method | Path | Body | Purpose |
|---|---|---|---|
| GET | /overlays/{id}/files/content |
?path= |
Returns {path, content} for an editable file. 415 if not editable. |
| POST | /overlays/{id}/files/save |
JSON {path, content, new_path?} |
Text-mode save. Optional new_path performs rename atomically with the write. |
| POST | /overlays/{id}/files/replace |
multipart path, file, optional new_path |
Binary-mode replace. Optional new_path performs rename atomically. |
| POST | /overlays/{id}/files/upload |
multipart target_path, single file part (carrying webkitRelativePath) |
OS-drag upload, one file per request. Creates intermediate dirs via mkdir(parents=True). Cleans up partial writes on cancel via finally. 200 on success, 409 on conflict, 413/415/422 on validation failure. |
| POST | /overlays/{id}/files/move |
JSON {src, dst} |
Internal drag move (and plain rename when same parent). |
| POST | /overlays/{id}/files/mkdir |
JSON {path} |
Create empty folder; slashes in path produce nested intermediates. |
| POST | /overlays/{id}/files/delete |
form path |
Delete file or empty folder. |
| GET | /overlays/{id}/files/download_zip |
?path= |
Stream a zip of the folder's contents. |
Existing GET /overlays/{id}/files?path=... and GET /overlays/{id}/files/download?path=... stay as-is. The listing endpoint additionally returns editable per file row.
All new routes:
- 404 when
overlay.type != "files". - Require
overlay.user_id == current_user.id(or admin). - Use the new safe-resolve helpers.
- CSRF via the existing
csrf.jsinjection (multipart endpoints included).
Tech stack
Stay inside the project's established stack — Flask + Jinja2 + HTMX + tiny vanilla JS in static/js/ + custom CSS with tokens, no build step:
- Templates: Jinja2 partials, returned as HTMX swaps where appropriate (subtree refresh after upload/move/mkdir/delete).
- Modals: native
<dialog>with the existingdata-modal-open/data-modal-closeevent-delegated handlers. - JS: vanilla. Extend
static/js/file-tree.js(or add a siblingfiles-overlay.js) covering:dragstarton rows,dragoverhighlight + source-discrimination (dataTransfer.types.includes("Files")vs internal MIME),webkitGetAsEntry()walk for whole-folder OS drops, editor modal open/save (Ctrl+S, fetch POST), binary replace-zone drop handler, conflict-modal flow, new-folder dialog, upload queue + floating progress panel (XHR per file, concurrency 3, abort on cancel, debounced tree-refresh on completion). - CSS: extend
tokens.cssandcomponents.csswith file-manager-specific rules — drop-target outline, hover-reveal action column, editor modal sizing, replace-zone styling.
No external libraries (no Dropzone, no jsTree, no CodeMirror) — adding one would be a meaningful departure from the project's "no build step, vendored libs only" posture.
Creation flow for new overlays
The "create overlay" UI gains a third radio option: Files. Selecting it skips the type-specific fields (no Steam Workshop selector, no script editor) and creates an empty Overlay row with type="files", last_build_status="ok", and an empty directory.
Host-side
No changes. The mount helper, instance lifecycle, and srcds startup don't care what produced the contents of an overlay directory.
Migration / Alembic
None. Overlay.type already stores arbitrary strings; introducing a new value is data-only.
Critical files
| Layer | File | Change |
|---|---|---|
| Models | l4d2web/models.py |
None (Overlay.type already String) |
| Builders | l4d2web/services/overlay_builders.py |
Register FilesBuilder (no-op build) |
| Safety | l4d2web/services/overlay_files.py |
Add safe_resolve_for_write, safe_resolve_for_delete, safe_resolve_for_move; add is_editable and surface it via _entry_dict |
| Routes | l4d2web/routes/files_routes.py |
Add content, save, replace, upload, move, mkdir, delete, download_zip endpoints |
| Templates | l4d2web/templates/overlay_detail.html, l4d2web/templates/_overlay_file_tree.html |
Hover-reveal action buttons; data-target-path on folder rows; draggable="true" on file/folder rows; editor modal <dialog> with both flavors; new-folder modal <dialog>; conflict modal <dialog> |
| Static JS | l4d2web/static/js/file-tree.js (extend) or new files-overlay.js |
Drag-drop wiring, modal save, binary replace, mkdir, conflict flow, upload queue + panel |
| Static CSS | l4d2web/static/css/components.css |
Drop-target outline, hover action column, editor modal sizing, replace-zone, upload panel |
| Create form | overlay creation template + route | Add files option to the type radio |
| Spec / facade | l4d2web/services/l4d2_facade.py |
None — already type-agnostic |
| Host spec | l4d2host/spec.py, l4d2host/instances.py |
None |
| Tests | adjacent to each touched module | safe-resolve refusals; is_editable heuristic; CRUD round-trip; ownership; non-files-type 404s; multipart with webkitRelativePath; move refuses cycles; conflict (409); zip stream; mkdir parents |
Verification
- Safety unit tests —
safe_resolve_for_write,_for_delete,_for_movereject..traversal, absolute paths, symlink-target escapes, attempts to overwrite a symlink, non-empty-dir delete, anddstinsidesrc. - Editability heuristic —
is_editablereturns false for files > 1 MiB, symlinks, files with non-UTF-8 bytes in their first 8 KiB. - Editor round-trip (text) — from a folder row, "+ new file" → modal → save creates
left4dead2/cfg/admins.txt; row appears witheditbutton; edit; rename via filename input; delete. - Editor round-trip (binary) — upload a
.vpk, clickedit, queue a replacement file via drop, change filename, Save → rename + replace happen atomically. - Upload single file — drag a file from the OS onto
left4dead2/cfg/; appears with size and download link. - Upload whole folder — drag
addons/sourcemod/from the OS onto the overlay root; nested structure preserved; intermediate directories auto-created. - Conflict on upload — drop a file with a colliding name; overwrite/keep-both modal; both choices behave correctly.
- Move within tree — drag
motd.txtontoaddons/; file moves; tree refreshes. - Move refusals — drag a folder onto itself or a descendant; UI rejects without server round-trip.
- mkdir —
+ new folderwith namesourcemod/configscreates both intermediates; collision returns 409. - Zip download —
⬇ ziponaddons/streams a valid zip containing the subtree. - Mount integration — attach the files overlay to a blueprint, start a server, confirm the files appear under
runtime/{server_id}/merged/.... - server.cfg alias — with
expose_server_cfg=trueand aserver.cfgin the files overlay,exec server_overlay_{id}is auto-injected into the mergedserver.cfg. - Type isolation — every new endpoint returns 404 for
workshopandscriptoverlays. - Browser smoke test — Chromium and Firefox: drag a folder containing nested files into a row; confirm
webkitRelativePatharrives correctly. - Upload progress panel — drop 5 files of mixed sizes; panel shows 3 in flight, 2 queued; per-file progress bars advance; canceling one file aborts that XHR cleanly without affecting the others; partial file is removed server-side; tree refreshes once per parent folder (debounced) when uploads finish.
- End-to-end on the real test box — deploy the branch to
ckn@10.0.4.128via the project's deploy path, then drive the running web UI through theclaude-in-chromeMCP tools end-to-end: create afilesoverlay, attach to a blueprint, exercise every CRUD path, boot a server, confirm the files materialize in the merged mount. Iterate until all paths work without errors.