Compare commits
20 commits
36d3d83de6
...
c594d4b5e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c594d4b5e8 | ||
|
|
bcea450e98 | ||
|
|
3490be5fb7 | ||
|
|
726acfa4ff | ||
|
|
0811d22c44 | ||
|
|
a987304358 | ||
|
|
9f0b51b455 | ||
|
|
26f3d270b0 | ||
|
|
a9ca90537b | ||
|
|
878639147a | ||
|
|
d783449d05 | ||
|
|
fbb342db87 | ||
|
|
076bfb72ca | ||
|
|
e822e9fbc7 | ||
|
|
e1add4fffa | ||
|
|
0cc92f2c17 | ||
|
|
62d6d4cbcd | ||
|
|
2bba1f31d0 | ||
|
|
76cd7ddda0 | ||
|
|
2d3c98866a |
33 changed files with 5342 additions and 24 deletions
128
deploy/README.md
128
deploy/README.md
|
|
@ -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.
|
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.
|
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
|
### CPU governor
|
||||||
|
|
||||||
The performance governor squeezes a few percent off jitter under bursty load. `schedutil` is acceptable for sustained UDP workloads.
|
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.
|
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
|
### Applying changes to running servers
|
||||||
|
|
||||||
Unit-file changes do not apply to already-running services. After any change:
|
Unit-file changes do not apply to already-running services. After any change:
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,9 @@ fi
|
||||||
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
$sudo_cmd apt-get update
|
$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
|
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
|
else
|
||||||
printf 'Unsupported package manager: expected apt-get or dnf\n' >&2
|
printf 'Unsupported package manager: expected apt-get or dnf\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -98,6 +98,7 @@ $sudo_cmd mkdir -p \
|
||||||
/opt/left4me \
|
/opt/left4me \
|
||||||
/usr/local/lib/systemd/system \
|
/usr/local/lib/systemd/system \
|
||||||
/usr/local/libexec/left4me \
|
/usr/local/libexec/left4me \
|
||||||
|
/usr/local/lib/left4me/nft \
|
||||||
/var/lib/left4me/installation \
|
/var/lib/left4me/installation \
|
||||||
/var/lib/left4me/overlays \
|
/var/lib/left4me/overlays \
|
||||||
/var/lib/left4me/instances \
|
/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/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-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/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
|
# 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.
|
# 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-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-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 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 cp /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me
|
||||||
$sudo_cmd chmod 0440 /etc/sudoers.d/left4me
|
$sudo_cmd chmod 0440 /etc/sudoers.d/left4me
|
||||||
$sudo_cmd visudo -cf /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 \
|
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
|
||||||
/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
|
# Host perf-baseline sysctls. Apply with `sysctl --system` so values
|
||||||
# take effect this deploy, not on next reboot.
|
# take effect this deploy, not on next reboot.
|
||||||
$sudo_cmd install -m 0644 -o root -g root \
|
$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
|
/etc/sysctl.d/99-left4me.conf
|
||||||
$sudo_cmd sysctl --system >/dev/null
|
$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.
|
# 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
|
# SECRET_KEY is derived from /etc/machine-id so it stays stable across
|
||||||
# redeploys (no session invalidation) without persisting state in /etc.
|
# 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
|
seed-script-overlays /opt/left4me/examples/script-overlays
|
||||||
|
|
||||||
$sudo_cmd systemctl daemon-reload
|
$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 enable --now left4me-web.service
|
||||||
$sudo_cmd systemctl restart left4me-web.service
|
$sudo_cmd systemctl restart left4me-web.service
|
||||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
|
|
||||||
12
deploy/files/etc/left4me/cake.env
Normal file
12
deploy/files/etc/left4me/cake.env
Normal 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=
|
||||||
|
|
@ -19,3 +19,18 @@ net.core.netdev_budget = 600
|
||||||
# Latency-sensitive default: avoid swap unless the box is really under
|
# Latency-sensitive default: avoid swap unless the box is really under
|
||||||
# pressure. Harmless on swapless hosts.
|
# pressure. Harmless on swapless hosts.
|
||||||
vm.swappiness = 10
|
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
|
||||||
|
|
|
||||||
12
deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft
Normal file
12
deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
47
deploy/files/usr/local/libexec/left4me/left4me-apply-cake
Executable file
47
deploy/files/usr/local/libexec/left4me/left4me-apply-cake
Executable 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
|
||||||
|
|
@ -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"
|
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_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"
|
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"
|
SANDBOX_UNIT_DIR = DEPLOY / "files/usr/local/lib/systemd/system"
|
||||||
SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
|
SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
|
||||||
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
|
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
|
||||||
OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay"
|
OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay"
|
||||||
SCRIPT_SANDBOX_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-script-sandbox"
|
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"
|
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"
|
SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
|
||||||
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
|
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
|
||||||
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
|
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
|
||||||
DEPLOY_SCRIPT = DEPLOY / "deploy-test-server.sh"
|
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():
|
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_max_backlog = 5000",
|
||||||
"net.core.netdev_budget = 600",
|
"net.core.netdev_budget = 600",
|
||||||
"vm.swappiness = 10",
|
"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"
|
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.
|
# verify the dry-run guard short-circuits before systemd-run / bwrap.
|
||||||
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
|
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
|
||||||
assert 'exit 0' 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
|
||||||
|
|
|
||||||
895
docs/superpowers/plans/2026-05-10-l4d2-network-shaping.md
Normal file
895
docs/superpowers/plans/2026-05-10-l4d2-network-shaping.md
Normal 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
|
||||||
|
```
|
||||||
220
docs/superpowers/specs/2026-05-09-files-overlay-design.md
Normal file
220
docs/superpowers/specs/2026-05-09-files-overlay-design.md
Normal 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.
|
||||||
487
docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md
Normal file
487
docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md
Normal 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.
|
||||||
33
l4d2web/alembic/versions/0008_user_active.py
Normal file
33
l4d2web/alembic/versions/0008_user_active.py
Normal 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")
|
||||||
|
|
@ -27,7 +27,10 @@ def load_current_user() -> None:
|
||||||
g.user = None
|
g.user = None
|
||||||
return
|
return
|
||||||
with session_scope() as db:
|
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:
|
def current_user() -> User | None:
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ class User(Base):
|
||||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
password_digest: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_digest: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
admin: Mapped[bool] = mapped_column(Boolean, default=False, 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ def login() -> Response:
|
||||||
user = db.scalar(select(User).where(User.username == username))
|
user = db.scalar(select(User).where(User.username == username))
|
||||||
digest = user.password_digest if user is not None else _TIMING_DUMMY_DIGEST
|
digest = user.password_digest if user is not None else _TIMING_DUMMY_DIGEST
|
||||||
password_ok = verify_password(password, 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)
|
return Response("invalid credentials", status=401)
|
||||||
login_user(user.id)
|
login_user(user.id)
|
||||||
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
|
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"""Routes for the overlay 'Files' section.
|
"""Routes for the overlay 'Files' section.
|
||||||
|
|
||||||
Two GETs, both gated to the overlay's owner or any admin (mirrors the
|
Read-only endpoints (any overlay):
|
||||||
overlay detail page rule):
|
|
||||||
|
|
||||||
- `GET /overlays/<id>/files?path=<rel>` — HTML fragment listing one
|
- `GET /overlays/<id>/files?path=<rel>` — HTML fragment listing one
|
||||||
directory level. Used both for the initial server-rendered root and
|
directory level. Used both for the initial server-rendered root and
|
||||||
for HTMX swaps when a folder expands.
|
for HTMX swaps when a folder expands.
|
||||||
|
|
@ -10,26 +8,68 @@ overlay detail page rule):
|
||||||
Symlinks resolving anywhere under `LEFT4ME_ROOT` are allowed (so
|
Symlinks resolving anywhere under `LEFT4ME_ROOT` are allowed (so
|
||||||
workshop addons stream from the shared cache); anything escaping it
|
workshop addons stream from the shared cache); anything escaping it
|
||||||
is refused.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_login
|
from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Overlay, Server
|
from l4d2web.models import Overlay, Server
|
||||||
from l4d2web.services.overlay_files import (
|
from l4d2web.services.overlay_files import (
|
||||||
|
is_editable,
|
||||||
list_directory,
|
list_directory,
|
||||||
|
safe_resolve_for_delete,
|
||||||
safe_resolve_for_download,
|
safe_resolve_for_download,
|
||||||
safe_resolve_for_listing,
|
safe_resolve_for_listing,
|
||||||
|
safe_resolve_for_move,
|
||||||
safe_resolve_for_server_download,
|
safe_resolve_for_server_download,
|
||||||
safe_resolve_for_server_listing,
|
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__)
|
bp = Blueprint("files", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,6 +86,18 @@ def _load_overlay_for_user(overlay_id: int, user) -> Overlay | Response:
|
||||||
return overlay
|
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")
|
@bp.get("/overlays/<int:overlay_id>/files")
|
||||||
@require_login
|
@require_login
|
||||||
def overlay_files_fragment(overlay_id: int):
|
def overlay_files_fragment(overlay_id: int):
|
||||||
|
|
@ -76,6 +128,7 @@ def overlay_files_fragment(overlay_id: int):
|
||||||
truncated_count=truncated_count,
|
truncated_count=truncated_count,
|
||||||
files_base_url=f"/overlays/{overlay_id}",
|
files_base_url=f"/overlays/{overlay_id}",
|
||||||
download_supported=True,
|
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")
|
@bp.get("/servers/<int:server_id>/files/download")
|
||||||
@require_login
|
@require_login
|
||||||
def server_files_download(server_id: int):
|
def server_files_download(server_id: int):
|
||||||
|
|
|
||||||
|
|
@ -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"
|
WIPE_SCRIPT = "find /overlay -mindepth 1 -delete"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ def _can_edit_overlay(overlay: Overlay, user) -> bool:
|
||||||
return False
|
return False
|
||||||
if user.admin:
|
if user.admin:
|
||||||
return True
|
return True
|
||||||
if overlay.type in {"workshop", "script"}:
|
if overlay.type in {"workshop", "script", "files"}:
|
||||||
return overlay.user_id == user.id
|
return overlay.user_id == user.id
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -68,7 +68,14 @@ def create_overlay() -> Response:
|
||||||
if _name_already_taken(db, name, scope_user_id):
|
if _name_already_taken(db, name, scope_user_id):
|
||||||
return Response("overlay already exists", status=409)
|
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.add(overlay)
|
||||||
db.flush()
|
db.flush()
|
||||||
overlay.path = generate_overlay_path(overlay.id)
|
overlay.path = generate_overlay_path(overlay.id)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from flask import Blueprint, Response, redirect, render_template, request
|
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.auth import current_user, require_admin, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
|
|
@ -55,6 +55,76 @@ def admin_users() -> str:
|
||||||
return render_template("admin_users.html", users=users)
|
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")
|
@bp.get("/admin/jobs")
|
||||||
@require_admin
|
@require_admin
|
||||||
def admin_jobs() -> str:
|
def admin_jobs() -> str:
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,27 @@ def _is_under(path: Path, root: Path) -> bool:
|
||||||
return True
|
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] = {
|
BUILDERS: dict[str, OverlayBuilder] = {
|
||||||
"workshop": WorkshopBuilder(),
|
"workshop": WorkshopBuilder(),
|
||||||
"script": ScriptBuilder(),
|
"script": ScriptBuilder(),
|
||||||
|
"files": FilesBuilder(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,113 @@ def safe_resolve_for_listing(overlay_path_value: str, sub_path: str) -> Path:
|
||||||
return candidate
|
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:
|
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
|
"""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
|
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)
|
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 {
|
return {
|
||||||
"name": entry.name,
|
"name": entry.name,
|
||||||
"rel": rel_str,
|
"rel": rel_str,
|
||||||
|
|
@ -116,6 +228,7 @@ def _entry_dict(entry: "os.DirEntry[str]", overlay_root: Path) -> dict:
|
||||||
"broken": broken,
|
"broken": broken,
|
||||||
"size": size,
|
"size": size,
|
||||||
"size_human": _format_size(size) if size is not None else "",
|
"size_human": _format_size(size) if size is not None else "",
|
||||||
|
"editable": editable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -510,3 +510,347 @@ button.danger-outline:hover {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-top-right-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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,18 @@
|
||||||
// carries `data-files-url`. First expand fires a fetch and innerHTMLs the
|
// carries `data-files-url`. First expand fires a fetch and innerHTMLs the
|
||||||
// returned partial into the next `.file-tree-children`; subsequent clicks
|
// returned partial into the next `.file-tree-children`; subsequent clicks
|
||||||
// just toggle visibility — no re-fetch.
|
// 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 () {
|
(function () {
|
||||||
document.addEventListener("click", function (event) {
|
document.addEventListener("click", function (event) {
|
||||||
const button = event.target.closest(".file-tree-toggle");
|
const button = event.target.closest(".file-tree-toggle");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
const children = button.nextElementSibling;
|
const row = button.closest(".file-tree-row");
|
||||||
if (!children || !children.classList.contains("file-tree-children")) return;
|
const children = row ? row.querySelector(":scope > .file-tree-children") : null;
|
||||||
|
if (!children) return;
|
||||||
|
|
||||||
const wasExpanded = button.getAttribute("aria-expanded") === "true";
|
const wasExpanded = button.getAttribute("aria-expanded") === "true";
|
||||||
button.setAttribute("aria-expanded", wasExpanded ? "false" : "true");
|
button.setAttribute("aria-expanded", wasExpanded ? "false" : "true");
|
||||||
|
|
|
||||||
984
l4d2web/static/js/files-overlay.js
Normal file
984
l4d2web/static/js/files-overlay.js
Normal 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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
})[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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
{% if entry.kind == 'dir' %}
|
{% 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"
|
<button type="button"
|
||||||
class="file-tree-toggle"
|
class="file-tree-toggle"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
data-files-url="{{ files_base_url }}/files?path={{ entry.rel|urlencode }}">
|
data-files-url="{{ files_base_url }}/files?path={{ entry.rel|urlencode }}">
|
||||||
<span class="chevron" aria-hidden="true">›</span>{{ entry.name }}/
|
<span class="chevron" aria-hidden="true">›</span>{{ entry.name }}/
|
||||||
</button>
|
</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>
|
<div class="file-tree-children" hidden></div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% 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 %}
|
{% if entry.broken %}
|
||||||
<span>{{ entry.name }}</span>
|
<span>{{ entry.name }}</span>
|
||||||
<span class="file-tree-badge file-tree-badge-warn">broken link</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 %}
|
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
|
||||||
<span class="muted">{{ entry.size_human }}</span>
|
<span class="muted">{{ entry.size_human }}</span>
|
||||||
{% endif %}
|
{% 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>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
{% for entry in entries %}{% include "_overlay_file_node.html" %}{% endfor %}
|
||||||
{% if truncated %}
|
{% if truncated %}
|
||||||
<li class="file-tree-row file-tree-row-truncated muted">
|
<li class="file-tree-row file-tree-row-truncated muted">
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,70 @@
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<table class="table">
|
<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>
|
<tbody>
|
||||||
{% for user in users %}
|
{% 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 %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="muted">No users found.</td></tr>
|
{% 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="6" class="muted">No users found.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
|
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<section class="panel">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h1>Overlay: {{ overlay.name }}</h1>
|
<h1>Overlay: {{ overlay.name }}</h1>
|
||||||
|
|
@ -59,6 +61,38 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2 class="section-title">Files</h2>
|
<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 %}
|
{% if not file_tree_root_entries %}
|
||||||
<p class="muted">No files yet — build this overlay to populate it.</p>
|
<p class="muted">No files yet — build this overlay to populate it.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -69,6 +103,7 @@
|
||||||
{% set download_supported = True %}
|
{% set download_supported = True %}
|
||||||
{% include "_overlay_file_tree.html" %}
|
{% include "_overlay_file_tree.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h2 class="section-title">Used by</h2>
|
<h2 class="section-title">Used by</h2>
|
||||||
{% if using_blueprints %}
|
{% if using_blueprints %}
|
||||||
|
|
@ -119,4 +154,119 @@
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
{% endif %}
|
{% 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">×</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">×</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">×</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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
<legend>Type</legend>
|
<legend>Type</legend>
|
||||||
<label><input type="radio" name="type" value="workshop" checked> Workshop (downloads from Steam)</label>
|
<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="script"> Script (runs sandboxed bash)</label>
|
||||||
|
<label><input type="radio" name="type" value="files"> Files (upload / edit text files online)</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label>Name <input name="name" required></label>
|
<label>Name <input name="name" required></label>
|
||||||
{% if g.user and g.user.admin %}
|
{% if g.user and g.user.admin %}
|
||||||
|
|
|
||||||
260
l4d2web/tests/test_admin_users.py
Normal file
260
l4d2web/tests/test_admin_users.py
Normal 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
|
||||||
|
|
@ -64,7 +64,7 @@ def _capture_logs():
|
||||||
|
|
||||||
|
|
||||||
def test_builders_registry() -> None:
|
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:
|
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
|
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:
|
def test_registry_unknown_type_raises_keyerror() -> None:
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
overlay_builders.BUILDERS["nope"]
|
overlay_builders.BUILDERS["nope"]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# Files only — directories don't have size_human.
|
||||||
assert by_name["tiny.txt"]["size_human"] == "5 B"
|
assert by_name["tiny.txt"]["size_human"] == "5 B"
|
||||||
assert by_name["big.bin"]["size_human"] == "3.0 MB"
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""HTTP-level tests for the overlay 'Files' tree-fragment + download routes."""
|
"""HTTP-level tests for the overlay 'Files' tree-fragment + download routes."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 response.status_code == 200
|
||||||
assert ">Files<" in text
|
assert ">Files<" in text
|
||||||
assert "left4dead2" 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}"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue