Compare commits

..

20 commits

Author SHA1 Message Date
mwiegand
c594d4b5e8
tests: admin user management
14 tests covering /admin/users/<id>/{deactivate,activate,delete}:

  - deactivate/activate flips and 404 on unknown user
  - deactivate-self refused (409)
  - deactivated user cannot log in (same 401 as wrong-password)
  - existing sessions stop working after deactivation (load_current_user
    returns None for inactive users → @require_login redirects to /login)
  - delete-self refused (409)
  - delete refuses when user owns Server, Blueprint, or custom Overlay
  - delete on orphan succeeds (302 → /admin/users)
  - delete nulls out Job.user_id (jobs survive as audit trail)
  - delete-other-admin succeeds when more than one admin exists

The "last admin" branch in the delete endpoint is defense-in-depth and
unreachable via normal flow (any path that triggers it is shadowed by
self-delete) — covered by a comment, not a test.
2026-05-10 21:19:03 +02:00
mwiegand
bcea450e98
admin: deactivate/activate/delete endpoints for /admin/users
Three new POST endpoints on the existing admin blueprint, all guarded
by @require_admin and CSRF (per the global before_request hook):

  /admin/users/<id>/deactivate  flips active=False (refuses self)
  /admin/users/<id>/activate    flips active=True
  /admin/users/<id>/delete      hard delete with safeties:
    - refuses self-delete
    - refuses delete-of-the-last-admin
    - refuses if the user owns Servers, Blueprints, or custom
      Overlays (operator deletes those first via existing UIs)
    - nulls out Job.user_id (jobs stay as audit trail; FK is nullable)

admin_users.html grows an Active column + an Actions column with the
appropriate button per row (none for self, Deactivate/Activate
toggle, Delete-with-confirmation modal). Modal pattern mirrors
blueprint_detail.html (same modal-close/modal-open data attrs,
csrf_token hidden field).

Refusal responses are 409 with a plain-text body (matches the
blueprint-in-use refusal at blueprint_routes.py:182). No flash
infrastructure introduced; consistent with the rest of the codebase.

All 367 existing tests still pass.
2026-05-10 21:15:52 +02:00
mwiegand
3490be5fb7
auth: reject inactive users at login + invalidate existing sessions
Two-pronged enforcement so deactivation has effect both for fresh
logins and already-issued sessions:

  - load_current_user(): treat User with active=False as logged-out
    (sets g.user=None). Existing sessions stop working immediately.
  - login(): include `not user.active` in the existing 401 condition,
    so deactivated accounts get the same "invalid credentials"
    response as wrong-password / unknown-user — no timing oracle for
    deactivation status.

Tests still green (12/12 in test_auth.py).
2026-05-10 21:13:31 +02:00
mwiegand
726acfa4ff
models: add User.active column for soft-delete (deactivation)
Default true; server_default '1'. Lets the admin UI deactivate a user
without losing the row or the user's content (servers, blueprints,
overlays). Reactivation flips it back. Migration 0008 adds the column
via op.add_column; downgrade uses batch_alter_table per SQLite ALTER
TABLE semantics, matching the 0007 pattern.
2026-05-10 21:12:27 +02:00
mwiegand
0811d22c44
deploy/README: mark as historical reference, point at ckn-bw
ovh.left4me is now provisioned by the ckn-bw bundle bundles/left4me/
(attached via groups/applications/left4me.py); run `bw apply
ovh.left4me` from there.

Keep this directory verbatim as deployment-knowledge reference: what
was configured, what each unit/helper does, why the privileged
boundaries are drawn the way they are. Add a top-of-README
correspondence table marking which files migrated 1:1 vs. which are
obsolete in the new architecture (CAKE moved to systemd-networkd;
nft marking moved into the central nftables bundle; systemd units
are emitted by a metadata reactor; CPU isolation drop-ins are no
longer managed declaratively).

The deploy-test-server.sh stays here too — useful as a concrete walk-
through of the install steps the bundle now performs declaratively.
Just don't run it against an ovh.left4me node managed by ckn-bw; the
two would fight over file ownership, sudoers, and unit definitions.
2026-05-10 18:25:23 +02:00
mwiegand
a987304358
fix(deploy): make iproute2 explicit + document disable recipe
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:29:22 +02:00
mwiegand
9f0b51b455
docs(deploy): document network-shaping defaults + opt-in network knobs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:09:28 +02:00
mwiegand
26f3d270b0
feat(deploy): wire nft marking + CAKE shaper into deploy script
Installs nftables via apt/dnf, copies left4me-mark.nft and left4me-apply-cake
helper into system paths, conditionally seeds cake.env (preserving operator
edits), and enables left4me-nft-mark.service + left4me-cake.service on deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:04:12 +02:00
mwiegand
a9ca90537b
feat(deploy): left4me-cake.service oneshot wrapping apply-cake helper
The CAKE egress shaper now has a systemd unit that wraps the
left4me-apply-cake helper in apply and clear modes. The unit is a
oneshot that starts after network-online and survives service restarts,
allowing the shaper to persist across reboots and be managed by systemd.
The environment file is marked non-fatal (EnvironmentFile=-) to handle
missing or incomplete configurations gracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:58:42 +02:00
mwiegand
878639147a
feat(deploy): left4me-apply-cake helper with apply/clear modes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:52:16 +02:00
mwiegand
d783449d05
feat(deploy): cake.env template with documented uplink knobs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:49:08 +02:00
mwiegand
fbb342db87
feat(deploy): systemd unit to load/clear left4me_mark nftables table
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:35:27 +02:00
mwiegand
076bfb72ca
feat(deploy): nftables uid-based DSCP-EF + skb-priority marking for srcds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:32:53 +02:00
mwiegand
e822e9fbc7
feat(deploy): extend sysctls with udp_*_min, fq_codel default, BBR
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:28:24 +02:00
mwiegand
e1add4fffa
docs(plans): l4d2 network shaping & marking — implementation plan
Eight TDD tasks: sysctl extension, nftables marking (file + unit), CAKE
shaper (env + helper + unit), deploy-script wiring, README. Each task
adds one artifact with its assertion in test_deploy_artifacts.py and
ends in its own commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:10:40 +02:00
mwiegand
0cc92f2c17
docs(specs): l4d2 network shaping & marking — design
CAKE egress shaping (test-deploy oneshot + systemd-networkd [CAKE] block
on prod), nftables uid-based DSCP-EF + skb-priority marking for srcds
UDP, plus rounding sysctls (udp_rmem_min/wmem_min, default_qdisc=fq_codel,
tcp_congestion_control=bbr). Hardware-specific knobs stay documented
escape hatches matching the perf-baseline boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:05:44 +02:00
mwiegand
62d6d4cbcd
ui(files-overlay): label root row as "/" instead of "(overlay root)"
Tighter, more terminal-flavored. Mono font on the label echoes how
paths are rendered elsewhere in the tree. New-folder dialog title
also shows "/" when targeting the root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:50:14 +02:00
mwiegand
2bba1f31d0
fix(files-overlay): post-deploy bug sweep + root-as-row UX
Three bugs surfaced in browser testing, plus one UX request:

1. The Uploads panel and the binary-mode editor sub-panels stayed
   visible after `el.hidden = true` because their `display: flex/grid`
   rules in components.css have the same specificity as the UA's
   `[hidden]{display:none}` and come later in cascade. Add a targeted
   `[hidden]!important` rule for the affected classes.

2. Clicking a folder toggle inside a `files` overlay did nothing.
   `file-tree.js` looked for `.file-tree-children` via
   `button.nextElementSibling`, but the files-overlay row template
   inserts a per-row action span between the toggle and the children
   div. Switch to `closest('.file-tree-row').querySelector(':scope >
   .file-tree-children')` so both row variants resolve correctly.

3. Pressing Enter on the new-folder dialog did nothing — the keydown
   handler was attached with `{once:true}` inside `openNewFolder`,
   so the first letter the user typed consumed the listener and Enter
   never fired. Move the listener to module init so it survives
   subsequent keystrokes and dialog reopenings.

UX: render the overlay root as a row inside the tree (label
"(overlay root)") rather than as a separate toolbar. The root row
carries the same `+ new file · + new folder · ⬇ zip` hover-action
column as every other folder row, so drop-on-row, hover-reveal, and
data-target-path semantics are uniform across the tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:46:19 +02:00
mwiegand
76cd7ddda0
fix(files-overlay): fall back to getAsFile when webkitGetAsEntry returns null
webkitGetAsEntry() only returns an Entry for real OS-originated drag-drops;
synthetic DragEvents (and some browsers without folder-drop support) get
null back. Per-item fallback to getAsFile() keeps single-file drops working
in those cases without sacrificing the whole-folder upload path on real
OS drops.

Caught while end-to-end testing on the deploy box: a programmatically-
dispatched drop fired the listener and reached preventDefault(), but no
upload row appeared because the file collection loop never enqueued.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:11:41 +02:00
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
33 changed files with 5342 additions and 24 deletions

View file

@ -1,4 +1,50 @@
# left4me Deployment
# left4me Deployment — Historical Reference
> **Status: superseded.** Production provisioning of left4me on `ovh.left4me`
> is now driven by [ckn-bw](https://git.sublimity.de/cronekorkn/ckn-bw)
> (`bundles/left4me/`, attached via `groups/applications/left4me.py`). Run
> `bw apply ovh.left4me` from the ckn-bw repo to deploy.
>
> The contents of this directory are kept as deployment-knowledge reference:
> what was configured, what each unit/helper does, why the privileged
> boundaries are drawn the way they are. Some files are now obsolete in the
> ckn-bw architecture (CAKE moved to systemd-networkd via
> `network/<iface>/cake` metadata; nft marking moved into the central
> `nftables/output` set; the systemd units are emitted by the bundle's
> `systemd/units` reactor instead of being shipped as static files). The
> obsolete bits are kept here intact so the original choices and tradeoffs
> remain greppable.
>
> **Don't run `deploy-test-server.sh` against an ovh.left4me node managed by
> ckn-bw** — the two would fight over file ownership, sudoers, and unit
> definitions. The script remains useful as concrete documentation of the
> install steps the bundle now performs declaratively.
## What lives here (and what corresponds to it in ckn-bw)
| Path here | Status under ckn-bw |
|---|---|
| `deploy-test-server.sh` | replaced by `bw apply` |
| `files/etc/sudoers.d/left4me` | shipped verbatim by `bundles/left4me/files/etc/sudoers.d/left4me` (validated with `visudo -cf` via `test_with`) |
| `files/etc/sysctl.d/99-left4me.conf` | shipped verbatim by the bundle |
| `files/etc/left4me/sandbox-resolv.conf` | shipped verbatim by the bundle |
| `files/usr/local/libexec/left4me/{left4me-systemctl,journalctl,overlay,script-sandbox}` | shipped verbatim by the bundle |
| `files/usr/local/lib/systemd/system/left4me-web.service` | emitted by `systemd_units` reactor in `bundles/left4me/metadata.py` (intentional change: `--bind 0.0.0.0:8000``127.0.0.1:8000` because nginx now terminates TLS) |
| `files/usr/local/lib/systemd/system/left4me-server@.service` | emitted by the same reactor |
| `files/usr/local/lib/systemd/system/{l4d2-game,l4d2-build}.slice` | emitted by the same reactor |
| `files/usr/local/lib/systemd/system/left4me-cake.service` | **obsolete** — CAKE applied via systemd-networkd (`network/<iface>/cake` metadata in `bundles/network/`) |
| `files/usr/local/libexec/left4me/left4me-apply-cake` | **obsolete** — same as above |
| `files/etc/left4me/cake.env` | **obsolete** — bandwidth lives in node metadata under `network/external/cake/Bandwidth` |
| `files/usr/local/lib/systemd/system/left4me-nft-mark.service` | **obsolete** — central `bundles/nftables/` consumes the rules from `bundles/left4me/`'s defaults |
| `files/usr/local/lib/left4me/nft/left4me-mark.nft` | **obsolete** — same as above |
| `templates/etc/left4me/host.env` | rendered as Mako by `bundles/left4me/files/etc/left4me/host.env.mako` |
| `templates/etc/left4me/web.env.template` | rendered as Mako by `bundles/left4me/files/etc/left4me/web.env.mako` (intentional change: `SESSION_COOKIE_SECURE=false``true`, plus `LEFT4ME_PORT_RANGE_*` are now wired through) |
| First-run admin bootstrap (`flask create-user … --admin` near the end of `deploy-test-server.sh`) | manual one-time step after `bw apply`; the bundle deliberately doesn't seed an admin to keep credentials out of the metadata pipeline |
| CPU isolation drop-ins (`/etc/systemd/system/{system,user,l4d2-game,l4d2-build}.slice.d/99-left4me-cpuset.conf`) | **not managed by the bundle** — generated dynamically based on `nproc --all` in the script; that logic doesn't fit static bundle metadata, apply manually post-deploy if needed |
---
## Original notes (still accurate as a description of the install steps)
This directory contains the production-like test deployment for a Linux server. It installs the repository into a fixed host layout, configures a dedicated runtime user, installs systemd units, and wires the web app to host operations through privileged helper commands.
@ -78,6 +124,61 @@ The deployment ships a host-side perf baseline (slices, unit directives, sysctls
The following knobs are documented escape hatches — they are **not** auto-applied. Apply only if you have measured a need and understand the failure modes.
### Network shaping
The deploy ships three things that affect player-experience network behaviour:
1. **Per-flow marking.** `left4me-nft-mark.service` loads a small nftables
table (`inet left4me_mark`) that marks every UDP packet from uid `left4me`
with DSCP EF and `skb->priority` 6. srcds doesn't set these itself, so
without this rule its UDP is indistinguishable from any other flow.
2. **Sysctl baseline.** `99-left4me.conf` sets `udp_rmem_min=16384`,
`udp_wmem_min=16384`, `default_qdisc=fq_codel`, and
`tcp_congestion_control=bbr`. Reduces head-of-line blocking when bulk
TCP egress (backups, package fetches, web responses) coexists with
game UDP.
3. **CAKE egress shaping.** `left4me-cake.service` runs
`tc qdisc replace dev <iface> root cake bandwidth Xmbit internet
diffserv4 dual-dsthost` from `/etc/left4me/cake.env`. CAKE only shapes
if its declared bandwidth is **below** the real bottleneck, so set
`LEFT4ME_UPLINK_MBIT` to ≈95% of measured uplink:
sudoedit /etc/left4me/cake.env
# set LEFT4ME_UPLINK_MBIT=480 (or whatever ~95% of your uplink is)
sudo systemctl restart left4me-cake.service
`LEFT4ME_UPLINK_IFACE` is auto-detected from the IPv4 default route;
override only on hosts with multi-homed setups.
At idle 500 Mbit with no competing egress, CAKE shapes nothing — that's
expected, not a bug. The win materialises when bulk traffic on the
same uplink would otherwise bufferbloat the link the players share.
**Production hosts running `systemd-networkd`** should NOT use the
`left4me-cake.service` oneshot. Instead, configure the equivalent in the
matching `.network` file, which systemd-networkd reapplies across iface
lifecycle events:
# /etc/systemd/network/<your-uplink>.network
[CAKE]
Bandwidth=480M
OverheadKeyword=internet
PriorityQueueingPreset=diffserv4
EgressHostIsolation=yes
The nftables marking from (1) is qdisc-installer-agnostic and ships
unchanged on production.
**Disabling network shaping.** To turn the whole feature off on a deployed
host:
sudo systemctl stop left4me-cake.service left4me-nft-mark.service
sudo systemctl disable left4me-cake.service left4me-nft-mark.service
The sysctl baseline (`99-left4me.conf`) and the BBR/fq_codel defaults stay
applied; revert those by removing the file and running `sysctl --system`
if needed.
### CPU governor
The performance governor squeezes a few percent off jitter under bursty load. `schedutil` is acceptable for sustained UDP workloads.
@ -144,6 +245,31 @@ AmbientCapabilities=CAP_SYS_NICE
The `AmbientCapabilities=CAP_SYS_NICE` line is needed because the service runs as `User=left4me` with `NoNewPrivileges=true`; without it some kernels/systemd combinations refuse to apply the RT policy.
### Additional opt-in network knobs
- **Ingress shaping via IFB.** Egress CAKE alone does not protect srcds
receive against ingress saturation (large workshop downloads, package
fetches arriving at line rate). One-liner:
sudo modprobe ifb && sudo ip link set ifb0 up
sudo tc qdisc add dev <uplink> handle ffff: ingress
sudo tc filter add dev <uplink> parent ffff: protocol ip u32 \
match u32 0 0 action mirred egress redirect dev ifb0
sudo tc qdisc add dev ifb0 root cake bandwidth Xmbit ingress \
diffserv4 dual-srchost
Worth flipping only when measurement shows ingress hurting receive.
- **`net.core.busy_poll = 50` / `net.core.busy_read = 50`.** Reduces UDP
receive median latency by polling for incoming packets briefly at
syscall boundaries. Cost: measurable CPU per syscall under load. Worth
flipping if a host is dedicated to game serving and CPU headroom is
plentiful.
- **`ethtool -K <iface> gro off`.** Some Source-engine ops disable
generic receive offload to avoid receive-side coalescing latency.
Hardware/driver dependent; document only.
### Applying changes to running servers
Unit-file changes do not apply to already-running services. After any change:

View file

@ -85,9 +85,9 @@ fi
if command -v apt-get >/dev/null 2>&1; then
$sudo_cmd apt-get update
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full nftables iproute2
elif command -v dnf >/dev/null 2>&1; then
$sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip p7zip-plugins
$sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip p7zip-plugins nftables iproute
else
printf 'Unsupported package manager: expected apt-get or dnf\n' >&2
exit 1
@ -98,6 +98,7 @@ $sudo_cmd mkdir -p \
/opt/left4me \
/usr/local/lib/systemd/system \
/usr/local/libexec/left4me \
/usr/local/lib/left4me/nft \
/var/lib/left4me/installation \
/var/lib/left4me/overlays \
/var/lib/left4me/instances \
@ -138,6 +139,8 @@ $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-game.slice /usr/local/lib/systemd/system/l4d2-game.slice
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-build.slice /usr/local/lib/systemd/system/l4d2-build.slice
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service
# CPU isolation via cgroup-v2 AllowedCPUs= drop-ins. Pin everything that
# isn't a live game server to core 0; give game servers cores 1..N-1.
@ -176,7 +179,8 @@ $sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemc
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-overlay
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox /usr/local/libexec/left4me/left4me-script-sandbox
$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-script-sandbox
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-apply-cake /usr/local/libexec/left4me/left4me-apply-cake
$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-script-sandbox /usr/local/libexec/left4me/left4me-apply-cake
$sudo_cmd cp /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me
$sudo_cmd chmod 0440 /etc/sudoers.d/left4me
$sudo_cmd visudo -cf /etc/sudoers.d/left4me
@ -190,6 +194,12 @@ $sudo_cmd install -m 0644 -o root -g root \
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
/etc/left4me/sandbox-resolv.conf
# Network packet marking + shaping. See spec
# docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
$sudo_cmd install -m 0644 -o root -g root \
/opt/left4me/deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft \
/usr/local/lib/left4me/nft/left4me-mark.nft
# Host perf-baseline sysctls. Apply with `sysctl --system` so values
# take effect this deploy, not on next reboot.
$sudo_cmd install -m 0644 -o root -g root \
@ -197,6 +207,16 @@ $sudo_cmd install -m 0644 -o root -g root \
/etc/sysctl.d/99-left4me.conf
$sudo_cmd sysctl --system >/dev/null
# CAKE config: ship the template only if the operator hasn't created one
# (their LEFT4ME_UPLINK_MBIT value must survive re-deploys).
if [ -e /etc/left4me/cake.env ]; then
: # operator file present; leave it intact
else
$sudo_cmd install -m 0644 -o root -g root \
/opt/left4me/deploy/files/etc/left4me/cake.env \
/etc/left4me/cake.env
fi
# 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.
@ -313,6 +333,8 @@ run_left4me_with_env env \
seed-script-overlays /opt/left4me/examples/script-overlays
$sudo_cmd systemctl daemon-reload
$sudo_cmd systemctl enable --now left4me-nft-mark.service
$sudo_cmd systemctl enable --now left4me-cake.service
$sudo_cmd systemctl enable --now left4me-web.service
$sudo_cmd systemctl restart left4me-web.service
for attempt in 1 2 3 4 5 6 7 8 9 10; do

View file

@ -0,0 +1,12 @@
# left4me — CAKE egress shaper config. Consumed by left4me-cake.service via
# its EnvironmentFile=. Edit then `systemctl restart left4me-cake.service`.
# See docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
# Uplink bandwidth in Mbit/s. Set to ~95% of the smaller of measured upload
# and measured download. CAKE only shapes correctly when its declared
# bandwidth sits below the real bottleneck. If unset, the shaper unit logs
# a warning and exits 0 (no shaping).
LEFT4ME_UPLINK_MBIT=
# Egress interface. If unset, auto-detected from the IPv4 default route.
LEFT4ME_UPLINK_IFACE=

View file

@ -19,3 +19,18 @@ net.core.netdev_budget = 600
# Latency-sensitive default: avoid swap unless the box is really under
# pressure. Harmless on swapless hosts.
vm.swappiness = 10
# Per-socket UDP buffer floors: protect game-server sockets that don't bump
# their own SO_RCVBUF/SO_SNDBUF when softirq drains lag briefly.
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
# Default qdisc for ifaces we don't explicitly shape with CAKE. Debian Trixie
# already defaults to fq_codel; setting it explicitly is belt-and-suspenders
# and survives kernel-default churn.
net.core.default_qdisc = fq_codel
# TCP congestion control: BBR for any bulk TCP egress on the host (admin SSH,
# backups, package fetches, web-app responses) so a long flow does not push
# the bottleneck queue ahead of game UDP. UDP srcds is unaffected.
net.ipv4.tcp_congestion_control = bbr

View file

@ -0,0 +1,12 @@
# left4me — uid-based DSCP/priority marking for srcds UDP egress.
# Loaded by left4me-nft-mark.service into its own `inet` table so it cannot
# conflict with whatever the operator already runs in /etc/nftables.conf.
# See docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
table inet left4me_mark {
chain mangle_output {
type filter hook output priority mangle; policy accept;
meta skuid "left4me" meta l4proto udp ip dscp set ef meta priority set 0006:0000
meta skuid "left4me" meta l4proto udp ip6 dscp set ef meta priority set 0006:0000
}
}

View file

@ -0,0 +1,14 @@
[Unit]
Description=left4me CAKE egress shaper
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
EnvironmentFile=-/etc/left4me/cake.env
ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply
ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=left4me nftables packet marking (DSCP EF + priority for srcds)
After=network-pre.target
Before=network.target
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /usr/local/lib/left4me/nft/left4me-mark.nft
ExecStop=/usr/sbin/nft delete table inet left4me_mark
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,47 @@
#!/bin/sh
# left4me — apply or clear CAKE egress shaper on the configured uplink.
# Driven by left4me-cake.service. See spec
# docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
set -eu
mode=${1:-apply}
if [ -r /etc/left4me/cake.env ]; then
. /etc/left4me/cake.env
fi
resolve_iface() {
if [ -n "${LEFT4ME_UPLINK_IFACE:-}" ]; then
printf '%s' "$LEFT4ME_UPLINK_IFACE"
return
fi
ip -4 route show default | awk '/default/ {print $5; exit}'
}
case "$mode" in
apply)
if [ -z "${LEFT4ME_UPLINK_MBIT:-}" ]; then
echo "left4me-cake: LEFT4ME_UPLINK_MBIT unset; skipping shaper" >&2
exit 0
fi
iface=$(resolve_iface)
if [ -z "$iface" ]; then
echo "left4me-cake: cannot determine egress iface; skipping" >&2
exit 0
fi
exec tc qdisc replace dev "$iface" root cake \
bandwidth "${LEFT4ME_UPLINK_MBIT}mbit" \
internet diffserv4 dual-dsthost
;;
clear)
iface=$(resolve_iface)
if [ -z "$iface" ]; then
exit 0
fi
tc qdisc del dev "$iface" root 2>/dev/null || true
;;
*)
echo "usage: $0 [apply|clear]" >&2
exit 2
;;
esac

View file

@ -14,16 +14,21 @@ BUILD_SLICE = DEPLOY / "files/usr/local/lib/systemd/system/l4d2-build.slice"
SYSCTL_CONF = DEPLOY / "files/etc/sysctl.d/99-left4me.conf"
GLOBAL_REFRESH_SERVICE = DEPLOY / "files/usr/local/lib/systemd/system/left4me-refresh-global-overlays.service"
GLOBAL_REFRESH_TIMER = DEPLOY / "files/usr/local/lib/systemd/system/left4me-refresh-global-overlays.timer"
NFT_MARK_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-nft-mark.service"
CAKE_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-cake.service"
SANDBOX_UNIT_DIR = DEPLOY / "files/usr/local/lib/systemd/system"
SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay"
SCRIPT_SANDBOX_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-script-sandbox"
APPLY_CAKE_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-apply-cake"
SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf"
CAKE_ENV = DEPLOY / "files/etc/left4me/cake.env"
SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
DEPLOY_SCRIPT = DEPLOY / "deploy-test-server.sh"
NFT_MARK_FILE = DEPLOY / "files/usr/local/lib/left4me/nft/left4me-mark.nft"
def test_global_unit_files_exist_at_product_level_paths():
@ -207,6 +212,10 @@ def test_sysctl_conf_present_with_perf_settings():
"net.core.netdev_max_backlog = 5000",
"net.core.netdev_budget = 600",
"vm.swappiness = 10",
"net.ipv4.udp_rmem_min = 16384",
"net.ipv4.udp_wmem_min = 16384",
"net.core.default_qdisc = fq_codel",
"net.ipv4.tcp_congestion_control = bbr",
):
assert line in text, f"missing {line!r} in 99-left4me.conf"
@ -708,3 +717,141 @@ def test_script_sandbox_helper_dry_run_mode(tmp_path):
# verify the dry-run guard short-circuits before systemd-run / bwrap.
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
assert 'exit 0' in helper_text
def test_nft_mark_file_marks_left4me_udp_with_dscp_ef_and_priority():
assert NFT_MARK_FILE.is_file()
text = NFT_MARK_FILE.read_text()
# Own table in the inet family so it cannot conflict with operator nftables config.
assert "table inet left4me_mark" in text
assert "chain mangle_output" in text
assert "type filter hook output priority mangle" in text
# Match by uid (every srcds runs as `left4me`) restricted to UDP.
assert 'meta skuid "left4me"' in text
assert "meta l4proto udp" in text
# DSCP EF for both L3 families; in `inet` tables, `ip` only fires on v4
# and `ip6` only on v6.
assert "ip dscp set ef" in text
assert "ip6 dscp set ef" in text
# skb->priority class 6:0, set inline alongside DSCP.
assert "meta priority set 0006:0000" in text
def test_nft_mark_unit_loads_and_clears_left4me_table():
assert NFT_MARK_UNIT.is_file()
text = NFT_MARK_UNIT.read_text()
# Loads the rules early so the very first packet srcds emits is marked.
assert "After=network-pre.target" in text
assert "Before=network.target" in text
assert "Wants=network-pre.target" in text
# Oneshot lifecycle: load on start, drop on stop.
assert "Type=oneshot" in text
assert "RemainAfterExit=yes" in text
assert (
"ExecStart=/usr/sbin/nft -f /usr/local/lib/left4me/nft/left4me-mark.nft"
in text
)
assert "ExecStop=/usr/sbin/nft delete table inet left4me_mark" in text
assert "WantedBy=multi-user.target" in text
def test_cake_env_template_documents_required_knobs():
assert CAKE_ENV.is_file()
text = CAKE_ENV.read_text()
# Both knobs are documented and present (commented OK; the deploy preserves
# operator edits, so the template must not bake in a wrong value).
assert "LEFT4ME_UPLINK_MBIT" in text
assert "LEFT4ME_UPLINK_IFACE" in text
# Empty defaults: shaper unit no-ops with a journal warning when unset.
assert "LEFT4ME_UPLINK_MBIT=" in text
assert "LEFT4ME_UPLINK_IFACE=" in text
def test_apply_cake_helper_supports_apply_and_clear_modes():
assert APPLY_CAKE_HELPER.is_file()
text = APPLY_CAKE_HELPER.read_text()
assert text.startswith("#!/bin/sh")
# Both knobs are read from the env file.
assert "LEFT4ME_UPLINK_MBIT" in text
assert "LEFT4ME_UPLINK_IFACE" in text
assert ". /etc/left4me/cake.env" in text
# Iface fallback to default route.
assert "ip -4 route show default" in text
# Two modes; default to apply.
assert "mode=${1:-apply}" in text
assert 'apply)' in text and 'clear)' in text
# Apply: idempotent `tc qdisc replace` with the documented flags.
assert "tc qdisc replace" in text
assert "cake" in text
assert "bandwidth" in text
assert "internet" in text
assert "diffserv4" in text
assert "dual-dsthost" in text
# Clear: tolerates a missing qdisc.
assert "tc qdisc del" in text
assert "|| true" in text
# Fail-soft on missing config.
assert "LEFT4ME_UPLINK_MBIT unset" in text
def test_apply_cake_helper_passes_shell_syntax_check():
subprocess.run(["sh", "-n", str(APPLY_CAKE_HELPER)], check=True)
def test_cake_unit_runs_helper_in_apply_and_clear_modes():
assert CAKE_UNIT.is_file()
text = CAKE_UNIT.read_text()
assert "After=network-online.target" in text
assert "Wants=network-online.target" in text
assert "Type=oneshot" in text
assert "RemainAfterExit=yes" in text
# `-` prefix: missing env file is non-fatal (deploy ships one, but be safe).
assert "EnvironmentFile=-/etc/left4me/cake.env" in text
assert (
"ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply" in text
)
assert (
"ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear" in text
)
assert "WantedBy=multi-user.target" in text
def test_deploy_script_installs_network_shaping_artifacts():
script = DEPLOY_SCRIPT.read_text()
# nftables: package install on both apt and dnf paths.
apt_lines = [l for l in script.splitlines() if "apt-get install" in l]
dnf_lines = [l for l in script.splitlines() if "dnf install" in l]
assert apt_lines and dnf_lines
for line in apt_lines:
assert "nftables" in line, line
for line in dnf_lines:
assert "nftables" in line, line
# nft rules + unit copied to system paths.
assert "/usr/local/lib/left4me/nft/left4me-mark.nft" in script
assert (
"/usr/local/lib/systemd/system/left4me-nft-mark.service" in script
)
assert "systemctl enable --now left4me-nft-mark.service" in script
# CAKE helper + unit copied; helper made executable.
assert "/usr/local/libexec/left4me/left4me-apply-cake" in script
assert (
"/usr/local/lib/systemd/system/left4me-cake.service" in script
)
assert "chmod 0755" in script and "left4me-apply-cake" in script
assert "systemctl enable --now left4me-cake.service" in script
# cake.env: copied only if absent (operator edits survive re-deploys).
assert "/etc/left4me/cake.env" in script
assert "[ -e /etc/left4me/cake.env ]" in script

View file

@ -0,0 +1,895 @@
# L4D2 Network Shaping & Marking Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a network-side player-experience baseline alongside the existing host perf baseline: nftables uid-based DSCP-EF + skb-priority marking for srcds UDP, rounding sysctls (`udp_rmem_min`/`wmem_min`, `default_qdisc=fq_codel`, `tcp_congestion_control=bbr`), and CAKE egress shaping via a systemd oneshot driven by an operator-edited env file. Production hosts running `systemd-networkd` consume an equivalent `[CAKE]` section documented in the README.
**Architecture:** Eight ship-ready artifacts under `deploy/files/...`, wired into `deploy-test-server.sh`, asserted in `deploy/tests/test_deploy_artifacts.py`, and documented in `deploy/README.md`. Each artifact is a separate, independently-testable file. The CAKE helper takes an `apply`/`clear` mode argument so the unit's `ExecStart`/`ExecStop` are clean shell calls without escape soup.
**Tech Stack:** sysctl, nftables (`inet` table, output hook, mangle priority), tc-cake, systemd oneshot units, POSIX `/bin/sh` for the helper, pytest substring assertions.
**Spec:** `docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md`.
---
## File Structure
**New files (`deploy/files/...`):**
- `usr/local/lib/left4me/nft/left4me-mark.nft` — nftables ruleset, own `inet` table.
- `usr/local/lib/systemd/system/left4me-nft-mark.service` — applies/removes the table.
- `etc/left4me/cake.env` — operator-edited template (deploy preserves edits).
- `usr/local/libexec/left4me/left4me-apply-cake` — POSIX shell helper, `apply`/`clear` modes.
- `usr/local/lib/systemd/system/left4me-cake.service` — runs the helper at network-online, clears on stop.
**Modified files:**
- `deploy/files/etc/sysctl.d/99-left4me.conf` — append four new directives.
- `deploy/deploy-test-server.sh` — add `nftables iproute2` to apt/dnf install lines, copy the new artifacts, conditional cake.env copy, enable the two new units.
- `deploy/README.md` — Network shaping subsection + three new escape hatches (IFB ingress, busy_poll, GRO).
- `deploy/tests/test_deploy_artifacts.py` — add path constants and assertions.
Each task adds (or extends) one artifact and the matching test, ending in a commit. Order matters: sysctl extension first (smallest, isolated), then the nftables pair, then the CAKE pair, then deploy-script wiring (depends on every prior task), then README.
---
### Task 1: Sysctl additions to `99-left4me.conf`
**Files:**
- Modify: `deploy/files/etc/sysctl.d/99-left4me.conf` (append block)
- Modify: `deploy/tests/test_deploy_artifacts.py:199-211` (extend existing `test_sysctl_conf_present_with_perf_settings`)
- [ ] **Step 1: Extend the existing sysctl test with the new lines.**
In `deploy/tests/test_deploy_artifacts.py`, edit `test_sysctl_conf_present_with_perf_settings` to append four lines to the tuple it already iterates:
```python
def test_sysctl_conf_present_with_perf_settings():
assert SYSCTL_CONF.is_file()
text = SYSCTL_CONF.read_text()
for line in (
"net.core.rmem_max = 8388608",
"net.core.wmem_max = 8388608",
"net.core.rmem_default = 524288",
"net.core.wmem_default = 524288",
"net.core.netdev_max_backlog = 5000",
"net.core.netdev_budget = 600",
"vm.swappiness = 10",
"net.ipv4.udp_rmem_min = 16384",
"net.ipv4.udp_wmem_min = 16384",
"net.core.default_qdisc = fq_codel",
"net.ipv4.tcp_congestion_control = bbr",
):
assert line in text, f"missing {line!r} in 99-left4me.conf"
```
- [ ] **Step 2: Run the test to verify it fails.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_sysctl_conf_present_with_perf_settings -v
```
Expected: FAIL — `AssertionError: missing 'net.ipv4.udp_rmem_min = 16384' in 99-left4me.conf`.
- [ ] **Step 3: Append the new block to `99-left4me.conf`.**
Open `deploy/files/etc/sysctl.d/99-left4me.conf` and append (after the existing `vm.swappiness = 10` line):
```
# Per-socket UDP buffer floors: protect game-server sockets that don't bump
# their own SO_RCVBUF/SO_SNDBUF when softirq drains lag briefly.
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
# Default qdisc for ifaces we don't explicitly shape with CAKE. Debian Trixie
# already defaults to fq_codel; setting it explicitly is belt-and-suspenders
# and survives kernel-default churn.
net.core.default_qdisc = fq_codel
# TCP congestion control: BBR for any bulk TCP egress on the host (admin SSH,
# backups, package fetches, web-app responses) so a long flow does not push
# the bottleneck queue ahead of game UDP. UDP srcds is unaffected.
net.ipv4.tcp_congestion_control = bbr
```
- [ ] **Step 4: Run the test again to verify it passes.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_sysctl_conf_present_with_perf_settings -v
```
Expected: PASS.
- [ ] **Step 5: Commit.**
```
git add deploy/files/etc/sysctl.d/99-left4me.conf deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): extend sysctls with udp_*_min, fq_codel default, BBR"
```
---
### Task 2: nftables marking file
**Files:**
- Create: `deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft`
- Modify: `deploy/tests/test_deploy_artifacts.py` (add path constant + new test function)
- [ ] **Step 1: Add the path constant and a failing test.**
In `deploy/tests/test_deploy_artifacts.py`, add the constant near the existing path constants block (around line 26, after `DEPLOY_SCRIPT`):
```python
NFT_MARK_FILE = DEPLOY / "files/usr/local/lib/left4me/nft/left4me-mark.nft"
```
Append this test function to the bottom of the file:
```python
def test_nft_mark_file_marks_left4me_udp_with_dscp_ef_and_priority():
assert NFT_MARK_FILE.is_file()
text = NFT_MARK_FILE.read_text()
# Own table in the inet family so it cannot conflict with operator nftables config.
assert "table inet left4me_mark" in text
assert "chain mangle_output" in text
assert "type filter hook output priority mangle" in text
# Match by uid (every srcds runs as `left4me`) restricted to UDP.
assert 'meta skuid "left4me"' in text
assert "meta l4proto udp" in text
# DSCP EF for both L3 families; in `inet` tables, `ip` only fires on v4
# and `ip6` only on v6.
assert "ip dscp set ef" in text
assert "ip6 dscp set ef" in text
# skb->priority class 6:0, set inline alongside DSCP.
assert "meta priority set 0006:0000" in text
```
- [ ] **Step 2: Run the new test and confirm it fails.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_nft_mark_file_marks_left4me_udp_with_dscp_ef_and_priority -v
```
Expected: FAIL — `AssertionError: assert False` on `NFT_MARK_FILE.is_file()`.
- [ ] **Step 3: Create the directory and write the nftables file.**
```
mkdir -p deploy/files/usr/local/lib/left4me/nft
```
Write `deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft`:
```nft
# left4me — uid-based DSCP/priority marking for srcds UDP egress.
# Loaded by left4me-nft-mark.service into its own `inet` table so it cannot
# conflict with whatever the operator already runs in /etc/nftables.conf.
# See docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
table inet left4me_mark {
chain mangle_output {
type filter hook output priority mangle; policy accept;
meta skuid "left4me" meta l4proto udp ip dscp set ef meta priority set 0006:0000
meta skuid "left4me" meta l4proto udp ip6 dscp set ef meta priority set 0006:0000
}
}
```
- [ ] **Step 4: Re-run the test and confirm it passes.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_nft_mark_file_marks_left4me_udp_with_dscp_ef_and_priority -v
```
Expected: PASS.
- [ ] **Step 5: Commit.**
```
git add deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): nftables uid-based DSCP-EF + skb-priority marking for srcds"
```
---
### Task 3: nftables systemd unit
**Files:**
- Create: `deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service`
- Modify: `deploy/tests/test_deploy_artifacts.py` (path constant + test)
- [ ] **Step 1: Add the path constant and a failing test.**
Append the constant near the existing systemd-unit constants (around line 16):
```python
NFT_MARK_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-nft-mark.service"
```
Append the test:
```python
def test_nft_mark_unit_loads_and_clears_left4me_table():
assert NFT_MARK_UNIT.is_file()
text = NFT_MARK_UNIT.read_text()
# Loads the rules early so the very first packet srcds emits is marked.
assert "After=network-pre.target" in text
assert "Before=network.target" in text
assert "Wants=network-pre.target" in text
# Oneshot lifecycle: load on start, drop on stop.
assert "Type=oneshot" in text
assert "RemainAfterExit=yes" in text
assert (
"ExecStart=/usr/sbin/nft -f /usr/local/lib/left4me/nft/left4me-mark.nft"
in text
)
assert "ExecStop=/usr/sbin/nft delete table inet left4me_mark" in text
assert "WantedBy=multi-user.target" in text
```
- [ ] **Step 2: Run the test and confirm FAIL.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_nft_mark_unit_loads_and_clears_left4me_table -v
```
Expected: FAIL — `assert False` on `NFT_MARK_UNIT.is_file()`.
- [ ] **Step 3: Write the unit file.**
`deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service`:
```ini
[Unit]
Description=left4me nftables packet marking (DSCP EF + priority for srcds)
After=network-pre.target
Before=network.target
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /usr/local/lib/left4me/nft/left4me-mark.nft
ExecStop=/usr/sbin/nft delete table inet left4me_mark
[Install]
WantedBy=multi-user.target
```
- [ ] **Step 4: Re-run the test and confirm PASS.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_nft_mark_unit_loads_and_clears_left4me_table -v
```
Expected: PASS.
- [ ] **Step 5: Commit.**
```
git add deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): systemd unit to load/clear left4me_mark nftables table"
```
---
### Task 4: CAKE env template
**Files:**
- Create: `deploy/files/etc/left4me/cake.env`
- Modify: `deploy/tests/test_deploy_artifacts.py` (path constant + test)
- [ ] **Step 1: Add path constant and failing test.**
Append the constant near the other `/etc/left4me` constants (around line 22):
```python
CAKE_ENV = DEPLOY / "files/etc/left4me/cake.env"
```
Append the test:
```python
def test_cake_env_template_documents_required_knobs():
assert CAKE_ENV.is_file()
text = CAKE_ENV.read_text()
# Both knobs are documented and present (commented OK; the deploy preserves
# operator edits, so the template must not bake in a wrong value).
assert "LEFT4ME_UPLINK_MBIT" in text
assert "LEFT4ME_UPLINK_IFACE" in text
# Empty defaults: shaper unit no-ops with a journal warning when unset.
assert "LEFT4ME_UPLINK_MBIT=" in text
assert "LEFT4ME_UPLINK_IFACE=" in text
```
- [ ] **Step 2: Run and confirm FAIL.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_cake_env_template_documents_required_knobs -v
```
Expected: FAIL on `CAKE_ENV.is_file()`.
- [ ] **Step 3: Write the env template.**
`deploy/files/etc/left4me/cake.env`:
```
# left4me — CAKE egress shaper config. Consumed by left4me-cake.service via
# its EnvironmentFile=. Edit then `systemctl restart left4me-cake.service`.
# See docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
# Uplink bandwidth in Mbit/s. Set to ~95% of the smaller of measured upload
# and measured download. CAKE only shapes correctly when its declared
# bandwidth sits below the real bottleneck. If unset, the shaper unit logs
# a warning and exits 0 (no shaping).
LEFT4ME_UPLINK_MBIT=
# Egress interface. If unset, auto-detected from the IPv4 default route.
LEFT4ME_UPLINK_IFACE=
```
- [ ] **Step 4: Re-run and confirm PASS.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_cake_env_template_documents_required_knobs -v
```
Expected: PASS.
- [ ] **Step 5: Commit.**
```
git add deploy/files/etc/left4me/cake.env deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): cake.env template with documented uplink knobs"
```
---
### Task 5: CAKE helper script
**Files:**
- Create: `deploy/files/usr/local/libexec/left4me/left4me-apply-cake`
- Modify: `deploy/tests/test_deploy_artifacts.py` (path constant + tests)
- [ ] **Step 1: Add path constant and failing tests.**
Append the constant near the libexec helper constants (around line 21):
```python
APPLY_CAKE_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-apply-cake"
```
Append two test functions:
```python
def test_apply_cake_helper_supports_apply_and_clear_modes():
assert APPLY_CAKE_HELPER.is_file()
text = APPLY_CAKE_HELPER.read_text()
assert text.startswith("#!/bin/sh")
# Both knobs are read from the env file.
assert "LEFT4ME_UPLINK_MBIT" in text
assert "LEFT4ME_UPLINK_IFACE" in text
assert ". /etc/left4me/cake.env" in text
# Iface fallback to default route.
assert "ip -4 route show default" in text
# Two modes; default to apply.
assert "mode=${1:-apply}" in text
assert 'apply)' in text and 'clear)' in text
# Apply: idempotent `tc qdisc replace` with the documented flags.
assert "tc qdisc replace" in text
assert "cake" in text
assert "bandwidth" in text
assert "internet" in text
assert "diffserv4" in text
assert "dual-dsthost" in text
# Clear: tolerates a missing qdisc.
assert "tc qdisc del" in text
assert "|| true" in text
# Fail-soft on missing config.
assert "LEFT4ME_UPLINK_MBIT unset" in text
def test_apply_cake_helper_passes_shell_syntax_check():
subprocess.run(["sh", "-n", str(APPLY_CAKE_HELPER)], check=True)
```
- [ ] **Step 2: Run and confirm FAIL.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_apply_cake_helper_supports_apply_and_clear_modes deploy/tests/test_deploy_artifacts.py::test_apply_cake_helper_passes_shell_syntax_check -v
```
Expected: both FAIL.
- [ ] **Step 3: Write the helper.**
`deploy/files/usr/local/libexec/left4me/left4me-apply-cake`:
```sh
#!/bin/sh
# left4me — apply or clear CAKE egress shaper on the configured uplink.
# Driven by left4me-cake.service. See spec
# docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
set -eu
mode=${1:-apply}
if [ -r /etc/left4me/cake.env ]; then
. /etc/left4me/cake.env
fi
resolve_iface() {
if [ -n "${LEFT4ME_UPLINK_IFACE:-}" ]; then
printf '%s' "$LEFT4ME_UPLINK_IFACE"
return
fi
ip -4 route show default | awk '/default/ {print $5; exit}'
}
case "$mode" in
apply)
if [ -z "${LEFT4ME_UPLINK_MBIT:-}" ]; then
echo "left4me-cake: LEFT4ME_UPLINK_MBIT unset; skipping shaper" >&2
exit 0
fi
iface=$(resolve_iface)
if [ -z "$iface" ]; then
echo "left4me-cake: cannot determine egress iface; skipping" >&2
exit 0
fi
exec tc qdisc replace dev "$iface" root cake \
bandwidth "${LEFT4ME_UPLINK_MBIT}mbit" \
internet diffserv4 dual-dsthost
;;
clear)
iface=$(resolve_iface)
if [ -z "$iface" ]; then
exit 0
fi
tc qdisc del dev "$iface" root 2>/dev/null || true
;;
*)
echo "usage: $0 [apply|clear]" >&2
exit 2
;;
esac
```
Make it executable in the repo (the deploy script also `chmod 0755`s the destination, but executable mode in the source tree is conventional here):
```
chmod 0755 deploy/files/usr/local/libexec/left4me/left4me-apply-cake
```
- [ ] **Step 4: Re-run and confirm PASS.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_apply_cake_helper_supports_apply_and_clear_modes deploy/tests/test_deploy_artifacts.py::test_apply_cake_helper_passes_shell_syntax_check -v
```
Expected: both PASS.
- [ ] **Step 5: Commit.**
```
git add deploy/files/usr/local/libexec/left4me/left4me-apply-cake deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): left4me-apply-cake helper with apply/clear modes"
```
---
### Task 6: CAKE systemd unit
**Files:**
- Create: `deploy/files/usr/local/lib/systemd/system/left4me-cake.service`
- Modify: `deploy/tests/test_deploy_artifacts.py` (path constant + test)
- [ ] **Step 1: Add path constant and failing test.**
Append the constant near the existing systemd-unit constants (around line 16):
```python
CAKE_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-cake.service"
```
Append the test:
```python
def test_cake_unit_runs_helper_in_apply_and_clear_modes():
assert CAKE_UNIT.is_file()
text = CAKE_UNIT.read_text()
assert "After=network-online.target" in text
assert "Wants=network-online.target" in text
assert "Type=oneshot" in text
assert "RemainAfterExit=yes" in text
# `-` prefix: missing env file is non-fatal (deploy ships one, but be safe).
assert "EnvironmentFile=-/etc/left4me/cake.env" in text
assert (
"ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply" in text
)
assert (
"ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear" in text
)
assert "WantedBy=multi-user.target" in text
```
- [ ] **Step 2: Run and confirm FAIL.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_cake_unit_runs_helper_in_apply_and_clear_modes -v
```
Expected: FAIL on `CAKE_UNIT.is_file()`.
- [ ] **Step 3: Write the unit.**
`deploy/files/usr/local/lib/systemd/system/left4me-cake.service`:
```ini
[Unit]
Description=left4me CAKE egress shaper
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
EnvironmentFile=-/etc/left4me/cake.env
ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply
ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear
[Install]
WantedBy=multi-user.target
```
- [ ] **Step 4: Re-run and confirm PASS.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_cake_unit_runs_helper_in_apply_and_clear_modes -v
```
Expected: PASS.
- [ ] **Step 5: Commit.**
```
git add deploy/files/usr/local/lib/systemd/system/left4me-cake.service deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): left4me-cake.service oneshot wrapping apply-cake helper"
```
---
### Task 7: Wire artifacts into `deploy-test-server.sh`
**Files:**
- Modify: `deploy/deploy-test-server.sh`
- Modify: `deploy/tests/test_deploy_artifacts.py` (new test)
This task adds: `nftables` to apt/dnf install lines, copies the four new artifact files into their target paths, conditionally copies `cake.env` only if absent, and `systemctl enable --now`s the two new units. Each piece gets its own assertion in a single new test function.
- [ ] **Step 1: Add the new test.**
Append to `deploy/tests/test_deploy_artifacts.py`:
```python
def test_deploy_script_installs_network_shaping_artifacts():
script = DEPLOY_SCRIPT.read_text()
# nftables: package install on both apt and dnf paths.
apt_lines = [l for l in script.splitlines() if "apt-get install" in l]
dnf_lines = [l for l in script.splitlines() if "dnf install" in l]
assert apt_lines and dnf_lines
for line in apt_lines:
assert "nftables" in line, line
for line in dnf_lines:
assert "nftables" in line, line
# nft rules + unit copied to system paths.
assert "/usr/local/lib/left4me/nft/left4me-mark.nft" in script
assert (
"/usr/local/lib/systemd/system/left4me-nft-mark.service" in script
)
assert "systemctl enable --now left4me-nft-mark.service" in script
# CAKE helper + unit copied; helper made executable.
assert "/usr/local/libexec/left4me/left4me-apply-cake" in script
assert (
"/usr/local/lib/systemd/system/left4me-cake.service" in script
)
assert "chmod 0755" in script and "left4me-apply-cake" in script
assert "systemctl enable --now left4me-cake.service" in script
# cake.env: copied only if absent (operator edits survive re-deploys).
assert "/etc/left4me/cake.env" in script
assert "[ -e /etc/left4me/cake.env ]" in script
```
- [ ] **Step 2: Run and confirm FAIL.**
```
pytest deploy/tests/test_deploy_artifacts.py::test_deploy_script_installs_network_shaping_artifacts -v
```
Expected: FAIL on the first missing string.
- [ ] **Step 3: Edit `deploy-test-server.sh`.**
Make these targeted edits — do not rewrite the script.
(a) **Append `nftables` to both package-install lines (line 88 and line 90 in the current file).**
Old (line 88):
```
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full
```
New:
```
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full nftables
```
Old (line 90):
```
$sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip p7zip-plugins
```
New:
```
$sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip p7zip-plugins nftables
```
(b) **Add the nft-rules-dir creation to the `mkdir -p` block (currently lines 96-106).**
Append `/usr/local/lib/left4me/nft` to the existing `mkdir -p` invocation:
Old (lines 96-106):
```
$sudo_cmd mkdir -p \
/etc/left4me \
/opt/left4me \
/usr/local/lib/systemd/system \
/usr/local/libexec/left4me \
/var/lib/left4me/installation \
/var/lib/left4me/overlays \
/var/lib/left4me/instances \
/var/lib/left4me/runtime \
/var/lib/left4me/workshop_cache \
/var/lib/left4me/tmp
```
New (insert one line after `/usr/local/libexec/left4me`):
```
$sudo_cmd mkdir -p \
/etc/left4me \
/opt/left4me \
/usr/local/lib/systemd/system \
/usr/local/libexec/left4me \
/usr/local/lib/left4me/nft \
/var/lib/left4me/installation \
/var/lib/left4me/overlays \
/var/lib/left4me/instances \
/var/lib/left4me/runtime \
/var/lib/left4me/workshop_cache \
/var/lib/left4me/tmp
```
(c) **Copy the new systemd units alongside the existing ones (after line 140's `l4d2-build.slice` copy).**
Insert immediately after the `l4d2-build.slice` copy (the existing line that ends `l4d2-build.slice`):
```
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service
```
(d) **Copy the nftables rules file alongside the existing `install`-mode copies (next to the sandbox-resolv.conf install at lines 189-191).**
Insert after the sandbox-resolv install block:
```
# Network packet marking + shaping. See spec
# docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
$sudo_cmd install -m 0644 -o root -g root \
/opt/left4me/deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft \
/usr/local/lib/left4me/nft/left4me-mark.nft
```
(e) **Copy the CAKE helper alongside the other libexec helpers (after the existing `cp` block at lines 175-179).**
Find the existing `cp` block that copies `left4me-systemctl`, `left4me-journalctl`, `left4me-overlay`, `left4me-script-sandbox`. Add a new `cp` line for `left4me-apply-cake`, and add it to the `chmod 0755` line on line 179:
Old (line 178):
```
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox /usr/local/libexec/left4me/left4me-script-sandbox
```
After it, insert:
```
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-apply-cake /usr/local/libexec/left4me/left4me-apply-cake
```
Old (line 179):
```
$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-script-sandbox
```
New (append `left4me-apply-cake`):
```
$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-script-sandbox /usr/local/libexec/left4me/left4me-apply-cake
```
(f) **Conditionally copy `cake.env` (after the existing sysctl install/apply block at lines 193-198).**
Insert immediately after `$sudo_cmd sysctl --system >/dev/null`:
```
# CAKE config: ship the template only if the operator hasn't created one
# (their LEFT4ME_UPLINK_MBIT value must survive re-deploys).
if [ ! -e /etc/left4me/cake.env ]; then
$sudo_cmd install -m 0644 -o root -g root \
/opt/left4me/deploy/files/etc/left4me/cake.env \
/etc/left4me/cake.env
fi
```
(g) **Enable the new units alongside the existing `systemctl enable --now left4me-web.service`.**
Find the existing block (around line 315-316):
```
$sudo_cmd systemctl daemon-reload
$sudo_cmd systemctl enable --now left4me-web.service
```
Insert two lines between them:
```
$sudo_cmd systemctl daemon-reload
$sudo_cmd systemctl enable --now left4me-nft-mark.service
$sudo_cmd systemctl enable --now left4me-cake.service
$sudo_cmd systemctl enable --now left4me-web.service
```
- [ ] **Step 4: Re-run all existing tests + the new one to make sure nothing regressed.**
```
pytest deploy/tests/test_deploy_artifacts.py -v
```
Expected: every test passes, including the new `test_deploy_script_installs_network_shaping_artifacts` and the unmodified `test_deploy_script_shell_syntax` (the latter validates `sh -n` on the modified script).
- [ ] **Step 5: Commit.**
```
git add deploy/deploy-test-server.sh deploy/tests/test_deploy_artifacts.py
git commit -m "feat(deploy): wire nft marking + CAKE shaper into deploy script"
```
---
### Task 8: README documentation
**Files:**
- Modify: `deploy/README.md`
This is documentation only — no test asserts the README contents. Run an `sh -n` of the deploy script one more time after editing, just as a hygiene check (the README change can't affect it, but the test suite is fast).
- [ ] **Step 1: Open `deploy/README.md` and locate the existing Performance tuning section.**
The previous perf-baseline spec added a "Performance tuning" section (entries for CPU governor, CPU affinity, NIC tuning, and real-time scheduling opt-in). Find it.
- [ ] **Step 2: Add a "Network shaping" subsection.**
Add this subsection at the top of "Performance tuning" (before the existing entries; network-shaping covers the universal artifacts that ship by default, while the existing entries are escape hatches):
```markdown
### Network shaping
The deploy ships three things that affect player-experience network behaviour:
1. **Per-flow marking.** `left4me-nft-mark.service` loads a small nftables
table (`inet left4me_mark`) that marks every UDP packet from uid `left4me`
with DSCP EF and `skb->priority` 6. srcds doesn't set these itself, so
without this rule its UDP is indistinguishable from any other flow.
2. **Sysctl baseline.** `99-left4me.conf` sets `udp_rmem_min=16384`,
`udp_wmem_min=16384`, `default_qdisc=fq_codel`, and
`tcp_congestion_control=bbr`. Reduces head-of-line blocking when bulk
TCP egress (backups, package fetches, web responses) coexists with
game UDP.
3. **CAKE egress shaping.** `left4me-cake.service` runs
`tc qdisc replace dev <iface> root cake bandwidth Xmbit internet
diffserv4 dual-dsthost` from `/etc/left4me/cake.env`. CAKE only shapes
if its declared bandwidth is **below** the real bottleneck, so set
`LEFT4ME_UPLINK_MBIT` to ≈95% of measured uplink:
sudoedit /etc/left4me/cake.env
# set LEFT4ME_UPLINK_MBIT=480 (or whatever ~95% of your uplink is)
sudo systemctl restart left4me-cake.service
`LEFT4ME_UPLINK_IFACE` is auto-detected from the IPv4 default route;
override only on hosts with multi-homed setups.
At idle 500 Mbit with no competing egress, CAKE shapes nothing — that's
expected, not a bug. The win materialises when bulk traffic on the
same uplink would otherwise bufferbloat the link the players share.
**Production hosts running `systemd-networkd`** should NOT use the
`left4me-cake.service` oneshot. Instead, configure the equivalent in the
matching `.network` file, which systemd-networkd reapplies across iface
lifecycle events:
# /etc/systemd/network/<your-uplink>.network
[CAKE]
Bandwidth=480M
OverheadKeyword=internet
PriorityQueueingPreset=diffserv4
EgressHostIsolation=yes
The nftables marking from (1) is qdisc-installer-agnostic and ships
unchanged on production.
```
- [ ] **Step 3: Append the three new escape hatches to the existing Performance tuning section.**
Add after the existing escape-hatch entries (CPU governor / CPU affinity / NIC tuning / real-time scheduling):
```markdown
### Additional opt-in network knobs
- **Ingress shaping via IFB.** Egress CAKE alone does not protect srcds
receive against ingress saturation (large workshop downloads, package
fetches arriving at line rate). One-liner:
sudo modprobe ifb && sudo ip link set ifb0 up
sudo tc qdisc add dev <uplink> handle ffff: ingress
sudo tc filter add dev <uplink> parent ffff: protocol ip u32 \
match u32 0 0 action mirred egress redirect dev ifb0
sudo tc qdisc add dev ifb0 root cake bandwidth Xmbit ingress \
diffserv4 dual-srchost
Worth flipping only when measurement shows ingress hurting receive.
- **`net.core.busy_poll = 50` / `net.core.busy_read = 50`.** Reduces UDP
receive median latency by polling for incoming packets briefly at
syscall boundaries. Cost: measurable CPU per syscall under load. Worth
flipping if a host is dedicated to game serving and CPU headroom is
plentiful.
- **`ethtool -K <iface> gro off`.** Some Source-engine ops disable
generic receive offload to avoid receive-side coalescing latency.
Hardware/driver dependent; document only.
```
- [ ] **Step 4: Re-run the full test suite.**
```
pytest deploy/tests/test_deploy_artifacts.py -v
```
Expected: every test passes, including `test_deploy_script_shell_syntax`.
- [ ] **Step 5: Commit.**
```
git add deploy/README.md
git commit -m "docs(deploy): document network-shaping defaults + opt-in network knobs"
```
---
## Final verification
After all eight tasks land, run the whole suite once more and verify the new files are tracked:
```
pytest deploy/tests/test_deploy_artifacts.py -v
git status
git log --oneline -10
```
Every test should pass. `git status` should be clean. The last 8 commits should match the eight tasks above.
The new files in the tree:
```
deploy/files/etc/left4me/cake.env
deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft
deploy/files/usr/local/lib/systemd/system/left4me-cake.service
deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service
deploy/files/usr/local/libexec/left4me/left4me-apply-cake
```
Modified files:
```
deploy/files/etc/sysctl.d/99-left4me.conf
deploy/deploy-test-server.sh
deploy/README.md
deploy/tests/test_deploy_artifacts.py
```

View file

@ -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 `<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.
- **Footer**`Delete` 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 tests**`safe_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 heuristic**`is_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.

View file

@ -0,0 +1,487 @@
# l4d2 network shaping & marking — design
Date: 2026-05-10
Status: design
## Summary
Add a network-side player-experience baseline alongside the existing host
perf baseline. Three concerns ship together:
1. **Mark srcds outbound packets** with DSCP `EF` and skb priority `6:0` so
any qdisc — host CAKE, ISP gear that honours DSCP, future systems —
recognises L4D2 game traffic as latency-sensitive. Marking happens by uid
match on the `left4me` user.
2. **Round out the UDP-socket sysctl baseline** (`udp_rmem_min`,
`udp_wmem_min`), set the default qdisc explicitly to `fq_codel`, and
switch TCP to `bbr` so coexisting TCP egress (admin, backups, web app,
apt) cannot bufferbloat the link the players share.
3. **Shape egress with CAKE.** On the test deploy, install a systemd oneshot
that applies `tc qdisc replace … cake …` from an operator-edited env
file. On production hosts running `systemd-networkd`, document the
equivalent `[CAKE]` section in the matching `.network` file as the
long-term path.
The intent is "all reasonable measures that do not depend on host-specific
hardware." Hardware-specific tuning (NIC ring buffers, IRQ pinning, CPU
governor, real-time scheduling, CPU affinity) remains a documented escape
hatch — same boundary the existing perf-baseline spec drew. The pieces
that *are* universally safe ship as defaults.
## Goals
- Game-server UDP packets carry an unambiguous priority signal in DSCP and
in `skb->priority`, set on the host before any qdisc inspects them.
- A coexisting bulk TCP flow on the same host (backup upload, package
fetch, web-app response) cannot push the bottleneck queue ahead of game
UDP under saturation.
- An operator who declares uplink bandwidth gets fair-queueing egress
shaping with diffserv-aware tin selection — i.e. EF-marked srcds traffic
drops into the highest-priority CAKE tin, per-destination-host fairness
keeps every connected player on equal footing.
- A production deployment using `systemd-networkd` has a one-block
configuration recipe, no helper script needed.
- Operators have a documented set of additional knobs (ingress shaping via
IFB, `busy_poll`, GRO toggling) for cases the default baseline does not
cover. None of these auto-apply.
## Non-goals
- NIC ring-buffer / IRQ pinning / RPS / RFS / hardware timestamping —
already declared host-specific in the perf-baseline spec; not
re-litigated here.
- `busy_poll` / `busy_read` as defaults — non-trivial CPU cost; documented
as opt-in.
- Ingress shaping via IFB as a default — only matters if egress CAKE turns
out load-bearing and ingress is also saturated; documented as opt-in.
- Real-time scheduling, governor changes — already declined by the
perf-baseline spec.
- Blueprint-side game settings (`sv_minrate`, `sv_maxrate`, tickrate,
`fps_max`) — owned by the server maintainer.
- Auto-detection or measurement of uplink bandwidth. CAKE only shapes
correctly when its declared bandwidth sits below the real bottleneck;
the operator must measure once and configure.
- Iface-flap watchdog. `tc qdisc replace` is idempotent; on prod,
`systemd-networkd` reapplies CAKE across iface lifecycle events. On
test, `systemctl restart left4me-cake.service` is the documented
recovery.
## Background
Current state (commit `62d6d4c` or thereabouts):
- The perf-baseline spec ships `/etc/sysctl.d/99-left4me.conf` with
`rmem_max`, `wmem_max`, `rmem_default`, `wmem_default`,
`netdev_max_backlog`, `netdev_budget`, `vm.swappiness`. No per-socket
UDP minimums, no default-qdisc directive, no TCP congestion-control
setting.
- `srcds_run` runs as system user `left4me`. srcds itself does not set
`IP_TOS` or `SO_PRIORITY`, so its UDP packets leave the host with
DSCP 0 and priority 0 — indistinguishable from any other UDP traffic to
any qdisc.
- The deploy ships nftables-relevant infrastructure only via package
defaults (Debian Trixie ships `nftables` in base, but no `left4me`
table is created).
- No qdisc is explicitly configured. The kernel's per-iface default
applies — `fq_codel` on Trixie, but only because Debian's default has
been `fq_codel` since Buster.
- The deploy script already copies sysctl drop-ins and runs
`sysctl --system` (`deploy/deploy-test-server.sh:196`).
## Design
### Sysctl additions to `99-left4me.conf`
Append to `deploy/files/etc/sysctl.d/99-left4me.conf`:
```
# Per-socket UDP buffer floors: protect game-server sockets that don't bump
# their own SO_RCVBUF/SO_SNDBUF when softirq drains lag briefly.
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
# Default qdisc for ifaces we don't explicitly shape with CAKE. Debian
# Trixie already defaults to fq_codel; setting it explicitly is
# belt-and-suspenders and survives kernel-default churn.
net.core.default_qdisc = fq_codel
# TCP congestion control: BBR for any bulk TCP egress on the host (admin
# SSH, backups, package fetches, web-app responses) so a long flow does
# not push the bottleneck queue ahead of game UDP. UDP srcds is
# unaffected.
net.ipv4.tcp_congestion_control = bbr
```
The deploy already runs `sysctl --system` after copying the conf
(`deploy/deploy-test-server.sh:198`); no script change required for this
block.
### nftables packet marking
New file `deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft`:
```nft
table inet left4me_mark {
chain mangle_output {
type filter hook output priority mangle; policy accept;
meta skuid "left4me" meta l4proto udp ip dscp set ef meta priority set 0006:0000
meta skuid "left4me" meta l4proto udp ip6 dscp set ef meta priority set 0006:0000
}
}
```
Per-element rationale:
- `meta skuid "left4me"` — every srcds instance runs as that user. The
match is exact; nothing else on the host matches. No false positives
against the web app (which runs as `left4me` too but speaks TCP) or the
build sandbox (different uid).
- `meta l4proto udp` — bypass anything not UDP, including the future
RCON/HTTP TCP traffic from the web app.
- `ip dscp set ef` / `ip6 dscp set ef` — DSCP `EF` (Expedited Forwarding,
decimal 46) is the standard low-latency marking. CAKE's `diffserv4`
preset routes EF into its highest-priority "Voice" tin. Two rules,
one per L3 family, because in an `inet` table the `ip` matcher only
fires on v4 and `ip6` only on v6.
- `meta priority set 0006:0000` — sets `skb->priority` to class `6:0`.
Read by qdiscs that classify on skb priority (CAKE included) ahead of
any DSCP table lookup. Set inline with the DSCP rule so a single
rule-match runs both statements.
The table is named `left4me_mark` and lives in its own `inet` namespace.
It does not touch, depend on, or conflict with any nftables config the
operator may run independently. `nft -f` loads the file; `nft delete
table inet left4me_mark` cleanly removes it.
New unit `deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service`:
```ini
[Unit]
Description=left4me nftables packet marking (DSCP EF + priority for srcds)
After=network-pre.target
Before=network.target
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /usr/local/lib/left4me/nft/left4me-mark.nft
ExecStop=/usr/sbin/nft delete table inet left4me_mark
[Install]
WantedBy=multi-user.target
```
`After=network-pre.target` / `Before=network.target` keeps the rules in
place before any iface comes up, so the very first packet srcds emits
post-boot is already marked.
Deploy script changes:
- Ensure `nftables` is installed (`apt-get install -y nftables`;
idempotent — package is in Trixie base).
- Create `/usr/local/lib/left4me/nft/` and copy `left4me-mark.nft` into
it.
- Copy the unit, `daemon-reload`, `systemctl enable --now
left4me-nft-mark.service`.
### CAKE egress shaper — test deploy mechanism
Three files plus deploy-script changes. All operator-tunable knobs go in
the env file; the helper and unit are static.
**`deploy/files/etc/left4me/cake.env`** (template; deploy installs only
if absent so operator edits survive re-runs):
```
# Uplink bandwidth in Mbit/s. Set to ~95% of the smaller of measured
# upload and measured download. CAKE only shapes correctly when its
# declared bandwidth sits below the real bottleneck. If unset, the
# left4me-cake.service unit logs a warning and exits 0 (no shaping).
LEFT4ME_UPLINK_MBIT=
# Egress interface. If unset, auto-detected from the IPv4 default route.
LEFT4ME_UPLINK_IFACE=
```
**`deploy/files/usr/local/libexec/left4me/left4me-apply-cake`** (mode
`0755`, owner `root:root`). The helper takes a single argument — `apply`
or `clear` — so the unit's `ExecStart` and `ExecStop` both call the same
script and the unit file stays free of shell escaping:
```sh
#!/bin/sh
set -eu
mode=${1:-apply}
if [ -r /etc/left4me/cake.env ]; then
. /etc/left4me/cake.env
fi
resolve_iface() {
if [ -n "${LEFT4ME_UPLINK_IFACE:-}" ]; then
printf '%s' "$LEFT4ME_UPLINK_IFACE"
return
fi
ip -4 route show default | awk '/default/ {print $5; exit}'
}
case "$mode" in
apply)
if [ -z "${LEFT4ME_UPLINK_MBIT:-}" ]; then
echo "left4me-cake: LEFT4ME_UPLINK_MBIT unset; skipping shaper" >&2
exit 0
fi
iface=$(resolve_iface)
if [ -z "$iface" ]; then
echo "left4me-cake: cannot determine egress iface; skipping" >&2
exit 0
fi
exec tc qdisc replace dev "$iface" root cake \
bandwidth "${LEFT4ME_UPLINK_MBIT}mbit" \
internet diffserv4 dual-dsthost
;;
clear)
iface=$(resolve_iface)
if [ -z "$iface" ]; then
exit 0
fi
tc qdisc del dev "$iface" root 2>/dev/null || true
;;
*)
echo "usage: $0 [apply|clear]" >&2
exit 2
;;
esac
```
`tc qdisc replace` is idempotent: replaces an existing root qdisc on the
iface, adds one if absent. Re-running the unit any time is safe. `clear`
swallows the "no such qdisc" error so stop is also idempotent.
Fail-soft on missing config matches the perf-baseline philosophy — the
deploy does not refuse to boot servers because the operator has not yet
filled in `LEFT4ME_UPLINK_MBIT`. The journal warning surfaces the gap.
**`deploy/files/usr/local/lib/systemd/system/left4me-cake.service`**:
```ini
[Unit]
Description=left4me CAKE egress shaper
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
EnvironmentFile=-/etc/left4me/cake.env
ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply
ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear
[Install]
WantedBy=multi-user.target
```
Per-flag rationale for the `cake` invocation:
- `bandwidth ${LEFT4ME_UPLINK_MBIT}mbit` — operator-declared, ≈95% of
measured uplink. CAKE only shapes if its declared bandwidth is below
the real bottleneck; setting it slightly low moves the queue into a
place the host controls.
- `internet` — overhead-accounting keyword that handles common
Ethernet+ISP encapsulation (DOCSIS / GPON / PPPoE) correctly without
undershooting. Conservative default.
- `diffserv4` — four-tier DSCP-aware tin selection. Reads the EF marks
set by the nftables rule and routes srcds packets into the
highest-priority "Voice" tin. Without `diffserv4`, the marks are
ignored.
- `dual-dsthost` — egress fairness keyed on destination host. With ≥2
players connected, each player gets fair share regardless of how
chatty the server is to any single client.
Iface-flap behaviour: the kernel keeps the qdisc on an iface across
link-down/link-up while the iface itself exists. If the iface is
recreated (e.g., NetworkManager reconfiguration), `systemctl restart
left4me-cake.service` reapplies. Documented; no auto-watchdog in v1.
Deploy script changes (in `deploy/deploy-test-server.sh`):
- Copy `cake.env` to `/etc/left4me/cake.env` only if absent (do not
clobber operator edits).
- Copy `left4me-apply-cake` to `/usr/local/libexec/left4me/`, mode
`0755`, owner `root:root`.
- Copy `left4me-cake.service` to `/usr/local/lib/systemd/system/`.
- `systemctl daemon-reload` (already done in the existing flow).
- `systemctl enable --now left4me-cake.service`.
### CAKE egress shaper — production deployment (systemd-networkd)
On hosts running `systemd-networkd`, the CAKE configuration belongs in
the matching `.network` file. systemd-networkd reapplies it across iface
lifecycle events, addressing the only fragility of the test-deploy
oneshot.
Document in `deploy/README.md` Performance section:
```ini
# /etc/systemd/network/<your-uplink>.network
[CAKE]
Bandwidth=480M
OverheadKeyword=internet
PriorityQueueingPreset=diffserv4
EgressHostIsolation=yes
```
Directive names follow `systemd.network(5)`. Values mirror the test
deploy's `tc` invocation:
- `Bandwidth=480M` — placeholder; operator sets to ≈95% of measured
uplink in their actual `.network`.
- `OverheadKeyword=internet` — equivalent of the `internet` keyword.
- `PriorityQueueingPreset=diffserv4` — equivalent of `diffserv4`.
- `EgressHostIsolation=yes` — equivalent of `dual-dsthost` on egress.
The nftables marking from the previous section ships unchanged on prod;
it is qdisc-installer-agnostic.
The test-deploy oneshot does NOT install on a host running
`systemd-networkd`. v1 does not implement that gate — production hosts
do not run the test-deploy script. If the boundary blurs in the future,
add a check in `left4me-apply-cake` for `systemctl is-active
systemd-networkd` and skip cleanly.
### Documented escape hatches
Append to `deploy/README.md` Performance section, alongside the existing
governor / CPU-affinity / NIC entries:
- **Ingress shaping via IFB.** Egress CAKE alone does not protect srcds
receive against ingress saturation (large workshop downloads, package
fetches arriving at line rate). One-liner template using `modprobe
ifb`, `ip link set ifb0 up`, `tc qdisc add dev ifb0 root cake bandwidth
Xmbit ingress diffserv4 dual-srchost`, and a `tc filter` redirect from
the uplink iface. Worth flipping only when measurement shows ingress
hurting receive; in v1 we have no such measurement, so it stays
documented.
- **`net.core.busy_poll = 50` / `net.core.busy_read = 50`.** Reduces UDP
receive median latency by polling for incoming packets briefly at
syscall boundaries. Cost: measurable CPU per syscall under load. Worth
flipping if a host is dedicated to game serving and CPU headroom is
plentiful.
- **`ethtool -K <iface> gro off`.** Some Source-engine ops disable
generic receive offload to avoid receive-side coalescing latency.
Hardware/driver dependent. Document, do not ship.
These three entries follow the existing escape-hatch style: a one-liner
or short config block, plus one sentence on when it matters.
### Files changed / added
```
deploy/files/etc/sysctl.d/99-left4me.conf (modified — block added)
deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft (new)
deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service (new)
deploy/files/etc/left4me/cake.env (new — template, deploy preserves operator edits)
deploy/files/usr/local/libexec/left4me/left4me-apply-cake (new)
deploy/files/usr/local/lib/systemd/system/left4me-cake.service (new)
deploy/deploy-test-server.sh (modified — install+enable nft and cake units, conditional copy of cake.env)
deploy/README.md (modified — Network shaping subsection + 3 new escape hatches)
deploy/tests/test_deploy_artifacts.py (modified — assertions for all artifacts above)
```
## Tests
Following the existing `assert "key=value" in text` pattern in
`deploy/tests/test_deploy_artifacts.py`:
**Sysctl block** (extension of the existing perf-baseline assertions):
- Each of `net.ipv4.udp_rmem_min = 16384`, `net.ipv4.udp_wmem_min =
16384`, `net.core.default_qdisc = fq_codel`,
`net.ipv4.tcp_congestion_control = bbr` is asserted as a separate line.
**nftables marking artifacts:**
- `left4me-mark.nft` ships with `table inet left4me_mark`, `chain
mangle_output`, `meta skuid "left4me"`, `ip dscp set ef`, `ip6 dscp
set ef`, and `meta priority set 0006:0000` each asserted as separate
substring matches. (DSCP and priority statements appear inline on
the same rule per L3 family; substring assertions don't depend on
rule layout.)
- `left4me-nft-mark.service` has `ExecStart=/usr/sbin/nft -f
/usr/local/lib/left4me/nft/left4me-mark.nft`, `ExecStop=/usr/sbin/nft
delete table inet left4me_mark`, `Type=oneshot`,
`RemainAfterExit=yes`, `WantedBy=multi-user.target`.
- `deploy-test-server.sh` invokes `systemctl enable --now
left4me-nft-mark.service` (or equivalent at-deploy enabling step).
**CAKE artifacts:**
- `cake.env` template contains the literal lines `LEFT4ME_UPLINK_MBIT=`
and `LEFT4ME_UPLINK_IFACE=` (commented or uncommented; matched as
substring).
- `left4me-apply-cake` contains the literals `tc qdisc replace`, `cake`,
`bandwidth`, `internet`, `diffserv4`, `dual-dsthost`,
`LEFT4ME_UPLINK_MBIT`, `LEFT4ME_UPLINK_IFACE`.
- `left4me-apply-cake` is mode `0755` after deploy (asserted via the
same mechanism the existing helper-script tests use).
- `left4me-cake.service` contains
`EnvironmentFile=-/etc/left4me/cake.env`,
`ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply`,
`ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear`,
`Wants=network-online.target`, `Type=oneshot`,
`WantedBy=multi-user.target`.
- `deploy-test-server.sh` invokes `systemctl enable --now
left4me-cake.service`.
- `deploy-test-server.sh` copies `cake.env` only when target absent
(asserted by literal substring of the guarding `[ -e
/etc/left4me/cake.env ]` test or equivalent).
No runtime networking tests in v1. The artifacts are static; their
runtime behaviour requires a real iface and a real bandwidth load,
which the operator measures.
## Rollout
Single deploy. After the new sysctl block lands, `sysctl --system`
applies it immediately (already in the deploy flow). The two new
systemd units start on `systemctl enable --now`; CAKE without a
configured `LEFT4ME_UPLINK_MBIT` logs a warning and no-ops, which is
the expected fresh-deploy state. The operator measures their uplink,
edits `/etc/left4me/cake.env`, and runs `systemctl restart
left4me-cake.service`.
Already-running game servers are unaffected by the network changes
themselves. The marking applies on every emitted packet from the moment
the nft rule loads; future-emitted packets pick up DSCP+priority without
restarting any srcds instance.
## Open questions
None blocking. v2 candidates if measurement justifies them:
- A `LEFT4ME_INGRESS_MBIT` knob that flips on the IFB ingress shaper as
a default, conditional on the env value being set.
- A `left4me-net-doctor` helper that reports current qdisc, applied
marks, and a one-shot saturation+ping measurement against a local
endpoint.
- A small Python wrapper in `l4d2host` that reads `cake.env` for
display in the web UI, so the operator sees in one place whether
shaping is active.
## References
- `tc-cake(8)` — keyword semantics: `bandwidth`, `internet`,
`diffserv4`, `dual-dsthost`, tin priority mapping.
- `systemd.network(5)``[CAKE]` section directives:
`Bandwidth=`, `OverheadKeyword=`, `PriorityQueueingPreset=`,
`EgressHostIsolation=`.
- `nft(8)``meta skuid`, `meta priority`, `ip dscp set`, table
isolation semantics.
- RFC 3246 — Expedited Forwarding (EF) PHB.
- Linux kernel `Documentation/networking/tcp_bbr.txt` — BBR pairs with
`fq` / `fq_codel` for correct pacing.
- `docs/superpowers/specs/2026-05-09-l4d2-server-host-perf-baseline-design.md`
— sibling spec; this spec extends `99-left4me.conf` and reuses the
same deploy-test-artifact pattern.

View file

@ -0,0 +1,33 @@
"""users.active
Revision ID: 0008_user_active
Revises: 0007_blueprint_overlay_expose_server_cfg
Create Date: 2026-05-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0008_user_active"
down_revision: Union[str, Sequence[str], None] = "0007_blueprint_overlay_expose_server_cfg"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"active",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
)
def downgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.drop_column("active")

View file

@ -27,7 +27,10 @@ def load_current_user() -> None:
g.user = None
return
with session_scope() as db:
g.user = db.scalar(select(User).where(User.id == int(user_id)))
user = db.scalar(select(User).where(User.id == int(user_id)))
# Treat deactivated users as logged-out so existing sessions stop
# working as soon as an admin flips active=False.
g.user = user if user is not None and user.active else None
def current_user() -> User | None:

View file

@ -30,6 +30,9 @@ class User(Base):
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_digest: Mapped[str] = mapped_column(String(255), nullable=False)
admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, server_default=text("1"),
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)

View file

@ -48,7 +48,9 @@ def login() -> Response:
user = db.scalar(select(User).where(User.username == username))
digest = user.password_digest if user is not None else _TIMING_DUMMY_DIGEST
password_ok = verify_password(password, digest)
if user is None or not password_ok:
if user is None or not password_ok or not user.active:
# Same generic response for missing user, wrong password, or
# deactivated account — no timing oracle for deactivation status.
return Response("invalid credentials", status=401)
login_user(user.id)
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)

View file

@ -1,8 +1,6 @@
"""Routes for the overlay 'Files' section.
Two GETs, both gated to the overlay's owner or any admin (mirrors the
overlay detail page rule):
Read-only endpoints (any overlay):
- `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.
@ -10,26 +8,68 @@ overlay detail page rule):
Symlinks resolving anywhere under `LEFT4ME_ROOT` are allowed (so
workshop addons stream from the shared cache); anything escaping it
is refused.
Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
- `GET /overlays/<id>/files/content?path=` JSON `{path, content}` for
an editable text file, 415 if not editable.
- `POST /overlays/<id>/files/save` JSON `{path, content, new_path?}`,
text-mode write with optional atomic rename.
- `POST /overlays/<id>/files/replace` multipart `path`, `file`,
optional `new_path`. Binary-mode replace with optional atomic rename.
- `POST /overlays/<id>/files/upload` multipart `target_path`, single
`file` (carrying `webkitRelativePath`). One file per request; client
fans out for multi-file or whole-folder drops.
- `POST /overlays/<id>/files/move` JSON `{src, dst}`. Internal-drag
rename or move; refuses cycles via `safe_resolve_for_move`.
- `POST /overlays/<id>/files/mkdir` JSON `{path}`. Slashes allowed in
`path`; intermediate directories are created.
- `POST /overlays/<id>/files/delete` form `path`. Refuses non-empty
directories.
- `GET /overlays/<id>/files/download_zip?path=` streams a zip of the
folder's contents.
"""
from __future__ import annotations
import io
import os
import shutil
import tempfile
import zipfile
from flask import Blueprint, Response, render_template, request, send_file
from flask import (
Blueprint,
Response,
jsonify,
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, Server
from l4d2web.services.overlay_files import (
is_editable,
list_directory,
safe_resolve_for_delete,
safe_resolve_for_download,
safe_resolve_for_listing,
safe_resolve_for_move,
safe_resolve_for_server_download,
safe_resolve_for_server_listing,
safe_resolve_for_write,
)
# Same caps as is_editable so /save can't sneak in something the listing
# would have flagged non-editable.
_SAVE_MAX_BYTES = 1 * 1024 * 1024
# Per-upload byte cap — keeps a single hostile request from filling disk.
# 200 MiB picked generously for vpks; tune later if needed.
_UPLOAD_MAX_BYTES = 200 * 1024 * 1024
bp = Blueprint("files", __name__)
@ -46,6 +86,18 @@ def _load_overlay_for_user(overlay_id: int, user) -> Overlay | Response:
return overlay
def _load_files_overlay(overlay_id: int, user) -> Overlay | Response:
"""Like `_load_overlay_for_user`, but additionally 404s for any overlay
whose type isn't `files`. Mutating endpoints use this; read-only ones
keep working across all types."""
result = _load_overlay_for_user(overlay_id, user)
if isinstance(result, Response):
return result
if result.type != "files":
return Response(status=404)
return result
@bp.get("/overlays/<int:overlay_id>/files")
@require_login
def overlay_files_fragment(overlay_id: int):
@ -76,6 +128,7 @@ def overlay_files_fragment(overlay_id: int):
truncated_count=truncated_count,
files_base_url=f"/overlays/{overlay_id}",
download_supported=True,
files_overlay=(overlay.type == "files"),
)
@ -144,6 +197,416 @@ def server_files_fragment(server_id: int):
)
@bp.get("/overlays/<int:overlay_id>/files/content")
@require_login
def overlay_file_content(overlay_id: int):
"""Return `{path, content}` for an editable text file."""
user = current_user()
assert user is not None
sub_path = request.args.get("path", "")
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
target = safe_resolve_for_listing(overlay.path, sub_path)
except ValueError:
return Response("invalid path", status=400)
if not target.exists() or not target.is_file():
return Response(status=404)
if not is_editable(target):
return Response("not editable", status=415)
try:
content = target.read_text(encoding="utf-8")
except OSError:
return Response("read failed", status=500)
except UnicodeDecodeError:
# is_editable sniffed only the first 8 KiB; the tail can still fail.
return Response("not editable", status=415)
return jsonify({"path": sub_path, "content": content})
def _validate_save_content(content: str) -> Response | None:
if len(content.encode("utf-8")) > _SAVE_MAX_BYTES:
return Response("content exceeds 1 MiB", status=413)
if "\x00" in content:
return Response("content contains NUL bytes", status=415)
return None
@bp.post("/overlays/<int:overlay_id>/files/save")
@require_login
def overlay_file_save(overlay_id: int):
"""Write text content to `path`. Optional `new_path` performs a rename
in the same call (atomic: rename then write; both succeed or neither)."""
user = current_user()
assert user is not None
payload = request.get_json(silent=True) or {}
path = (payload.get("path") or "").strip()
new_path = payload.get("new_path")
new_path = new_path.strip() if isinstance(new_path, str) and new_path.strip() else None
content = payload.get("content")
if not path or not isinstance(content, str):
return Response("missing path or content", status=400)
err = _validate_save_content(content)
if err is not None:
return err
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
write_target = safe_resolve_for_write(overlay.path, new_path or path)
except ValueError as exc:
return Response(str(exc), status=422)
# Rename branch: source must exist, dst must not collide.
if new_path is not None and new_path != path:
try:
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
except ValueError as exc:
return Response(str(exc), status=422)
if dst_path.exists():
return Response("destination already exists", status=409)
dst_path.parent.mkdir(parents=True, exist_ok=True)
os.rename(src_path, dst_path)
write_target = dst_path
else:
write_target.parent.mkdir(parents=True, exist_ok=True)
# Creation branch: must not collide with an existing path.
if write_target.exists() and not _is_existing_file(write_target):
return Response("destination is not a file", status=409)
try:
write_target.write_text(content, encoding="utf-8")
except OSError as exc:
return Response(f"write failed: {exc}", status=500)
return jsonify({"path": new_path or path})
def _is_existing_file(path) -> bool:
return path.is_file() and not path.is_symlink()
@bp.post("/overlays/<int:overlay_id>/files/replace")
@require_login
def overlay_file_replace(overlay_id: int):
"""Replace the bytes of `path` with the uploaded `file`. Optional
`new_path` performs an atomic rename in the same call."""
user = current_user()
assert user is not None
path = (request.form.get("path") or "").strip()
new_path = (request.form.get("new_path") or "").strip() or None
upload = request.files.get("file")
if not path or upload is None:
return Response("missing path or file", status=400)
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
if new_path and new_path != path:
try:
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
except ValueError as exc:
return Response(str(exc), status=422)
if dst_path.exists():
return Response("destination already exists", status=409)
dst_path.parent.mkdir(parents=True, exist_ok=True)
os.rename(src_path, dst_path)
write_target = dst_path
echo_path = new_path
else:
try:
write_target = safe_resolve_for_write(overlay.path, path)
except ValueError as exc:
return Response(str(exc), status=422)
write_target.parent.mkdir(parents=True, exist_ok=True)
echo_path = path
return _stream_upload_into(upload, write_target, echo_path)
def _stream_upload_into(upload, write_target, echo_path: str) -> Response:
"""Write the multipart upload's stream into `write_target`, enforcing
the per-upload size cap. Cleans up the partial file on failure or
over-cap so a cancelled / oversized upload doesn't leak bytes.
`echo_path` is what the route reports back as the canonical relative
path for the new file (the rename target if a rename happened, else the
original path). The function doesn't recompute this so it can be passed
through verbatim.
"""
tmp_dir = write_target.parent
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".upload-", dir=str(tmp_dir))
bytes_seen = 0
try:
with os.fdopen(fd, "wb") as out:
while True:
chunk = upload.stream.read(64 * 1024)
if not chunk:
break
bytes_seen += len(chunk)
if bytes_seen > _UPLOAD_MAX_BYTES:
raise _UploadTooLarge()
out.write(chunk)
os.replace(tmp_name, write_target)
except _UploadTooLarge:
try:
os.unlink(tmp_name)
except OSError:
pass
return Response("upload exceeds size cap", status=413)
except OSError as exc:
try:
os.unlink(tmp_name)
except OSError:
pass
return Response(f"upload failed: {exc}", status=500)
return jsonify({"path": echo_path})
class _UploadTooLarge(Exception):
pass
def _join_clean(folder: str, leaf: str) -> str:
"""Join target_path + relative path safely, trimming slashes."""
folder = (folder or "").strip("/").strip()
leaf = (leaf or "").strip("/").strip()
if folder and leaf:
return f"{folder}/{leaf}"
return folder or leaf
@bp.post("/overlays/<int:overlay_id>/files/upload")
@require_login
def overlay_file_upload(overlay_id: int):
"""Single-file upload. Multi-file or whole-folder drops fan out client
side into one POST per file, each carrying its `webkitRelativePath` in
`relative_path`. Conflicts return 409 unless `overwrite=1`."""
user = current_user()
assert user is not None
target_folder = (request.form.get("target_path") or "").strip()
relative_path = (request.form.get("relative_path") or "").strip()
overwrite = request.form.get("overwrite") == "1"
upload = request.files.get("file")
if upload is None:
return Response("missing file", status=400)
# Filename fallback for browsers that don't send relative_path.
filename = relative_path or (upload.filename or "").strip()
if not filename:
return Response("missing filename", status=400)
# Normalise: strip any DOS-style path components from the filename.
filename = filename.replace("\\", "/")
full_rel = _join_clean(target_folder, filename)
if not full_rel:
return Response("missing destination path", status=400)
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
write_target = safe_resolve_for_write(overlay.path, full_rel)
except ValueError as exc:
return Response(str(exc), status=422)
if write_target.exists() and not overwrite:
return Response("file already exists", status=409)
write_target.parent.mkdir(parents=True, exist_ok=True)
return _stream_upload_into(upload, write_target, full_rel)
@bp.post("/overlays/<int:overlay_id>/files/move")
@require_login
def overlay_file_move(overlay_id: int):
"""Rename / move a file or folder."""
user = current_user()
assert user is not None
payload = request.get_json(silent=True) or {}
src = (payload.get("src") or "").strip()
dst = (payload.get("dst") or "").strip()
overwrite = bool(payload.get("overwrite"))
if not src or not dst:
return Response("missing src or dst", status=400)
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
src_path, dst_path = safe_resolve_for_move(overlay.path, src, dst)
except ValueError as exc:
return Response(str(exc), status=422)
if dst_path.exists() and not overwrite:
return Response("destination already exists", status=409)
dst_path.parent.mkdir(parents=True, exist_ok=True)
try:
if dst_path.exists() and overwrite:
if dst_path.is_dir() and not dst_path.is_symlink():
shutil.rmtree(dst_path)
else:
os.unlink(dst_path)
os.rename(src_path, dst_path)
except OSError as exc:
return Response(f"move failed: {exc}", status=500)
return jsonify({"src": src, "dst": dst})
@bp.post("/overlays/<int:overlay_id>/files/mkdir")
@require_login
def overlay_file_mkdir(overlay_id: int):
"""Create empty directory `path`. Slashes in `path` create intermediates."""
user = current_user()
assert user is not None
payload = request.get_json(silent=True) or {}
path = (payload.get("path") or "").strip("/").strip()
if not path:
return Response("missing path", status=400)
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
target = safe_resolve_for_write(overlay.path, path)
except ValueError as exc:
return Response(str(exc), status=422)
if target.exists():
if target.is_dir() and not target.is_symlink():
# Idempotent — folder already there.
return jsonify({"path": path})
return Response("destination already exists and is not a directory", status=409)
try:
target.mkdir(parents=True, exist_ok=False)
except FileExistsError:
return Response("directory already exists", status=409)
except OSError as exc:
return Response(f"mkdir failed: {exc}", status=500)
return jsonify({"path": path})
@bp.post("/overlays/<int:overlay_id>/files/delete")
@require_login
def overlay_file_delete(overlay_id: int):
"""Delete a file or empty folder. Refuses recursive directory removal."""
user = current_user()
assert user is not None
path = (request.form.get("path") or "").strip()
if not path:
return Response("missing path", status=400)
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
target = safe_resolve_for_delete(overlay.path, path)
except ValueError as exc:
return Response(str(exc), status=422)
if not target.exists() and not target.is_symlink():
return Response(status=404)
try:
if target.is_dir() and not target.is_symlink():
try:
target.rmdir()
except OSError:
return Response("directory is not empty", status=409)
else:
os.unlink(target)
except OSError as exc:
return Response(f"delete failed: {exc}", status=500)
return jsonify({"path": path})
@bp.get("/overlays/<int:overlay_id>/files/download_zip")
@require_login
def overlay_file_download_zip(overlay_id: int):
"""Stream a zip of the folder at `path` (or the overlay root). Symlinks
are written as their resolved file content (matches the regular download
endpoint's behavior — workshop-cache symlinks streamed as bytes)."""
user = current_user()
assert user is not None
sub_path = request.args.get("path", "")
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
try:
target = safe_resolve_for_listing(overlay.path, sub_path)
except ValueError:
return Response("invalid path", status=400)
if not target.exists() or not target.is_dir():
return Response(status=404)
folder_name = os.path.basename(str(target)) or "overlay"
download_name = f"{folder_name}.zip"
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(target, followlinks=False):
for name in files:
abs_path = os.path.join(root, name)
rel = os.path.relpath(abs_path, str(target))
try:
zf.write(abs_path, arcname=rel)
except OSError:
continue
# Include empty directories so the structure round-trips.
for name in dirs:
abs_dir = os.path.join(root, name)
rel_dir = os.path.relpath(abs_dir, str(target)) + "/"
if not any(True for _ in os.scandir(abs_dir)):
zf.writestr(zipfile.ZipInfo(rel_dir), b"")
buffer.seek(0)
return send_file(
buffer,
mimetype="application/zip",
as_attachment=True,
download_name=download_name,
)
@bp.get("/servers/<int:server_id>/files/download")
@require_login
def server_files_download(server_id: int):

View file

@ -16,7 +16,7 @@ from l4d2web.services.overlay_creation import (
)
CREATABLE_OVERLAY_TYPES = {"workshop", "script"}
CREATABLE_OVERLAY_TYPES = {"workshop", "script", "files"}
WIPE_SCRIPT = "find /overlay -mindepth 1 -delete"
@ -32,7 +32,7 @@ def _can_edit_overlay(overlay: Overlay, user) -> bool:
return False
if user.admin:
return True
if overlay.type in {"workshop", "script"}:
if overlay.type in {"workshop", "script", "files"}:
return overlay.user_id == user.id
return False
@ -68,7 +68,14 @@ def create_overlay() -> Response:
if _name_already_taken(db, name, scope_user_id):
return Response("overlay already exists", status=409)
overlay = Overlay(name=name, path="", type=overlay_type, user_id=scope_user_id)
last_build_status = "ok" if overlay_type == "files" else ""
overlay = Overlay(
name=name,
path="",
type=overlay_type,
user_id=scope_user_id,
last_build_status=last_build_status,
)
db.add(overlay)
db.flush()
overlay.path = generate_overlay_path(overlay.id)

View file

@ -1,7 +1,7 @@
import json
from flask import Blueprint, Response, redirect, render_template, request
from sqlalchemy import select
from sqlalchemy import func, select, update
from l4d2web.auth import current_user, require_admin, require_login
from l4d2web.db import session_scope
@ -55,6 +55,76 @@ def admin_users() -> str:
return render_template("admin_users.html", users=users)
@bp.post("/admin/users/<int:user_id>/deactivate")
@require_admin
def admin_users_deactivate(user_id: int) -> Response:
actor = current_user()
assert actor is not None
if actor.id == user_id:
return Response("cannot deactivate yourself", status=409)
with session_scope() as db:
target = db.scalar(select(User).where(User.id == user_id))
if target is None:
return Response(status=404)
target.active = False
return redirect("/admin/users")
@bp.post("/admin/users/<int:user_id>/activate")
@require_admin
def admin_users_activate(user_id: int) -> Response:
with session_scope() as db:
target = db.scalar(select(User).where(User.id == user_id))
if target is None:
return Response(status=404)
target.active = True
return redirect("/admin/users")
@bp.post("/admin/users/<int:user_id>/delete")
@require_admin
def admin_users_delete(user_id: int) -> Response:
actor = current_user()
assert actor is not None
if actor.id == user_id:
return Response("cannot delete yourself", status=409)
with session_scope() as db:
target = db.scalar(select(User).where(User.id == user_id))
if target is None:
return Response(status=404)
if target.admin:
other_admins = db.scalar(
select(func.count(User.id)).where(User.admin.is_(True), User.id != user_id)
) or 0
if other_admins == 0:
return Response("cannot delete the last admin", status=409)
owned_servers = db.scalar(
select(func.count(Server.id)).where(Server.user_id == user_id)
) or 0
owned_blueprints = db.scalar(
select(func.count(BlueprintModel.id)).where(BlueprintModel.user_id == user_id)
) or 0
owned_overlays = db.scalar(
select(func.count(Overlay.id)).where(Overlay.user_id == user_id)
) or 0
if owned_servers or owned_blueprints or owned_overlays:
return Response(
f"user owns content: {owned_servers} server(s), "
f"{owned_blueprints} blueprint(s), {owned_overlays} overlay(s) — "
"delete those first",
status=409,
)
# Job rows have nullable user_id — keep them as audit trail with the FK nulled out.
db.execute(update(Job).where(Job.user_id == user_id).values(user_id=None))
db.delete(target)
return redirect("/admin/users")
@bp.get("/admin/jobs")
@require_admin
def admin_jobs() -> str:

View file

@ -280,7 +280,27 @@ def _is_under(path: Path, root: Path) -> bool:
return True
class FilesBuilder:
"""No-op builder for `files` overlays. Their content IS the overlay
directory every save / upload / move / delete is immediately
authoritative. The build step exists only so the overlay-build subsystem
can dispatch uniformly across all overlay types; here it simply ensures
the overlay directory exists."""
def build(
self,
overlay: Overlay,
*,
on_stdout: LogSink,
on_stderr: LogSink,
should_cancel: CancelCheck,
) -> None:
overlay_path_for_id(overlay.id).mkdir(parents=True, exist_ok=True)
on_stdout(f"files overlay {overlay.name!r}: directory ensured (no-op build)")
BUILDERS: dict[str, OverlayBuilder] = {
"workshop": WorkshopBuilder(),
"script": ScriptBuilder(),
"files": FilesBuilder(),
}

View file

@ -33,6 +33,113 @@ def safe_resolve_for_listing(overlay_path_value: str, sub_path: str) -> Path:
return candidate
def safe_resolve_for_write(overlay_path_value: str, sub_path: str) -> Path:
"""Resolve a destination path for create / write / replace operations.
Refuses empty `sub_path`, escape via `..` or symlink target, refuses to
overwrite an existing symlink, and refuses a path whose parent resolves
to a non-directory. Returns the lexical (un-resolved) candidate so the
caller can write through to the user-visible path; the safety check is
done against the resolved variant.
"""
if sub_path == "":
raise ValueError("write requires a sub-path")
validate_overlay_ref(sub_path)
overlay_root = resolve_overlay_root(overlay_path_value).resolve()
candidate = overlay_root / sub_path
resolved_candidate = candidate.resolve(strict=False)
if not _is_under(resolved_candidate, overlay_root):
raise ValueError("path escapes overlay root")
if candidate.is_symlink():
raise ValueError("refusing to overwrite an existing symlink")
parent = candidate.parent
if parent.exists() and not parent.is_dir():
raise ValueError("parent path is not a directory")
return candidate
def safe_resolve_for_delete(overlay_path_value: str, sub_path: str) -> Path:
"""Resolve a path the user wants to delete. Same root-escape rules as
`safe_resolve_for_listing`. Allows files and (empty) directories; the
caller is responsible for refusing recursive directory removal."""
if sub_path == "":
raise ValueError("delete requires a sub-path")
validate_overlay_ref(sub_path)
overlay_root = resolve_overlay_root(overlay_path_value).resolve()
candidate = overlay_root / sub_path
resolved_candidate = candidate.resolve(strict=False)
if not _is_under(resolved_candidate, overlay_root):
raise ValueError("path escapes overlay root")
return candidate
def safe_resolve_for_move(
overlay_path_value: str, src: str, dst: str
) -> tuple[Path, Path]:
"""Resolve src + dst for a rename/move. Both must be inside the overlay
root. Refuses cycle (dst inside src), missing src, missing/non-dir parent
of dst, and overwriting a symlink at dst."""
if src == "" or dst == "":
raise ValueError("move requires non-empty src and dst")
validate_overlay_ref(src)
validate_overlay_ref(dst)
overlay_root = resolve_overlay_root(overlay_path_value).resolve()
src_path = overlay_root / src
dst_path = overlay_root / dst
src_resolved = src_path.resolve(strict=False)
dst_resolved = dst_path.resolve(strict=False)
if not _is_under(src_resolved, overlay_root):
raise ValueError("src escapes overlay root")
if not _is_under(dst_resolved, overlay_root):
raise ValueError("dst escapes overlay root")
if not src_path.exists() and not src_path.is_symlink():
raise ValueError("src does not exist")
if dst_path.is_symlink():
raise ValueError("refusing to overwrite an existing symlink at dst")
dst_parent = dst_path.parent
if dst_parent.exists() and not dst_parent.is_dir():
raise ValueError("dst parent is not a directory")
if src_path.is_dir():
# Cycle check: dst must not be src or any descendant of src.
if dst_resolved == src_resolved or src_resolved in dst_resolved.parents:
raise ValueError("cannot move a directory into itself")
return src_path, dst_path
_EDITABLE_MAX_BYTES = 1 * 1024 * 1024
_UTF8_SNIFF_BYTES = 8 * 1024
def is_editable(path: Path) -> bool:
"""True iff the file is a regular file (not symlink), ≤ 1 MiB, the first
8 KiB decodes as strict UTF-8, and contains no NUL bytes. NULs decode as
valid UTF-8 (U+0000) but render badly in textareas and almost always
indicate binary content; rejecting them matches the "is this a text
file" intuition users expect. Save endpoint enforces the same rules."""
try:
if path.is_symlink():
return False
if not path.is_file():
return False
size = path.stat().st_size
except OSError:
return False
if size > _EDITABLE_MAX_BYTES:
return False
try:
with path.open("rb") as f:
sample = f.read(_UTF8_SNIFF_BYTES)
except OSError:
return False
if b"\x00" in sample:
return False
try:
sample.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return False
return True
def safe_resolve_for_server_listing(server_id: int, sub_path: str) -> Path | None:
"""Resolve a path inside `runtime/<server_id>/merged` (the kernel-overlayfs
composed view of a running server). Returns None if the merged dir doesn't
@ -108,6 +215,11 @@ def _entry_dict(entry: "os.DirEntry[str]", overlay_root: Path) -> dict:
rel_str = "/".join(Path(entry.path).relative_to(overlay_root).parts)
if kind == "file" and not broken:
editable = is_editable(Path(entry.path))
else:
editable = False
return {
"name": entry.name,
"rel": rel_str,
@ -116,6 +228,7 @@ def _entry_dict(entry: "os.DirEntry[str]", overlay_root: Path) -> dict:
"broken": broken,
"size": size,
"size_human": _format_size(size) if size is not None else "",
"editable": editable,
}

View file

@ -510,3 +510,347 @@ button.danger-outline:hover {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* ============================================================
Files-overlay editor: tree-row hover actions, drop targets,
editor / new-folder / conflict / delete modals, and the
floating Uploads panel.
============================================================ */
/* The display: flex / grid declarations on the elements below have the
same specificity as the UA's `[hidden]{display:none}` rule and come
later in the cascade, so without this they'd win and elements would
stay visible after JS sets `el.hidden = true`. Targeted rather than
a global `[hidden]!important` so we don't fight unknown UA defaults. */
.files-uploads[hidden],
.files-editor-binary[hidden],
.files-editor-text[hidden],
.files-editor-replace-idle[hidden],
.files-editor-replace-queued[hidden],
.files-editor-rename-hint[hidden],
.files-uploads-clear[hidden] {
display: none !important;
}
.files-manager {
display: grid;
gap: var(--space-s);
}
.files-manager-hint {
font-size: 0.85em;
margin: 0;
}
.files-tree-root {
border: var(--line-soft);
border-radius: var(--radius-s);
padding: var(--space-s);
min-height: 3rem;
}
.files-tree-root .file-tree {
margin: 0;
}
.files-tree-root.is-drop-target {
outline: 2px solid var(--color-success);
outline-offset: -2px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
}
.files-row-root > .files-row-root-label {
color: var(--color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.files-root-children {
flex-basis: 100%;
margin-top: var(--space-xs);
}
.files-empty {
margin: var(--space-s) var(--space-xs);
}
/* Per-row action column. Lives inside a .file-tree-row. Hidden until row
is hovered, except on touch devices where hover doesn't apply. */
.files-row {
position: relative;
}
.files-row .files-row-actions {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
padding-left: var(--space-m);
opacity: 0;
pointer-events: none;
transition: opacity 80ms ease;
white-space: nowrap;
}
.files-row:hover > .files-row-actions,
.files-row:focus-within > .files-row-actions,
.files-row.is-drop-target > .files-row-actions,
.files-row.is-drag-source > .files-row-actions {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.files-row .files-row-actions {
opacity: 1;
pointer-events: auto;
}
}
.files-row-action {
background: none;
border: 1px solid transparent;
color: var(--color-link);
padding: 0 var(--space-xs);
font-size: 0.85em;
border-radius: var(--radius-s);
cursor: pointer;
text-decoration: none;
line-height: 1.3;
}
.files-row-action:hover {
background: var(--color-surface-muted);
border-color: var(--color-link);
}
.files-row-danger {
color: var(--color-danger);
}
.files-row-danger:hover {
border-color: var(--color-danger);
}
.files-row.is-drag-source {
opacity: 0.5;
}
.files-row.is-drop-target {
outline: 2px solid var(--color-success);
outline-offset: -2px;
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--color-success) 10%, transparent);
}
/* Wider modal for the editor (textarea needs the breathing room). */
dialog.modal.modal-wide {
width: min(48rem, 92vw);
}
.files-editor-field {
display: grid;
gap: 2px;
}
.files-editor-field input,
.files-editor-field textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
}
.files-field-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-muted);
}
.files-editor-path {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
margin: 0;
}
.files-editor-meta {
display: flex;
justify-content: space-between;
font-size: 0.85em;
}
.files-editor-rename-hint {
color: var(--color-success);
font-size: 0.85em;
margin: 0;
}
.files-editor-rename-hint code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.files-editor-binary {
background: var(--color-surface-muted);
border: var(--line);
border-radius: var(--radius-s);
padding: var(--space-m);
display: grid;
gap: var(--space-s);
}
.files-editor-binary-note {
color: var(--color-muted);
font-size: 0.9em;
}
.files-editor-binary-replace-label {
margin-top: var(--space-xs);
}
.files-editor-replace-zone {
border: 1.5px dashed var(--color-success);
border-radius: var(--radius-s);
padding: var(--space-m);
text-align: center;
background: color-mix(in srgb, var(--color-success) 5%, transparent);
}
.files-editor-replace-zone.is-drop-target {
background: color-mix(in srgb, var(--color-success) 22%, transparent);
}
.files-editor-replace-idle {
color: var(--color-success);
margin: 0;
font-size: 0.9em;
}
.files-editor-replace-queued {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
margin: 0;
font-size: 0.9em;
color: var(--color-success);
}
.files-editor-footer {
align-items: center;
}
.files-editor-footer-spacer {
flex: 1;
}
.files-editor-delete {
margin-right: auto;
}
.files-editor-save {
background: var(--color-success);
}
.files-editor-save[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.files-new-folder-error {
color: var(--color-danger);
margin: 0;
font-size: 0.9em;
}
.files-conflict-path {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Floating uploads panel — bottom-right of the page. */
.files-uploads {
position: fixed;
right: var(--space-l);
bottom: var(--space-l);
width: min(22rem, calc(100vw - 2 * var(--space-l)));
background: var(--color-surface);
border: var(--line);
border-radius: var(--radius-m);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
z-index: 50;
max-height: 60vh;
display: flex;
flex-direction: column;
}
.files-uploads-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-s) var(--space-m);
border-bottom: var(--line);
}
.files-uploads-list {
list-style: none;
margin: 0;
padding: var(--space-s) var(--space-m);
overflow-y: auto;
display: grid;
gap: var(--space-s);
}
.files-uploads-row {
display: grid;
gap: 2px;
font-size: 0.85em;
}
.files-uploads-row-meta {
display: flex;
justify-content: space-between;
gap: var(--space-s);
align-items: baseline;
}
.files-uploads-row-name {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.files-uploads-row-target {
font-size: 0.85em;
}
.files-uploads-row-progress {
height: 3px;
background: var(--color-surface-muted);
border-radius: 2px;
overflow: hidden;
}
.files-uploads-bar {
width: 0;
height: 100%;
background: var(--color-link);
transition: width 80ms linear;
}
.files-uploads-bar.is-done {
background: var(--color-success);
}
.files-uploads-bar.is-error {
background: var(--color-danger);
}
.files-uploads-bar.is-cancelled {
background: var(--color-muted);
}
.files-uploads-row-status {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8em;
color: var(--color-muted);
}

View file

@ -4,13 +4,18 @@
// 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.
//
// Children-div lookup goes through the row's <li> rather than the button's
// nextElementSibling so the files-overlay variant — where a per-row action
// column sits between the toggle button and the children div — works too.
(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 row = button.closest(".file-tree-row");
const children = row ? row.querySelector(":scope > .file-tree-children") : null;
if (!children) return;
const wasExpanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", wasExpanded ? "false" : "true");

View file

@ -0,0 +1,984 @@
// Files-overlay UI behavior. Activated only on overlay detail pages whose
// `<div class="files-manager">` exists (set by the template when the
// overlay is type='files' and the user can edit). The script binds:
//
// * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on
// folders; edit, ✕ on files (download is a regular <a>).
// * Drag-and-drop: dragging from the OS uploads (one POST per file,
// queue with concurrency 3); dragging a row inside the tree moves
// (rename/move via /files/move).
// * Editor modal: text mode for editable files; binary "details +
// replace upload" mode for everything else; doubles as new-file
// dialog. Filename input is the rename surface.
// * New-folder modal, conflict-resolution modal, delete-confirm modal.
// * Upload progress panel with per-file rows.
//
// All operations use the `/overlays/<id>/files/...` JSON / multipart
// endpoints. CSRF token comes from the <meta name="csrf-token"> tag.
(function () {
"use strict";
const manager = document.querySelector(".files-manager");
if (!manager) return;
const overlayId = manager.dataset.overlayId;
const baseUrl = manager.dataset.baseUrl; // /overlays/<id>
const treeRoot = manager.querySelector(".files-tree-root");
const csrfToken =
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
const editorDialog = document.getElementById("files-editor-modal");
const newFolderDialog = document.getElementById("files-new-folder-modal");
const conflictDialog = document.getElementById("files-conflict-modal");
const deleteDialog = document.getElementById("files-delete-modal");
const uploadsPanel = document.querySelector(".files-uploads");
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
// ---------- helpers ------------------------------------------------------
function joinPath(folder, leaf) {
folder = (folder || "").replace(/^\/+|\/+$/g, "");
leaf = (leaf || "").replace(/^\/+|\/+$/g, "");
if (folder && leaf) return folder + "/" + leaf;
return folder || leaf;
}
function parentOf(rel) {
const i = (rel || "").lastIndexOf("/");
return i < 0 ? "" : rel.slice(0, i);
}
function basename(rel) {
const i = (rel || "").lastIndexOf("/");
return i < 0 ? rel : rel.slice(i + 1);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c]);
}
function humanSize(bytes) {
if (bytes === undefined || bytes === null) return "";
if (bytes < 1024) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let v = bytes / 1024;
let u = "KB";
for (const next of units) {
u = next;
if (v < 1024) break;
v /= 1024;
}
return v.toFixed(1) + " " + u;
}
async function fetchJson(url, options) {
options = options || {};
options.headers = Object.assign({ Accept: "application/json" }, options.headers || {});
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = csrfToken;
}
options.credentials = "same-origin";
const response = await fetch(url, options);
let body = null;
try {
const text = await response.text();
body = text ? JSON.parse(text) : null;
} catch (_e) {
body = null;
}
return { ok: response.ok, status: response.status, body };
}
async function postJson(url, payload) {
return fetchJson(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
async function postForm(url, formData) {
return fetchJson(url, { method: "POST", body: formData });
}
// ---------- tree refresh ------------------------------------------------
// Re-fetch a folder's listing partial and swap it into the tree.
// `path === ""` refreshes the overlay root container.
async function refreshFolder(path) {
if (!treeRoot) return;
const url = `${baseUrl}/files?path=${encodeURIComponent(path || "")}`;
let html;
try {
const r = await fetch(url, {
headers: { Accept: "text/html" },
credentials: "same-origin",
});
if (!r.ok) {
if (r.status === 404) {
// Folder no longer exists — refresh its parent instead.
if (path) await refreshFolder(parentOf(path));
return;
}
return;
}
html = await r.text();
} catch (_e) {
return;
}
if (!path) {
// Overlay root — swap into the synthetic root row's children div.
const target = manager.querySelector(".files-root-children");
if (!target) return;
const empty = target.querySelector(".files-empty");
if (empty) empty.remove();
const existingUl = target.querySelector(":scope > ul.file-tree");
if (existingUl) existingUl.remove();
target.insertAdjacentHTML("beforeend", html);
// If the new content is also empty, restore the placeholder.
const newUl = target.querySelector(":scope > ul.file-tree");
if (newUl && newUl.children.length === 0) {
newUl.remove();
const p = document.createElement("p");
p.className = "muted files-empty";
p.textContent = 'Empty — drop files here, or click "+ new file" on this row.';
target.appendChild(p);
}
return;
}
// Sub-folder: find the matching folder row and swap its children div.
const row = findRowByPath(path, "dir");
if (!row) return;
const childrenDiv = row.querySelector(":scope > .file-tree-children");
const toggleBtn = row.querySelector(":scope > .file-tree-toggle");
if (!childrenDiv || !toggleBtn) return;
childrenDiv.innerHTML = html;
childrenDiv.hidden = false;
toggleBtn.setAttribute("aria-expanded", "true");
toggleBtn.dataset.loaded = "1";
}
function findRowByPath(path, kind) {
const sel = kind
? `[data-target-path="${cssEscape(path)}"][data-row-kind="${kind}"]`
: `[data-target-path="${cssEscape(path)}"]`;
return treeRoot ? treeRoot.querySelector(sel) : null;
}
function cssEscape(s) {
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
return String(s).replace(/["\\]/g, "\\$&");
}
// Debounce per-folder refreshes so a flurry of finishes coalesces.
const pendingRefresh = new Map();
function scheduleRefresh(path) {
const key = path || "";
if (pendingRefresh.has(key)) return;
const timer = setTimeout(() => {
pendingRefresh.delete(key);
refreshFolder(key);
}, 50);
pendingRefresh.set(key, timer);
}
// ---------- conflict modal ----------------------------------------------
function askConflict(path) {
return new Promise((resolve) => {
conflictDialog.querySelector(".files-conflict-path").textContent = path;
const handler = (event) => {
const action = event.target?.dataset?.filesConflictAction;
if (!action) return;
conflictDialog.removeEventListener("click", handler);
conflictDialog.close();
resolve(action);
};
conflictDialog.addEventListener("click", handler);
conflictDialog.showModal();
});
}
// Attach a path-collision suffix: foo.txt → foo (1).txt
function withCollisionSuffix(path) {
const dot = path.lastIndexOf(".");
const slash = path.lastIndexOf("/");
if (dot > slash + 0 && dot > -1) {
return path.slice(0, dot) + " (1)" + path.slice(dot);
}
return path + " (1)";
}
// ---------- delete modal ------------------------------------------------
function openDelete(targetPath, kind, name) {
deleteDialog.querySelector(".files-delete-name").textContent = name;
const errEl = deleteDialog.querySelector(".files-delete-error");
errEl.hidden = true;
errEl.textContent = "";
const confirmBtn = deleteDialog.querySelector(".files-delete-confirm");
const onConfirm = async () => {
const fd = new FormData();
fd.append("path", targetPath);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
deleteDialog.close();
scheduleRefresh(parentOf(targetPath));
} else {
errEl.hidden = false;
errEl.textContent = (r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`;
}
};
confirmBtn.replaceWith(confirmBtn.cloneNode(true));
deleteDialog.querySelector(".files-delete-confirm").addEventListener("click", onConfirm);
deleteDialog.showModal();
}
// ---------- editor modal ------------------------------------------------
// Editor state. Only one editor is open at a time.
const editor = {
mode: null, // "text" | "binary"
creating: false,
originalPath: null,
folder: null,
queuedReplacement: null, // File object
};
const editorEls = {
title: editorDialog.querySelector(".files-editor-title-text"),
filename: editorDialog.querySelector(".files-editor-filename"),
renameHint: editorDialog.querySelector(".files-editor-rename-hint"),
renameFrom: editorDialog.querySelector(".files-rename-from"),
renameTo: editorDialog.querySelector(".files-rename-to"),
textPanel: editorDialog.querySelector(".files-editor-text"),
contentBox: editorDialog.querySelector(".files-editor-content"),
byteCount: editorDialog.querySelector(".files-editor-byte-count"),
binaryPanel: editorDialog.querySelector(".files-editor-binary"),
binarySize: editorDialog.querySelector(".files-editor-binary-size"),
replaceZone: editorDialog.querySelector(".files-editor-replace-zone"),
replaceIdle: editorDialog.querySelector(".files-editor-replace-idle"),
replaceQueued: editorDialog.querySelector(".files-editor-replace-queued"),
replaceName: editorDialog.querySelector(".files-editor-replace-name"),
replaceSize: editorDialog.querySelector(".files-editor-replace-size"),
replaceClear: editorDialog.querySelector(".files-editor-replace-clear"),
replaceBrowse: editorDialog.querySelector(".files-editor-replace-browse"),
replaceInput: editorDialog.querySelector(".files-editor-replace-input"),
deleteBtn: editorDialog.querySelector(".files-editor-delete"),
downloadBtn: editorDialog.querySelector(".files-editor-download"),
saveBtn: editorDialog.querySelector(".files-editor-save"),
};
function setEditorTitle(text) {
editorEls.title.textContent = text;
}
function updateByteCount() {
const bytes = new TextEncoder().encode(editorEls.contentBox.value).length;
editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`;
}
function updateRenameHint() {
const current = editorEls.filename.value.trim();
const original = basename(editor.originalPath || "");
if (editor.creating || !current || current === original) {
editorEls.renameHint.hidden = true;
return;
}
editorEls.renameFrom.textContent = original;
editorEls.renameTo.textContent = current;
editorEls.renameHint.hidden = false;
}
function updateSaveEnabled() {
if (editor.mode === "binary" && !editor.creating) {
const filenameChanged =
editorEls.filename.value.trim() !== basename(editor.originalPath || "");
const hasReplacement = !!editor.queuedReplacement;
editorEls.saveBtn.disabled = !filenameChanged && !hasReplacement;
editorEls.saveBtn.textContent = "Save";
} else if (editor.creating) {
editorEls.saveBtn.disabled = !editorEls.filename.value.trim();
editorEls.saveBtn.textContent = "Create";
} else {
editorEls.saveBtn.disabled = false;
editorEls.saveBtn.textContent = "Save";
}
}
function setQueuedReplacement(file) {
editor.queuedReplacement = file;
if (file) {
editorEls.replaceIdle.hidden = true;
editorEls.replaceQueued.hidden = false;
editorEls.replaceName.textContent = file.name;
editorEls.replaceSize.textContent = humanSize(file.size);
} else {
editorEls.replaceIdle.hidden = false;
editorEls.replaceQueued.hidden = true;
}
updateSaveEnabled();
}
function openEditorTextNew(folder) {
editor.mode = "text";
editor.creating = true;
editor.originalPath = null;
editor.folder = folder;
editor.queuedReplacement = null;
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
editorEls.filename.value = "";
editorEls.filename.disabled = false;
editorEls.contentBox.value = "";
editorEls.contentBox.disabled = false;
editorEls.renameHint.hidden = true;
editorEls.textPanel.hidden = false;
editorEls.binaryPanel.hidden = true;
editorEls.deleteBtn.hidden = true;
editorEls.downloadBtn.hidden = true;
editorEls.saveBtn.textContent = "Create";
updateByteCount();
updateSaveEnabled();
editorDialog.showModal();
setTimeout(() => editorEls.filename.focus(), 0);
}
async function openEditorForFile(path, isEditable) {
editor.creating = false;
editor.originalPath = path;
editor.folder = parentOf(path);
editor.queuedReplacement = null;
setQueuedReplacement(null);
editorEls.filename.value = basename(path);
editorEls.filename.disabled = false;
editorEls.renameHint.hidden = true;
editorEls.deleteBtn.hidden = false;
editorEls.downloadBtn.hidden = false;
editorEls.downloadBtn.href = `${baseUrl}/files/download?path=${encodeURIComponent(path)}`;
setEditorTitle(path);
if (isEditable) {
editor.mode = "text";
editorEls.textPanel.hidden = false;
editorEls.binaryPanel.hidden = true;
editorEls.contentBox.value = "Loading…";
editorEls.contentBox.disabled = true;
const r = await fetchJson(
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
);
if (r.ok && r.body) {
editorEls.contentBox.value = r.body.content;
editorEls.contentBox.disabled = false;
updateByteCount();
updateSaveEnabled();
editorDialog.showModal();
setTimeout(() => editorEls.contentBox.focus(), 0);
return;
}
// Fallback: server says not editable. Re-open as binary.
editorEls.contentBox.disabled = false;
editor.mode = "binary";
} else {
editor.mode = "binary";
}
// Binary mode setup.
editorEls.textPanel.hidden = true;
editorEls.binaryPanel.hidden = false;
editorEls.binarySize.textContent = "—"; // server gave us no size; cosmetic
updateSaveEnabled();
editorDialog.showModal();
setTimeout(() => editorEls.filename.focus(), 0);
}
editorEls.filename.addEventListener("input", () => {
updateRenameHint();
updateSaveEnabled();
});
editorEls.contentBox.addEventListener("input", () => {
updateByteCount();
updateSaveEnabled();
});
editorEls.contentBox.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
editorEls.saveBtn.click();
}
});
editorEls.replaceClear.addEventListener("click", () => setQueuedReplacement(null));
editorEls.replaceBrowse.addEventListener("click", () => editorEls.replaceInput.click());
editorEls.replaceInput.addEventListener("change", () => {
const f = editorEls.replaceInput.files && editorEls.replaceInput.files[0];
if (f) setQueuedReplacement(f);
});
editorEls.replaceZone.addEventListener("dragover", (event) => {
if (Array.from(event.dataTransfer.types).includes("Files")) {
event.preventDefault();
editorEls.replaceZone.classList.add("is-drop-target");
}
});
editorEls.replaceZone.addEventListener("dragleave", () => {
editorEls.replaceZone.classList.remove("is-drop-target");
});
editorEls.replaceZone.addEventListener("drop", (event) => {
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
editorEls.replaceZone.classList.remove("is-drop-target");
const f = event.dataTransfer.files && event.dataTransfer.files[0];
if (f) setQueuedReplacement(f);
});
editorDialog.addEventListener("close", () => {
setQueuedReplacement(null);
});
editorEls.saveBtn.addEventListener("click", async () => {
const filename = editorEls.filename.value.trim();
if (!filename) return;
if (filename.includes("/")) {
alert("Filename can't contain '/'. Use drag-to-move to relocate.");
return;
}
const folder = editor.folder || "";
const newRel = joinPath(folder, filename);
if (editor.creating) {
// Text-flavor create → /save with no new_path.
const r = await postJson(`${baseUrl}/files/save`, {
path: newRel,
content: editorEls.contentBox.value,
});
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else if (r.status === 409) {
const action = await askConflict(newRel);
if (action === "overwrite") {
// Re-call /save (no overwrite flag — /save just writes); skip
// the conflict by writing in-place which is what users want.
// First delete the colliding entry to avoid the implicit
// "destination is not a file" branch when it's a directory.
// For files, a plain /save overwrite is fine.
const r2 = await postJson(`${baseUrl}/files/save`, {
path: newRel,
content: editorEls.contentBox.value,
});
if (r2.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r2.body && r2.body.error) ||
`Save failed (HTTP ${r2.status}).`
);
}
} else if (action === "keep-both") {
const altered = withCollisionSuffix(newRel);
const r2 = await postJson(`${baseUrl}/files/save`, {
path: altered,
content: editorEls.contentBox.value,
});
if (r2.ok) {
editorDialog.close();
scheduleRefresh(folder);
}
}
} else {
alert((r.body && r.body.error) || `Save failed (HTTP ${r.status}).`);
}
return;
}
const renaming = newRel !== editor.originalPath;
if (editor.mode === "text") {
const payload = {
path: editor.originalPath,
content: editorEls.contentBox.value,
};
if (renaming) payload.new_path = newRel;
const r = await postJson(`${baseUrl}/files/save`, payload);
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r.body && r.body.error) || `Save failed (HTTP ${r.status}).`
);
}
return;
}
// Binary mode.
const fd = new FormData();
fd.append("path", editor.originalPath);
fd.append("csrf_token", csrfToken);
if (renaming) fd.append("new_path", newRel);
if (editor.queuedReplacement) {
fd.append("file", editor.queuedReplacement);
const r = await postForm(`${baseUrl}/files/replace`, fd);
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`
);
}
} else if (renaming) {
// Rename only via /move (no content change).
const r = await postJson(`${baseUrl}/files/move`, {
src: editor.originalPath,
dst: newRel,
});
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`
);
}
}
});
editorEls.deleteBtn.addEventListener("click", async () => {
if (!editor.originalPath) return;
if (!confirm(`Delete ${editor.originalPath}?`)) return;
const fd = new FormData();
fd.append("path", editor.originalPath);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
editorDialog.close();
scheduleRefresh(parentOf(editor.originalPath));
} else {
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
}
});
// ---------- new-folder modal --------------------------------------------
function openNewFolder(targetFolder) {
const folder = targetFolder || "";
newFolderDialog.querySelector(".files-new-folder-target").textContent =
folder ? folder + "/" : "/";
const input = newFolderDialog.querySelector(".files-new-folder-name");
const errEl = newFolderDialog.querySelector(".files-new-folder-error");
input.value = "";
errEl.hidden = true;
errEl.textContent = "";
const createBtn = newFolderDialog.querySelector(".files-new-folder-create");
const fresh = createBtn.cloneNode(true);
createBtn.replaceWith(fresh);
fresh.addEventListener("click", async () => {
const name = input.value.trim().replace(/^\/+|\/+$/g, "");
if (!name) return;
const fullPath = joinPath(folder, name);
const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath });
if (r.ok) {
newFolderDialog.close();
scheduleRefresh(folder);
} else {
errEl.hidden = false;
errEl.textContent =
(r.body && r.body.error) ||
(r.status === 409
? "A file or folder with that name already exists."
: `Failed (HTTP ${r.status}).`);
}
});
newFolderDialog.showModal();
setTimeout(() => input.focus(), 0);
}
// Enter on the new-folder input submits — bound once at module init so
// it survives multiple openings of the dialog. (A previous version used
// `{once: true}` inside openNewFolder, which was consumed by the first
// letter the user typed and never saw Enter.)
newFolderDialog
.querySelector(".files-new-folder-name")
.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
newFolderDialog.querySelector(".files-new-folder-create")?.click();
});
// ---------- upload queue + progress panel -------------------------------
const uploadQueue = [];
let uploadActive = 0;
const UPLOAD_CONCURRENCY = 3;
function showPanel() {
if (uploadsPanel) uploadsPanel.hidden = false;
}
function maybeHidePanel() {
if (!uploadsList) return;
const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']");
if (!anyActive && uploadsList.children.length === 0) {
uploadsPanel.hidden = true;
if (uploadsClearBtn) uploadsClearBtn.hidden = true;
}
const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']");
if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone;
}
function buildUploadRow(item) {
const li = document.createElement("li");
li.className = "files-uploads-row";
li.dataset.state = "queued";
li.innerHTML = `
<div class="files-uploads-row-meta">
<span class="files-uploads-row-name"></span>
<span class="files-uploads-row-target muted"></span>
</div>
<div class="files-uploads-row-progress"><div class="files-uploads-bar"></div></div>
<div class="files-uploads-row-status">
<span class="files-uploads-row-state">queued</span>
<button type="button" class="link-button files-uploads-row-cancel" aria-label="Cancel"></button>
</div>
`;
li.querySelector(".files-uploads-row-name").textContent = item.relative;
li.querySelector(".files-uploads-row-target").textContent = `${item.targetFolder || "(root)"}`;
li.querySelector(".files-uploads-row-cancel").addEventListener("click", () => cancelUpload(item));
return li;
}
function setRowState(item, state, percent) {
if (!item.row) return;
item.row.dataset.state = state;
const stateEl = item.row.querySelector(".files-uploads-row-state");
const cancelBtn = item.row.querySelector(".files-uploads-row-cancel");
const bar = item.row.querySelector(".files-uploads-bar");
if (state === "queued") {
stateEl.textContent = "queued";
bar.style.width = "0%";
} else if (state === "active") {
stateEl.textContent = `${Math.round(percent || 0)}%`;
bar.style.width = `${percent || 0}%`;
} else if (state === "done") {
stateEl.textContent = "done";
bar.style.width = "100%";
bar.classList.add("is-done");
cancelBtn.textContent = "✓";
cancelBtn.disabled = true;
} else if (state === "cancelled") {
stateEl.textContent = "cancelled";
bar.classList.add("is-cancelled");
cancelBtn.disabled = true;
} else if (state === "error") {
stateEl.textContent = item.errorText || "error";
bar.classList.add("is-error");
cancelBtn.textContent = "✕";
} else if (state === "conflict") {
stateEl.textContent = "conflict — overwrite / keep both";
}
}
function cancelUpload(item) {
if (item.xhr && item.row.dataset.state === "active") {
item.cancelled = true;
item.xhr.abort();
}
if (item.row.dataset.state === "queued") {
const idx = uploadQueue.indexOf(item);
if (idx >= 0) uploadQueue.splice(idx, 1);
setRowState(item, "cancelled");
}
}
uploadsClearBtn?.addEventListener("click", () => {
uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => row.remove());
maybeHidePanel();
});
function enqueueUpload(file, targetFolder, relativePath) {
const item = {
file,
targetFolder,
relative: relativePath || file.name,
cancelled: false,
xhr: null,
errorText: null,
};
item.row = buildUploadRow(item);
uploadsList.prepend(item.row);
uploadQueue.push(item);
showPanel();
pump();
return item;
}
function pump() {
while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) {
const item = uploadQueue.shift();
runUpload(item);
}
}
function runUpload(item, overwriteFlag) {
uploadActive += 1;
const fd = new FormData();
fd.append("target_path", item.targetFolder || "");
fd.append("relative_path", item.relative);
fd.append("csrf_token", csrfToken);
if (overwriteFlag) fd.append("overwrite", "1");
fd.append("file", item.file, item.file.name);
const xhr = new XMLHttpRequest();
item.xhr = xhr;
xhr.open("POST", `${baseUrl}/files/upload`);
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const pct = (event.loaded / event.total) * 100;
setRowState(item, "active", pct);
}
};
setRowState(item, "active", 0);
xhr.onload = () => {
uploadActive -= 1;
if (xhr.status >= 200 && xhr.status < 300) {
setRowState(item, "done");
scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative)));
} else if (xhr.status === 409 && !overwriteFlag) {
setRowState(item, "conflict");
const collidingPath = joinPath(item.targetFolder || "", item.relative);
askConflict(collidingPath).then((action) => {
if (action === "overwrite") {
runUpload(item, true);
} else if (action === "keep-both") {
item.relative = withCollisionSuffix(item.relative);
item.row.querySelector(".files-uploads-row-name").textContent = item.relative;
runUpload(item, false);
} else {
setRowState(item, "cancelled");
}
});
} else {
item.errorText = `HTTP ${xhr.status}`;
try {
const text = xhr.responseText;
if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`;
} catch (_e) {}
setRowState(item, "error");
}
maybeHidePanel();
pump();
};
xhr.onerror = () => {
uploadActive -= 1;
item.errorText = item.cancelled ? "cancelled" : "network error";
setRowState(item, item.cancelled ? "cancelled" : "error");
maybeHidePanel();
pump();
};
xhr.onabort = () => {
uploadActive -= 1;
setRowState(item, "cancelled");
maybeHidePanel();
pump();
};
xhr.send(fd);
}
// ---------- drag-drop on tree rows --------------------------------------
function rowFromEvent(event) {
return event.target.closest("[data-row-kind]");
}
function isInternalDrag(event) {
return Array.from(event.dataTransfer.types).includes("application/x-files-overlay");
}
function isExternalDrag(event) {
const types = Array.from(event.dataTransfer.types);
return types.includes("Files");
}
// Expose containers (rows AND the empty-state placeholder + tree-root)
// as drop targets — both for OS files and internal moves.
if (treeRoot) {
treeRoot.addEventListener("dragstart", (event) => {
const row = rowFromEvent(event);
if (!row) return;
const path = row.dataset.targetPath;
if (!path) return; // overlay root row isn't draggable
event.dataTransfer.setData("application/x-files-overlay", path);
event.dataTransfer.setData("text/plain", path);
event.dataTransfer.effectAllowed = "move";
row.classList.add("is-drag-source");
});
treeRoot.addEventListener("dragend", () => {
treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source"));
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
});
treeRoot.addEventListener("dragover", (event) => {
// Only react to file or row drags.
if (!isExternalDrag(event) && !isInternalDrag(event)) return;
const row = rowFromEvent(event);
// Find the closest folder row, or the tree-root container itself for
// root-level drops.
const target = row && row.dataset.rowKind === "dir" ? row : null;
const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null;
const dropEl = target || fallbackRoot;
if (!dropEl) return;
event.preventDefault();
event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy";
// Highlight the dropEl exclusively.
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
dropEl.classList.add("is-drop-target");
});
treeRoot.addEventListener("dragleave", (event) => {
// Leaving the whole tree clears highlights.
if (!treeRoot.contains(event.relatedTarget)) {
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
}
});
treeRoot.addEventListener("drop", async (event) => {
const internal = isInternalDrag(event);
const external = isExternalDrag(event);
if (!internal && !external) return;
event.preventDefault();
const row = rowFromEvent(event);
const dropFolder =
row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : "";
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
if (internal) {
const src = event.dataTransfer.getData("application/x-files-overlay");
if (!src) return;
const dst = joinPath(dropFolder, basename(src));
if (src === dst) return;
// Cycle guard: refuse moving a folder into itself or descendant.
if (dropFolder === src || dropFolder.startsWith(src + "/")) return;
const r = await postJson(`${baseUrl}/files/move`, { src, dst });
if (r.ok) {
scheduleRefresh(parentOf(src));
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
} else if (r.status === 409) {
const action = await askConflict(dst);
if (action === "overwrite") {
const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
if (r2.ok) {
scheduleRefresh(parentOf(src));
scheduleRefresh(dropFolder);
}
}
} else {
alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`);
}
return;
}
// External drop — collect entries via webkitGetAsEntry where it
// returns an Entry (real OS drag with folder support), and fall back
// to getAsFile() for any item whose entry is null (synthetic events,
// browsers without the API, or items that have no folder structure).
const items = event.dataTransfer.items;
const files = [];
const tasks = [];
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (typeof item.webkitGetAsEntry === "function") {
const entry = item.webkitGetAsEntry();
if (entry) {
tasks.push(walkEntry(entry, "", files));
continue;
}
}
// Fallback: treat as a flat file.
if (typeof item.getAsFile === "function") {
const f = item.getAsFile();
if (f) files.push({ file: f, rel: f.name });
}
}
} else if (event.dataTransfer.files) {
for (let i = 0; i < event.dataTransfer.files.length; i++) {
files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name });
}
}
await Promise.all(tasks);
for (const f of files) {
enqueueUpload(f.file, dropFolder, f.rel);
}
});
}
function walkEntry(entry, prefix, out) {
return new Promise((resolve) => {
if (entry.isFile) {
entry.file((f) => {
out.push({ file: f, rel: prefix + entry.name });
resolve();
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const children = [];
const readBatch = () => {
reader.readEntries((batch) => {
if (!batch.length) {
Promise.all(
children.map((c) => walkEntry(c, prefix + entry.name + "/", out))
).then(() => resolve());
return;
}
for (const c of batch) children.push(c);
readBatch();
});
};
readBatch();
} else {
resolve();
}
});
}
// ---------- click delegation: action buttons ----------------------------
document.addEventListener("click", (event) => {
const action = event.target?.closest?.(".files-row-action[data-action]");
if (!action) return;
if (!manager.contains(action)) return;
const op = action.dataset.action;
const path = action.dataset.targetPath || "";
if (op === "new-file") {
openEditorTextNew(path);
} else if (op === "new-folder") {
openNewFolder(path);
} else if (op === "zip") {
const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
window.location.href = url;
} else if (op === "edit") {
const editable = action.dataset.editable === "1";
openEditorForFile(path, editable);
} else if (op === "delete") {
const kind = action.dataset.rowKind;
const name = action.dataset.rowName || basename(path);
openDelete(path, kind, name);
}
});
})();

View file

@ -1,15 +1,25 @@
{% if entry.kind == 'dir' %}
<li class="file-tree-row file-tree-row-dir">
<li class="file-tree-row file-tree-row-dir{% if files_overlay %} files-row{% endif %}"
{% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="dir"{% endif %}>
<button type="button"
class="file-tree-toggle"
aria-expanded="false"
data-files-url="{{ files_base_url }}/files?path={{ entry.rel|urlencode }}">
<span class="chevron" aria-hidden="true"></span>{{ entry.name }}/
</button>
{% if files_overlay %}
<span class="files-row-actions" aria-label="Folder actions">
<button type="button" class="files-row-action" data-action="new-file" data-target-path="{{ entry.rel }}" title="New file">+ file</button>
<button type="button" class="files-row-action" data-action="new-folder" data-target-path="{{ entry.rel }}" title="New folder">+ folder</button>
<button type="button" class="files-row-action" data-action="zip" data-target-path="{{ entry.rel }}" title="Download as zip">⬇ zip</button>
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="dir" data-row-name="{{ entry.name }}" title="Delete folder"></button>
</span>
{% endif %}
<div class="file-tree-children" hidden></div>
</li>
{% else %}
<li class="file-tree-row file-tree-row-file">
<li class="file-tree-row file-tree-row-file{% if files_overlay %} files-row{% endif %}"
{% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}>
{% if entry.broken %}
<span>{{ entry.name }}</span>
<span class="file-tree-badge file-tree-badge-warn">broken link</span>
@ -22,5 +32,12 @@
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
<span class="muted">{{ entry.size_human }}</span>
{% endif %}
{% if files_overlay and not entry.broken %}
<span class="files-row-actions" aria-label="File actions">
<button type="button" class="files-row-action" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Edit">edit</button>
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a>
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button>
</span>
{% endif %}
</li>
{% endif %}

View file

@ -1,4 +1,4 @@
<ul class="file-tree" role="group">
<ul class="file-tree" role="group" {% if files_overlay %}data-files-overlay="1"{% endif %}>
{% for entry in entries %}{% include "_overlay_file_node.html" %}{% endfor %}
{% if truncated %}
<li class="file-tree-row file-tree-row-truncated muted">

View file

@ -6,14 +6,70 @@
<section class="panel">
<h1>Users</h1>
<table class="table">
<thead><tr><th>Username</th><th>Admin</th><th>Created</th><th>Updated</th></tr></thead>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Active</th>
<th>Created</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr><td>{{ user.username }}</td><td>{{ "yes" if user.admin else "no" }}</td><td>{{ user.created_at }}</td><td>{{ user.updated_at }}</td></tr>
<tr>
<td>{{ user.username }}</td>
<td>{{ "yes" if user.admin else "no" }}</td>
<td>{{ "yes" if user.active else "no" }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
<td>
{% if user.id == g.user.id %}
<span class="muted">you</span>
{% else %}
{% if user.active %}
<form method="post" action="/admin/users/{{ user.id }}/deactivate" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" class="button-secondary">Deactivate</button>
</form>
{% else %}
<form method="post" action="/admin/users/{{ user.id }}/activate" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" class="button-secondary">Activate</button>
</form>
{% endif %}
<button type="button" class="danger-outline" data-modal-open="delete-user-{{ user.id }}-modal">Delete</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No users found.</td></tr>
<tr><td colspan="6" class="muted">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% for user in users %}
{% if user.id != g.user.id %}
<dialog id="delete-user-{{ user.id }}-modal" class="modal" aria-labelledby="delete-user-{{ user.id }}-title">
<div class="modal-header">
<h2 id="delete-user-{{ user.id }}-title">Delete user "{{ user.username }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This cannot be undone. Refused if the user owns servers, blueprints,
or custom overlays — delete those first.</p>
<p>For a reversible block, prefer Deactivate.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/admin/users/{{ user.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</div>
</dialog>
{% endif %}
{% endfor %}
{% endblock %}

View file

@ -3,7 +3,9 @@
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
{% block content %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script', 'files'] and overlay.user_id == g.user.id) %}
{% set is_files_overlay = overlay.type == 'files' %}
{% set files_can_edit = is_files_overlay and can_edit %}
<section class="panel">
<div class="page-heading">
<h1>Overlay: {{ overlay.name }}</h1>
@ -59,6 +61,38 @@
{% endif %}
<h2 class="section-title">Files</h2>
{% if files_can_edit %}
<div class="files-manager"
data-overlay-id="{{ overlay.id }}"
data-base-url="/overlays/{{ overlay.id }}">
<p class="muted files-manager-hint">Drop files or folders onto a folder row to upload. Drag rows inside the tree to move them.</p>
<ul class="file-tree files-tree-root" data-files-overlay="1">
<li class="file-tree-row file-tree-row-dir files-row files-row-root"
data-target-path=""
data-row-kind="dir">
<span class="files-row-root-label">/</span>
<span class="files-row-actions" aria-label="Overlay root actions">
<button type="button" class="files-row-action" data-action="new-file" data-target-path="">+ new file</button>
<button type="button" class="files-row-action" data-action="new-folder" data-target-path="">+ new folder</button>
<button type="button" class="files-row-action" data-action="zip" data-target-path="">⬇ zip</button>
</span>
<div class="file-tree-children files-root-children">
{% if not file_tree_root_entries %}
<p class="muted files-empty">Empty — drop files here, or click "+ new file" on this row.</p>
{% else %}
{% set entries = file_tree_root_entries %}
{% set truncated = file_tree_truncated %}
{% set truncated_count = file_tree_truncated_count %}
{% set files_base_url = "/overlays/" ~ overlay.id %}
{% set download_supported = True %}
{% set files_overlay = True %}
{% include "_overlay_file_tree.html" %}
{% endif %}
</div>
</li>
</ul>
</div>
{% else %}
{% if not file_tree_root_entries %}
<p class="muted">No files yet — build this overlay to populate it.</p>
{% else %}
@ -69,6 +103,7 @@
{% set download_supported = True %}
{% include "_overlay_file_tree.html" %}
{% endif %}
{% endif %}
<h2 class="section-title">Used by</h2>
{% if using_blueprints %}
@ -119,4 +154,119 @@
</div>
</dialog>
{% endif %}
{% if files_can_edit %}
<dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
<div class="modal-header">
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text"></span></h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<label class="files-editor-field">
<span class="files-field-label">Filename</span>
<input type="text" class="files-editor-filename" autocomplete="off" spellcheck="false">
</label>
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p>
<div class="files-editor-text">
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
</label>
<div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
<span>Ctrl+S to save</span>
</div>
</div>
<div class="files-editor-binary" hidden>
<div class="files-editor-binary-note">
<strong>⛌ Inline editing not available</strong>
· <span class="files-editor-binary-size"></span> · binary content
</div>
<label class="files-field-label files-editor-binary-replace-label">Replace file</label>
<div class="files-editor-replace-zone">
<p class="files-editor-replace-idle">↑ Drop a file here to replace ·
<button type="button" class="link-button files-editor-replace-browse">browse</button> ·
single file only · keeps the filename
</p>
<p class="files-editor-replace-queued" hidden>
<strong class="files-editor-replace-name"></strong> ·
<span class="files-editor-replace-size"></span> ·
<span class="muted">queued</span>
<button type="button" class="link-button files-editor-replace-clear" aria-label="Clear queued replacement"></button>
</p>
<input type="file" class="files-editor-replace-input" hidden>
</div>
</div>
</div>
<div class="modal-footer files-editor-footer">
<button type="button" class="danger-outline files-editor-delete">Delete</button>
<span class="files-editor-footer-spacer"></span>
<a class="button-secondary files-editor-download" href="#" hidden>⬇ Download</a>
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<button type="button" class="files-editor-save">Save</button>
</div>
</dialog>
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
<div class="modal-header">
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target"></code></h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<label class="files-editor-field">
<span class="files-field-label">Folder name</span>
<input type="text" class="files-new-folder-name" autocomplete="off" spellcheck="false" placeholder="e.g. sourcemod or sourcemod/configs">
</label>
<p class="muted">Slashes create nested folders in one go.</p>
<p class="files-new-folder-error" hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<button type="button" class="files-new-folder-create">Create</button>
</div>
</dialog>
<dialog id="files-conflict-modal" class="modal" aria-labelledby="files-conflict-title">
<div class="modal-header">
<h2 id="files-conflict-title">File already exists</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>A file already exists at <code class="files-conflict-path"></code>.</p>
<p class="muted">Choose how to handle this upload.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close data-files-conflict-action="cancel">Cancel</button>
<button type="button" class="button-secondary" data-files-conflict-action="keep-both">Keep both</button>
<button type="button" data-files-conflict-action="overwrite">Overwrite</button>
</div>
</dialog>
<dialog id="files-delete-modal" class="modal" aria-labelledby="files-delete-title">
<div class="modal-header">
<h2 id="files-delete-title">Delete <span class="files-delete-name"></span>?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This cannot be undone.</p>
<p class="files-delete-error muted" hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<button type="button" class="danger files-delete-confirm">Delete</button>
</div>
</dialog>
<aside class="files-uploads" data-overlay-id="{{ overlay.id }}" hidden aria-live="polite">
<header class="files-uploads-header">
<strong class="files-uploads-title">Uploads</strong>
<button type="button" class="link-button files-uploads-clear" hidden>clear done</button>
</header>
<ul class="files-uploads-list"></ul>
</aside>
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
{% endif %}
{% endblock %}

View file

@ -38,6 +38,7 @@
<legend>Type</legend>
<label><input type="radio" name="type" value="workshop" checked> Workshop (downloads from Steam)</label>
<label><input type="radio" name="type" value="script"> Script (runs sandboxed bash)</label>
<label><input type="radio" name="type" value="files"> Files (upload / edit text files online)</label>
</fieldset>
<label>Name <input name="name" required></label>
{% if g.user and g.user.admin %}

View file

@ -0,0 +1,260 @@
import pytest
from sqlalchemy import select
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, Job, Overlay, Server, User
@pytest.fixture
def admin_client(tmp_path, monkeypatch):
"""Returns a logged-in admin client + the admin's user id.
Also creates a second admin so delete-of-the-last-admin is not the
default scenario; tests that need that condition can prune.
"""
db_url = f"sqlite:///{tmp_path/'admin_users.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as db:
admin = User(username="admin", password_digest=hash_password("secret"), admin=True)
second_admin = User(username="admin2", password_digest=hash_password("secret"), admin=True)
db.add_all([admin, second_admin])
db.flush()
admin_id = admin.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = admin_id
sess["csrf_token"] = "test-token"
return client, admin_id
def _add_user(username: str, *, admin: bool = False, active: bool = True) -> int:
with session_scope() as db:
u = User(
username=username,
password_digest=hash_password("secret"),
admin=admin,
active=active,
)
db.add(u)
db.flush()
return u.id
def _user_exists(user_id: int) -> bool:
with session_scope() as db:
return db.scalar(select(User).where(User.id == user_id)) is not None
def _user_active(user_id: int) -> bool:
with session_scope() as db:
u = db.scalar(select(User).where(User.id == user_id))
assert u is not None
return u.active
def _post(client, path: str):
return client.post(path, headers={"X-CSRF-Token": "test-token"})
# ---------------- deactivate / activate ----------------
def test_deactivate_flips_active_false(admin_client):
client, _ = admin_client
target = _add_user("bob")
response = _post(client, f"/admin/users/{target}/deactivate")
assert response.status_code == 302
assert response.headers["Location"].endswith("/admin/users")
assert _user_active(target) is False
def test_activate_flips_active_true(admin_client):
client, _ = admin_client
target = _add_user("bob", active=False)
response = _post(client, f"/admin/users/{target}/activate")
assert response.status_code == 302
assert _user_active(target) is True
def test_deactivate_self_refused(admin_client):
client, admin_id = admin_client
response = _post(client, f"/admin/users/{admin_id}/deactivate")
assert response.status_code == 409
assert _user_active(admin_id) is True
def test_deactivate_unknown_user_404(admin_client):
client, _ = admin_client
response = _post(client, "/admin/users/99999/deactivate")
assert response.status_code == 404
def test_deactivated_user_cannot_log_in(admin_client):
client, _ = admin_client
target = _add_user("bob")
_post(client, f"/admin/users/{target}/deactivate")
# Fresh client — different session, no admin login.
fresh = client.application.test_client()
response = fresh.post(
"/login",
data={"username": "bob", "password": "secret"},
headers={"X-CSRF-Token": "test-token"},
)
# Same response as wrong-password / unknown-user (no leak about active).
assert response.status_code == 401
def test_deactivated_user_existing_session_invalidated(admin_client):
"""An active session at the moment of deactivation stops working."""
client, _ = admin_client
target = _add_user("bob")
# Forge a session for bob.
bob_client = client.application.test_client()
with bob_client.session_transaction() as sess:
sess["user_id"] = target
sess["csrf_token"] = "test-token"
# Sanity: bob can hit a logged-in route.
pre = bob_client.get("/dashboard")
assert pre.status_code == 200
# Admin deactivates bob.
_post(client, f"/admin/users/{target}/deactivate")
# bob's session should now be treated as logged-out → /dashboard redirects to /login.
post = bob_client.get("/dashboard", follow_redirects=False)
assert post.status_code == 302
assert "/login" in post.headers["Location"]
# ---------------- delete: refusal cases ----------------
def test_delete_self_refused(admin_client):
client, admin_id = admin_client
response = _post(client, f"/admin/users/{admin_id}/delete")
assert response.status_code == 409
assert _user_exists(admin_id)
def test_delete_other_admin_succeeds_when_more_than_one_admin(admin_client):
"""The fixture creates 2 admins; admin can delete admin2."""
client, _ = admin_client
with session_scope() as db:
admin2 = db.scalar(select(User).where(User.username == "admin2"))
admin2_id = admin2.id
response = _post(client, f"/admin/users/{admin2_id}/delete")
assert response.status_code == 302
assert not _user_exists(admin2_id)
# Note: the "last admin refused" branch in admin_users_delete is defense-
# in-depth. Through normal flow it's unreachable: a non-admin can't reach
# the endpoint (@require_admin), and an admin trying to delete the only
# remaining admin must be deleting themselves — which the self-delete
# check rejects first. The branch is kept anyway in case the auth model
# ever evolves (e.g. service accounts that bypass require_admin).
def test_delete_blocked_when_owns_servers(admin_client):
client, _ = admin_client
target = _add_user("bob")
with session_scope() as db:
bp = Blueprint(user_id=target, name="bp", arguments="[]", config="[]")
db.add(bp)
db.flush()
db.add(Server(user_id=target, blueprint_id=bp.id, name="alpha", port=27015))
response = _post(client, f"/admin/users/{target}/delete")
assert response.status_code == 409
assert b"server" in response.data
assert _user_exists(target)
def test_delete_blocked_when_owns_blueprints(admin_client):
client, _ = admin_client
target = _add_user("bob")
with session_scope() as db:
db.add(Blueprint(user_id=target, name="bp", arguments="[]", config="[]"))
response = _post(client, f"/admin/users/{target}/delete")
assert response.status_code == 409
assert b"blueprint" in response.data
assert _user_exists(target)
def test_delete_blocked_when_owns_custom_overlays(admin_client):
client, _ = admin_client
target = _add_user("bob")
with session_scope() as db:
db.add(Overlay(name="custom", path="/opt/custom", user_id=target))
response = _post(client, f"/admin/users/{target}/delete")
assert response.status_code == 409
assert b"overlay" in response.data
assert _user_exists(target)
def test_delete_unknown_user_404(admin_client):
client, _ = admin_client
response = _post(client, "/admin/users/99999/delete")
assert response.status_code == 404
# ---------------- delete: success cases ----------------
def test_delete_succeeds_for_orphan(admin_client):
client, _ = admin_client
target = _add_user("bob")
response = _post(client, f"/admin/users/{target}/delete")
assert response.status_code == 302
assert response.headers["Location"].endswith("/admin/users")
assert not _user_exists(target)
def test_delete_succeeds_when_user_only_owns_jobs(admin_client):
"""Job rows have nullable user_id and are kept as audit trail."""
client, _ = admin_client
target = _add_user("bob")
with session_scope() as db:
db.add(Job(user_id=target, server_id=None, operation="install", state="done"))
db.flush()
response = _post(client, f"/admin/users/{target}/delete")
assert response.status_code == 302
assert not _user_exists(target)
# The Job row survives, but its user_id is now NULL.
with session_scope() as db:
jobs = db.scalars(select(Job)).all()
assert len(jobs) == 1
assert jobs[0].user_id is None

View file

@ -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"]

View file

@ -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

View file

@ -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}"