left4me/docs/superpowers/specs/2026-05-09-files-overlay-design.md
mwiegand 2d3c98866a
feat(files-overlay): user-managed file content as a third overlay type
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>
2026-05-09 18:59:32 +02:00

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 empty sub_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. Refuses dst inside src (cycle). Refuses if src doesn't exist. Refuses if dst parent is missing or not a directory. Refuses overwriting a symlink at dst.

Plus a small predicate:

  • is_editable(path: Path) -> bool — true iff path is a regular file (not symlink), size ≤ 1 MiB, and first 8 KiB decodes as strict UTF-8. Surfaced via _entry_dict in listings as editable: 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 finally block.
  • 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-get on 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.txt hint.
  • FooterDelete on the left (only for existing files), then ⬇ Download, Cancel, Save/Create on 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 a browse link as fallback. Single file only.
  • Once a replacement is queued, the drop zone shows ↻ {newName} · {size} · queued with 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.js injection (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 existing data-modal-open / data-modal-close event-delegated handlers.
  • JS: vanilla. Extend static/js/file-tree.js (or add a sibling files-overlay.js) covering: dragstart on rows, dragover highlight + 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.css and components.css with 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

  1. Safety unit testssafe_resolve_for_write, _for_delete, _for_move reject .. traversal, absolute paths, symlink-target escapes, attempts to overwrite a symlink, non-empty-dir delete, and dst inside src.
  2. Editability heuristicis_editable returns false for files > 1 MiB, symlinks, files with non-UTF-8 bytes in their first 8 KiB.
  3. Editor round-trip (text) — from a folder row, "+ new file" → modal → save creates left4dead2/cfg/admins.txt; row appears with edit button; edit; rename via filename input; delete.
  4. Editor round-trip (binary) — upload a .vpk, click edit, queue a replacement file via drop, change filename, Save → rename + replace happen atomically.
  5. Upload single file — drag a file from the OS onto left4dead2/cfg/; appears with size and download link.
  6. Upload whole folder — drag addons/sourcemod/ from the OS onto the overlay root; nested structure preserved; intermediate directories auto-created.
  7. Conflict on upload — drop a file with a colliding name; overwrite/keep-both modal; both choices behave correctly.
  8. Move within tree — drag motd.txt onto addons/; file moves; tree refreshes.
  9. Move refusals — drag a folder onto itself or a descendant; UI rejects without server round-trip.
  10. mkdir+ new folder with name sourcemod/configs creates both intermediates; collision returns 409.
  11. Zip download⬇ zip on addons/ streams a valid zip containing the subtree.
  12. Mount integration — attach the files overlay to a blueprint, start a server, confirm the files appear under runtime/{server_id}/merged/....
  13. server.cfg alias — with expose_server_cfg=true and a server.cfg in the files overlay, exec server_overlay_{id} is auto-injected into the merged server.cfg.
  14. Type isolation — every new endpoint returns 404 for workshop and script overlays.
  15. Browser smoke test — Chromium and Firefox: drag a folder containing nested files into a row; confirm webkitRelativePath arrives correctly.
  16. 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.
  17. End-to-end on the real test box — deploy the branch to ckn@10.0.4.128 via the project's deploy path, then drive the running web UI through the claude-in-chrome MCP tools end-to-end: create a files overlay, 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.