From 2d3c98866a0d292998953903b3a6e0048da04f7d Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sat, 9 May 2026 18:59:32 +0200 Subject: [PATCH] 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) --- .../specs/2026-05-09-files-overlay-design.md | 220 ++++ l4d2web/routes/files_routes.py | 471 ++++++++- l4d2web/routes/overlay_routes.py | 13 +- l4d2web/services/overlay_builders.py | 20 + l4d2web/services/overlay_files.py | 113 ++ l4d2web/static/css/components.css | 333 ++++++ l4d2web/static/js/files-overlay.js | 968 ++++++++++++++++++ l4d2web/templates/_overlay_file_node.html | 21 +- l4d2web/templates/_overlay_file_tree.html | 2 +- l4d2web/templates/overlay_detail.html | 147 ++- l4d2web/templates/overlays.html | 1 + l4d2web/tests/test_overlay_builders.py | 25 +- l4d2web/tests/test_overlay_files.py | 240 +++++ l4d2web/tests/test_overlay_files_routes.py | 513 ++++++++++ 14 files changed, 3075 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-09-files-overlay-design.md create mode 100644 l4d2web/static/js/files-overlay.js diff --git a/docs/superpowers/specs/2026-05-09-files-overlay-design.md b/docs/superpowers/specs/2026-05-09-files-overlay-design.md new file mode 100644 index 0000000..9a201de --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-files-overlay-design.md @@ -0,0 +1,220 @@ +# 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 ` + +
+ UTF-8 · 0 bytes + Ctrl+S to save +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endif %} {% endblock %} diff --git a/l4d2web/templates/overlays.html b/l4d2web/templates/overlays.html index 2185e50..fd3be8e 100644 --- a/l4d2web/templates/overlays.html +++ b/l4d2web/templates/overlays.html @@ -38,6 +38,7 @@ Type + {% if g.user and g.user.admin %} diff --git a/l4d2web/tests/test_overlay_builders.py b/l4d2web/tests/test_overlay_builders.py index bf3d5e3..e687f69 100644 --- a/l4d2web/tests/test_overlay_builders.py +++ b/l4d2web/tests/test_overlay_builders.py @@ -64,7 +64,7 @@ def _capture_logs(): def test_builders_registry() -> None: - assert set(overlay_builders.BUILDERS) == {"workshop", "script"} + assert set(overlay_builders.BUILDERS) == {"workshop", "script", "files"} def test_registry_excludes_legacy_types() -> None: @@ -72,6 +72,29 @@ def test_registry_excludes_legacy_types() -> None: assert legacy not in overlay_builders.BUILDERS +def test_files_builder_is_idempotent_no_op(monkeypatch, tmp_path) -> None: + """Files builder ensures the overlay directory exists. Running twice + against an already-populated overlay must not clobber its contents.""" + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + overlay = type("O", (), {"id": 42, "name": "files-fixture"})() + out, err, on_stdout, on_stderr = _capture_logs() + + overlay_builders.BUILDERS["files"].build( + overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False + ) + + overlay_dir = tmp_path / "overlays" / "42" + assert overlay_dir.is_dir() + (overlay_dir / "kept.txt").write_text("preserved") + + overlay_builders.BUILDERS["files"].build( + overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False + ) + + assert (overlay_dir / "kept.txt").read_text() == "preserved" + assert err == [] + + def test_registry_unknown_type_raises_keyerror() -> None: with pytest.raises(KeyError): overlay_builders.BUILDERS["nope"] diff --git a/l4d2web/tests/test_overlay_files.py b/l4d2web/tests/test_overlay_files.py index e02e216..6f5c98a 100644 --- a/l4d2web/tests/test_overlay_files.py +++ b/l4d2web/tests/test_overlay_files.py @@ -265,3 +265,243 @@ def test_list_directory_includes_size_human_for_files(overlay_root: Path) -> Non # 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" + + +# ---- safe_resolve_for_write ------------------------------------------------- + + +def test_safe_resolve_for_write_returns_path_under_overlay_root( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_write + + resolved = safe_resolve_for_write("7", "left4dead2/cfg/server.cfg") + + assert resolved == overlay_root / "left4dead2" / "cfg" / "server.cfg" + + +def test_safe_resolve_for_write_rejects_empty_path(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_write + + with pytest.raises(ValueError): + safe_resolve_for_write("7", "") + + +def test_safe_resolve_for_write_rejects_dotdot(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_write + + with pytest.raises(ValueError): + safe_resolve_for_write("7", "../escape.txt") + + +def test_safe_resolve_for_write_rejects_overwriting_symlink( + overlay_root: Path, tmp_path: Path +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_write + + target = tmp_path / "outside.txt" + target.write_text("nope") + (overlay_root / "evil").symlink_to(target) + + with pytest.raises(ValueError): + safe_resolve_for_write("7", "evil") + + +def test_safe_resolve_for_write_rejects_path_with_non_dir_parent( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_write + + (overlay_root / "blocker").write_text("file, not dir") + + with pytest.raises(ValueError): + safe_resolve_for_write("7", "blocker/child.txt") + + +# ---- safe_resolve_for_delete ----------------------------------------------- + + +def test_safe_resolve_for_delete_returns_resolvable_path(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_delete + + target = overlay_root / "cfg" / "server.cfg" + target.parent.mkdir() + target.write_text("x") + + resolved = safe_resolve_for_delete("7", "cfg/server.cfg") + + assert resolved == target + + +def test_safe_resolve_for_delete_rejects_dotdot(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_delete + + with pytest.raises(ValueError): + safe_resolve_for_delete("7", "../neighbour") + + +def test_safe_resolve_for_delete_rejects_empty(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_delete + + with pytest.raises(ValueError): + safe_resolve_for_delete("7", "") + + +def test_safe_resolve_for_delete_rejects_symlink_escaping_root( + overlay_root: Path, tmp_path: Path +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_delete + + outside = tmp_path / "outside-link.txt" + outside.write_text("nope") + (overlay_root / "evil").symlink_to(outside) + + with pytest.raises(ValueError): + safe_resolve_for_delete("7", "evil") + + +# ---- safe_resolve_for_move ------------------------------------------------- + + +def test_safe_resolve_for_move_returns_paths_when_valid(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_move + + src = overlay_root / "motd.txt" + src.write_text("welcome") + (overlay_root / "addons").mkdir() + + src_path, dst_path = safe_resolve_for_move("7", "motd.txt", "addons/motd.txt") + + assert src_path == src + assert dst_path == overlay_root / "addons" / "motd.txt" + + +def test_safe_resolve_for_move_rejects_missing_src(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_move + + with pytest.raises(ValueError): + safe_resolve_for_move("7", "nope.txt", "addons/nope.txt") + + +def test_safe_resolve_for_move_rejects_dst_parent_not_directory( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_move + + (overlay_root / "src.txt").write_text("x") + (overlay_root / "blocker").write_text("file, not dir") + + with pytest.raises(ValueError): + safe_resolve_for_move("7", "src.txt", "blocker/dst.txt") + + +def test_safe_resolve_for_move_rejects_dst_inside_src(overlay_root: Path) -> None: + """Moving a directory into itself or a descendant must fail before any + rename happens.""" + from l4d2web.services.overlay_files import safe_resolve_for_move + + src = overlay_root / "addons" + src.mkdir() + (src / "child").mkdir() + + with pytest.raises(ValueError): + safe_resolve_for_move("7", "addons", "addons/child/addons") + + +def test_safe_resolve_for_move_rejects_overwrite_of_symlink( + overlay_root: Path, tmp_path: Path +) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_move + + (overlay_root / "src.txt").write_text("x") + (overlay_root / "dst").symlink_to(tmp_path / "outside.txt") + + with pytest.raises(ValueError): + safe_resolve_for_move("7", "src.txt", "dst") + + +def test_safe_resolve_for_move_rejects_dotdot(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import safe_resolve_for_move + + (overlay_root / "src.txt").write_text("x") + + with pytest.raises(ValueError): + safe_resolve_for_move("7", "src.txt", "../escape.txt") + + +# ---- is_editable ----------------------------------------------------------- + + +def test_is_editable_true_for_small_utf8_file(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import is_editable + + target = overlay_root / "motd.txt" + target.write_text("Welcome\nHave fun.\n") + + assert is_editable(target) is True + + +def test_is_editable_false_for_oversized_file(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import is_editable + + target = overlay_root / "huge.bin" + # 1 MiB + 1 byte + target.write_bytes(b"a" * (1024 * 1024 + 1)) + + assert is_editable(target) is False + + +def test_is_editable_false_for_binary_content_in_first_8kib( + overlay_root: Path, +) -> None: + from l4d2web.services.overlay_files import is_editable + + target = overlay_root / "fake.vpk" + # Random binary bytes in the sniff window — should fail strict UTF-8. + target.write_bytes(bytes(range(256)) * 40) + + assert is_editable(target) is False + + +def test_is_editable_false_for_symlink(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import is_editable + + real = overlay_root / "real.txt" + real.write_text("hi") + link = overlay_root / "link.txt" + link.symlink_to(real) + + assert is_editable(link) is False + + +def test_is_editable_false_for_directory(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import is_editable + + sub = overlay_root / "subdir" + sub.mkdir() + + assert is_editable(sub) is False + + +def test_entry_dict_marks_editable_for_small_utf8_file(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + (overlay_root / "small.txt").write_text("hello") + (overlay_root / "big.bin").write_bytes(b"\x00" * 200) + + entries, _ = list_directory(overlay_root, overlay_root) + by_name = {e["name"]: e for e in entries} + + assert by_name["small.txt"]["editable"] is True + assert by_name["big.bin"]["editable"] is False + + +def test_entry_dict_marks_directories_not_editable(overlay_root: Path) -> None: + from l4d2web.services.overlay_files import list_directory + + (overlay_root / "subdir").mkdir() + + entries, _ = list_directory(overlay_root, overlay_root) + by_name = {e["name"]: e for e in entries} + + assert by_name["subdir"]["editable"] is False diff --git a/l4d2web/tests/test_overlay_files_routes.py b/l4d2web/tests/test_overlay_files_routes.py index dfdfafa..59984cb 100644 --- a/l4d2web/tests/test_overlay_files_routes.py +++ b/l4d2web/tests/test_overlay_files_routes.py @@ -1,6 +1,7 @@ """HTTP-level tests for the overlay 'Files' tree-fragment + download routes.""" from __future__ import annotations +import io import os from pathlib import Path @@ -493,3 +494,515 @@ def test_server_detail_renders_files_section(app, left4me_root: Path) -> None: assert response.status_code == 200 assert ">Files<" in text assert "left4dead2" in text + + +# ============================================================================ +# Files-overlay mutating endpoints +# ============================================================================ + + +def _make_files_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int: + with session_scope() as s: + overlay = Overlay(name=name, path="", type="files", user_id=user_id, last_build_status="ok") + 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 _csrf_headers(): + return {"X-CSRF-Token": "test-token"} + + +# ---- /content ------------------------------------------------------------- + + +def test_content_returns_text(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_text("welcome") + + client = _client_for(app, user_id) + r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt") + + assert r.status_code == 200 + assert r.get_json() == {"path": "motd.txt", "content": "welcome"} + + +def test_content_returns_415_for_binary(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "image.bin").write_bytes(b"\x00\x01\x02") + + client = _client_for(app, user_id) + r = client.get(f"/overlays/{overlay_id}/files/content?path=image.bin") + + assert r.status_code == 415 + + +def test_content_404_for_non_files_overlay(app, left4me_root: Path) -> None: + """`content` is gated to type=='files' to keep the new editor scoped.""" + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws") # type='script' helper + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_text("hi") + + client = _client_for(app, user_id) + r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt") + + assert r.status_code == 404 + + +# ---- /save --------------------------------------------------------------- + + +def test_save_creates_new_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/save", + json={"path": "left4dead2/cfg/admins.txt", "content": "STEAM_1:0:1\n"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 200 + assert (overlay_dir / "left4dead2" / "cfg" / "admins.txt").read_text() == "STEAM_1:0:1\n" + + +def test_save_overwrites_existing_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_text("old") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/save", + json={"path": "motd.txt", "content": "new"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 200 + assert (overlay_dir / "motd.txt").read_text() == "new" + + +def test_save_with_new_path_renames_atomically(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_text("welcome") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/save", + json={"path": "motd.txt", "new_path": "motd_de.txt", "content": "willkommen"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 200 + assert not (overlay_dir / "motd.txt").exists() + assert (overlay_dir / "motd_de.txt").read_text() == "willkommen" + + +def test_save_rejects_oversized_content(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + + big = "x" * (1024 * 1024 + 1) + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/save", + json={"path": "huge.txt", "content": big}, + headers=_csrf_headers(), + ) + + assert r.status_code == 413 + + +def test_save_rejects_dotdot_path(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/save", + json={"path": "../escape.txt", "content": "x"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 422 + + +def test_save_404_for_non_files_overlay(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/save", + json={"path": "motd.txt", "content": "x"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 404 + + +# ---- /upload ------------------------------------------------------------- + + +def test_upload_writes_file_at_target_path(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/upload", + data={"target_path": "addons", "file": (io.BytesIO(b"vpk-bytes"), "map.vpk")}, + headers=_csrf_headers(), + content_type="multipart/form-data", + ) + + assert r.status_code == 200 + assert (overlay_dir / "addons" / "map.vpk").read_bytes() == b"vpk-bytes" + + +def test_upload_with_relative_path_creates_intermediates(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/upload", + data={ + "target_path": "addons", + "relative_path": "sourcemod/configs/admins.cfg", + "file": (io.BytesIO(b"#admins"), "admins.cfg"), + }, + headers=_csrf_headers(), + content_type="multipart/form-data", + ) + + assert r.status_code == 200 + assert ( + overlay_dir / "addons" / "sourcemod" / "configs" / "admins.cfg" + ).read_bytes() == b"#admins" + + +def test_upload_409_on_existing_without_overwrite(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_bytes(b"existing") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/upload", + data={"target_path": "", "file": (io.BytesIO(b"new"), "motd.txt")}, + headers=_csrf_headers(), + content_type="multipart/form-data", + ) + + assert r.status_code == 409 + assert (overlay_dir / "motd.txt").read_bytes() == b"existing" + + +def test_upload_overwrite_replaces_existing(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_bytes(b"existing") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/upload", + data={"target_path": "", "overwrite": "1", "file": (io.BytesIO(b"new"), "motd.txt")}, + headers=_csrf_headers(), + content_type="multipart/form-data", + ) + + assert r.status_code == 200 + assert (overlay_dir / "motd.txt").read_bytes() == b"new" + + +# ---- /move --------------------------------------------------------------- + + +def test_move_renames_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_text("hi") + (overlay_dir / "addons").mkdir() + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/move", + json={"src": "motd.txt", "dst": "addons/motd.txt"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 200 + assert not (overlay_dir / "motd.txt").exists() + assert (overlay_dir / "addons" / "motd.txt").read_text() == "hi" + + +def test_move_409_on_existing_dst(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "motd.txt").write_text("a") + (overlay_dir / "other.txt").write_text("b") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/move", + json={"src": "motd.txt", "dst": "other.txt"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 409 + assert (overlay_dir / "motd.txt").read_text() == "a" + assert (overlay_dir / "other.txt").read_text() == "b" + + +def test_move_rejects_cycle(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "addons" / "child").mkdir(parents=True) + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/move", + json={"src": "addons", "dst": "addons/child/addons"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 422 + + +# ---- /mkdir -------------------------------------------------------------- + + +def test_mkdir_creates_directory(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/mkdir", + json={"path": "addons/sourcemod/configs"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 200 + assert (overlay_dir / "addons" / "sourcemod" / "configs").is_dir() + + +def test_mkdir_idempotent_on_existing_dir(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "addons").mkdir() + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/mkdir", + json={"path": "addons"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 200 + assert (overlay_dir / "addons").is_dir() + + +def test_mkdir_409_when_path_is_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "blocker").write_text("x") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/mkdir", + json={"path": "blocker"}, + headers=_csrf_headers(), + ) + + assert r.status_code == 409 + + +# ---- /delete ------------------------------------------------------------- + + +def test_delete_removes_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + target = overlay_dir / "motd.txt" + target.write_text("hi") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/delete", + data={"path": "motd.txt", "csrf_token": "test-token"}, + ) + + assert r.status_code == 200 + assert not target.exists() + + +def test_delete_removes_empty_dir(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + target = overlay_dir / "empty-dir" + target.mkdir() + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/delete", + data={"path": "empty-dir", "csrf_token": "test-token"}, + ) + + assert r.status_code == 200 + assert not target.exists() + + +def test_delete_409_on_non_empty_dir(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "addons" / "file.txt").parent.mkdir() + (overlay_dir / "addons" / "file.txt").write_text("x") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/delete", + data={"path": "addons", "csrf_token": "test-token"}, + ) + + assert r.status_code == 409 + assert (overlay_dir / "addons" / "file.txt").exists() + + +# ---- /replace ------------------------------------------------------------- + + +def test_replace_overwrites_file(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "map.vpk").write_bytes(b"old-bytes") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/replace", + data={ + "path": "map.vpk", + "csrf_token": "test-token", + "file": (io.BytesIO(b"new-bytes"), "map.vpk"), + }, + content_type="multipart/form-data", + ) + + assert r.status_code == 200 + assert (overlay_dir / "map.vpk").read_bytes() == b"new-bytes" + + +def test_replace_with_new_path_renames(app, left4me_root: Path) -> None: + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "map.vpk").write_bytes(b"old") + + client = _client_for(app, user_id) + r = client.post( + f"/overlays/{overlay_id}/files/replace", + data={ + "path": "map.vpk", + "new_path": "renamed.vpk", + "csrf_token": "test-token", + "file": (io.BytesIO(b"new"), "ignored"), + }, + content_type="multipart/form-data", + ) + + assert r.status_code == 200 + assert not (overlay_dir / "map.vpk").exists() + assert (overlay_dir / "renamed.vpk").read_bytes() == b"new" + + +# ---- /download_zip -------------------------------------------------------- + + +def test_download_zip_streams_folder_contents(app, left4me_root: Path) -> None: + import zipfile + + user_id = _make_user() + overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files") + overlay_dir = left4me_root / "overlays" / str(overlay_id) + (overlay_dir / "left4dead2" / "cfg").mkdir(parents=True) + (overlay_dir / "left4dead2" / "cfg" / "server.cfg").write_text("hostname x") + (overlay_dir / "left4dead2" / "cfg" / "motd.txt").write_text("welcome") + + client = _client_for(app, user_id) + r = client.get( + f"/overlays/{overlay_id}/files/download_zip?path=left4dead2/cfg" + ) + + assert r.status_code == 200 + assert r.headers["Content-Disposition"].startswith("attachment") + body = io.BytesIO(r.get_data()) + with zipfile.ZipFile(body) as zf: + names = sorted(zf.namelist()) + assert names == ["motd.txt", "server.cfg"] + assert zf.read("motd.txt") == b"welcome" + + +# ---- type-isolation across all new endpoints ------------------------------ + + +def test_mutating_endpoints_404_for_workshop_overlay(app, left4me_root: Path) -> None: + user_id = _make_user() + 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) + headers = _csrf_headers() + paths = [ + ("post", f"/overlays/{overlay_id}/files/save", {"json": {"path": "x.txt", "content": "x"}}), + ("post", f"/overlays/{overlay_id}/files/replace", + {"data": {"path": "x.bin", "csrf_token": "test-token", "file": (io.BytesIO(b"y"), "x.bin")}, + "content_type": "multipart/form-data"}), + ("post", f"/overlays/{overlay_id}/files/upload", + {"data": {"target_path": "", "csrf_token": "test-token", "file": (io.BytesIO(b"y"), "x.bin")}, + "content_type": "multipart/form-data"}), + ("post", f"/overlays/{overlay_id}/files/move", {"json": {"src": "a", "dst": "b"}}), + ("post", f"/overlays/{overlay_id}/files/mkdir", {"json": {"path": "x"}}), + ("post", f"/overlays/{overlay_id}/files/delete", + {"data": {"path": "x", "csrf_token": "test-token"}}), + ("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}), + ("get", f"/overlays/{overlay_id}/files/content?path=x.txt", {}), + ] + for method, url, kwargs in paths: + kwargs = dict(kwargs) + if method == "post" and "json" in kwargs: + kwargs["headers"] = headers + r = getattr(client, method)(url, **kwargs) + assert r.status_code == 404, f"{method.upper()} {url} returned {r.status_code}"