Compare commits
No commits in common. "c594d4b5e8cfd8e020b180e821734dddfda4a493" and "36d3d83de6a9e92f11ad70eeb71d04d601ea370c" have entirely different histories.
c594d4b5e8
...
36d3d83de6
33 changed files with 24 additions and 5342 deletions
128
deploy/README.md
128
deploy/README.md
|
|
@ -1,50 +1,4 @@
|
|||
# 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)
|
||||
# left4me Deployment
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -124,61 +78,6 @@ The deployment ships a host-side perf baseline (slices, unit directives, sysctls
|
|||
|
||||
The following knobs are documented escape hatches — they are **not** auto-applied. Apply only if you have measured a need and understand the failure modes.
|
||||
|
||||
### Network shaping
|
||||
|
||||
The deploy ships three things that affect player-experience network behaviour:
|
||||
|
||||
1. **Per-flow marking.** `left4me-nft-mark.service` loads a small nftables
|
||||
table (`inet left4me_mark`) that marks every UDP packet from uid `left4me`
|
||||
with DSCP EF and `skb->priority` 6. srcds doesn't set these itself, so
|
||||
without this rule its UDP is indistinguishable from any other flow.
|
||||
2. **Sysctl baseline.** `99-left4me.conf` sets `udp_rmem_min=16384`,
|
||||
`udp_wmem_min=16384`, `default_qdisc=fq_codel`, and
|
||||
`tcp_congestion_control=bbr`. Reduces head-of-line blocking when bulk
|
||||
TCP egress (backups, package fetches, web responses) coexists with
|
||||
game UDP.
|
||||
3. **CAKE egress shaping.** `left4me-cake.service` runs
|
||||
`tc qdisc replace dev <iface> root cake bandwidth Xmbit internet
|
||||
diffserv4 dual-dsthost` from `/etc/left4me/cake.env`. CAKE only shapes
|
||||
if its declared bandwidth is **below** the real bottleneck, so set
|
||||
`LEFT4ME_UPLINK_MBIT` to ≈95% of measured uplink:
|
||||
|
||||
sudoedit /etc/left4me/cake.env
|
||||
# set LEFT4ME_UPLINK_MBIT=480 (or whatever ~95% of your uplink is)
|
||||
sudo systemctl restart left4me-cake.service
|
||||
|
||||
`LEFT4ME_UPLINK_IFACE` is auto-detected from the IPv4 default route;
|
||||
override only on hosts with multi-homed setups.
|
||||
|
||||
At idle 500 Mbit with no competing egress, CAKE shapes nothing — that's
|
||||
expected, not a bug. The win materialises when bulk traffic on the
|
||||
same uplink would otherwise bufferbloat the link the players share.
|
||||
|
||||
**Production hosts running `systemd-networkd`** should NOT use the
|
||||
`left4me-cake.service` oneshot. Instead, configure the equivalent in the
|
||||
matching `.network` file, which systemd-networkd reapplies across iface
|
||||
lifecycle events:
|
||||
|
||||
# /etc/systemd/network/<your-uplink>.network
|
||||
[CAKE]
|
||||
Bandwidth=480M
|
||||
OverheadKeyword=internet
|
||||
PriorityQueueingPreset=diffserv4
|
||||
EgressHostIsolation=yes
|
||||
|
||||
The nftables marking from (1) is qdisc-installer-agnostic and ships
|
||||
unchanged on production.
|
||||
|
||||
**Disabling network shaping.** To turn the whole feature off on a deployed
|
||||
host:
|
||||
|
||||
sudo systemctl stop left4me-cake.service left4me-nft-mark.service
|
||||
sudo systemctl disable left4me-cake.service left4me-nft-mark.service
|
||||
|
||||
The sysctl baseline (`99-left4me.conf`) and the BBR/fq_codel defaults stay
|
||||
applied; revert those by removing the file and running `sysctl --system`
|
||||
if needed.
|
||||
|
||||
### CPU governor
|
||||
|
||||
The performance governor squeezes a few percent off jitter under bursty load. `schedutil` is acceptable for sustained UDP workloads.
|
||||
|
|
@ -245,31 +144,6 @@ AmbientCapabilities=CAP_SYS_NICE
|
|||
|
||||
The `AmbientCapabilities=CAP_SYS_NICE` line is needed because the service runs as `User=left4me` with `NoNewPrivileges=true`; without it some kernels/systemd combinations refuse to apply the RT policy.
|
||||
|
||||
### Additional opt-in network knobs
|
||||
|
||||
- **Ingress shaping via IFB.** Egress CAKE alone does not protect srcds
|
||||
receive against ingress saturation (large workshop downloads, package
|
||||
fetches arriving at line rate). One-liner:
|
||||
|
||||
sudo modprobe ifb && sudo ip link set ifb0 up
|
||||
sudo tc qdisc add dev <uplink> handle ffff: ingress
|
||||
sudo tc filter add dev <uplink> parent ffff: protocol ip u32 \
|
||||
match u32 0 0 action mirred egress redirect dev ifb0
|
||||
sudo tc qdisc add dev ifb0 root cake bandwidth Xmbit ingress \
|
||||
diffserv4 dual-srchost
|
||||
|
||||
Worth flipping only when measurement shows ingress hurting receive.
|
||||
|
||||
- **`net.core.busy_poll = 50` / `net.core.busy_read = 50`.** Reduces UDP
|
||||
receive median latency by polling for incoming packets briefly at
|
||||
syscall boundaries. Cost: measurable CPU per syscall under load. Worth
|
||||
flipping if a host is dedicated to game serving and CPU headroom is
|
||||
plentiful.
|
||||
|
||||
- **`ethtool -K <iface> gro off`.** Some Source-engine ops disable
|
||||
generic receive offload to avoid receive-side coalescing latency.
|
||||
Hardware/driver dependent; document only.
|
||||
|
||||
### Applying changes to running servers
|
||||
|
||||
Unit-file changes do not apply to already-running services. After any change:
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ fi
|
|||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
$sudo_cmd apt-get update
|
||||
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full nftables iproute2
|
||||
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full
|
||||
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 nftables iproute
|
||||
$sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip p7zip-plugins
|
||||
else
|
||||
printf 'Unsupported package manager: expected apt-get or dnf\n' >&2
|
||||
exit 1
|
||||
|
|
@ -98,7 +98,6 @@ $sudo_cmd mkdir -p \
|
|||
/opt/left4me \
|
||||
/usr/local/lib/systemd/system \
|
||||
/usr/local/libexec/left4me \
|
||||
/usr/local/lib/left4me/nft \
|
||||
/var/lib/left4me/installation \
|
||||
/var/lib/left4me/overlays \
|
||||
/var/lib/left4me/instances \
|
||||
|
|
@ -139,8 +138,6 @@ $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.
|
|||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service
|
||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-game.slice /usr/local/lib/systemd/system/l4d2-game.slice
|
||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-build.slice /usr/local/lib/systemd/system/l4d2-build.slice
|
||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service
|
||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service
|
||||
|
||||
# CPU isolation via cgroup-v2 AllowedCPUs= drop-ins. Pin everything that
|
||||
# isn't a live game server to core 0; give game servers cores 1..N-1.
|
||||
|
|
@ -179,8 +176,7 @@ $sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemc
|
|||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl
|
||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-overlay
|
||||
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox /usr/local/libexec/left4me/left4me-script-sandbox
|
||||
$sudo_cmd 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 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/etc/sudoers.d/left4me /etc/sudoers.d/left4me
|
||||
$sudo_cmd chmod 0440 /etc/sudoers.d/left4me
|
||||
$sudo_cmd visudo -cf /etc/sudoers.d/left4me
|
||||
|
|
@ -194,12 +190,6 @@ $sudo_cmd install -m 0644 -o root -g root \
|
|||
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
|
||||
/etc/left4me/sandbox-resolv.conf
|
||||
|
||||
# Network packet marking + shaping. See spec
|
||||
# docs/superpowers/specs/2026-05-10-l4d2-network-shaping-design.md.
|
||||
$sudo_cmd install -m 0644 -o root -g root \
|
||||
/opt/left4me/deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft \
|
||||
/usr/local/lib/left4me/nft/left4me-mark.nft
|
||||
|
||||
# Host perf-baseline sysctls. Apply with `sysctl --system` so values
|
||||
# take effect this deploy, not on next reboot.
|
||||
$sudo_cmd install -m 0644 -o root -g root \
|
||||
|
|
@ -207,16 +197,6 @@ $sudo_cmd install -m 0644 -o root -g root \
|
|||
/etc/sysctl.d/99-left4me.conf
|
||||
$sudo_cmd sysctl --system >/dev/null
|
||||
|
||||
# CAKE config: ship the template only if the operator hasn't created one
|
||||
# (their LEFT4ME_UPLINK_MBIT value must survive re-deploys).
|
||||
if [ -e /etc/left4me/cake.env ]; then
|
||||
: # operator file present; leave it intact
|
||||
else
|
||||
$sudo_cmd install -m 0644 -o root -g root \
|
||||
/opt/left4me/deploy/files/etc/left4me/cake.env \
|
||||
/etc/left4me/cake.env
|
||||
fi
|
||||
|
||||
# Stomp the file every deploy so newly added vars reach existing boxes.
|
||||
# SECRET_KEY is derived from /etc/machine-id so it stays stable across
|
||||
# redeploys (no session invalidation) without persisting state in /etc.
|
||||
|
|
@ -333,8 +313,6 @@ run_left4me_with_env env \
|
|||
seed-script-overlays /opt/left4me/examples/script-overlays
|
||||
|
||||
$sudo_cmd systemctl daemon-reload
|
||||
$sudo_cmd systemctl enable --now left4me-nft-mark.service
|
||||
$sudo_cmd systemctl enable --now left4me-cake.service
|
||||
$sudo_cmd systemctl enable --now left4me-web.service
|
||||
$sudo_cmd systemctl restart left4me-web.service
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
# 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,18 +19,3 @@ net.core.netdev_budget = 600
|
|||
# Latency-sensitive default: avoid swap unless the box is really under
|
||||
# pressure. Harmless on swapless hosts.
|
||||
vm.swappiness = 10
|
||||
|
||||
# Per-socket UDP buffer floors: protect game-server sockets that don't bump
|
||||
# their own SO_RCVBUF/SO_SNDBUF when softirq drains lag briefly.
|
||||
net.ipv4.udp_rmem_min = 16384
|
||||
net.ipv4.udp_wmem_min = 16384
|
||||
|
||||
# Default qdisc for ifaces we don't explicitly shape with CAKE. Debian Trixie
|
||||
# already defaults to fq_codel; setting it explicitly is belt-and-suspenders
|
||||
# and survives kernel-default churn.
|
||||
net.core.default_qdisc = fq_codel
|
||||
|
||||
# TCP congestion control: BBR for any bulk TCP egress on the host (admin SSH,
|
||||
# backups, package fetches, web-app responses) so a long flow does not push
|
||||
# the bottleneck queue ahead of game UDP. UDP srcds is unaffected.
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
# 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
#!/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,21 +14,16 @@ BUILD_SLICE = DEPLOY / "files/usr/local/lib/systemd/system/l4d2-build.slice"
|
|||
SYSCTL_CONF = DEPLOY / "files/etc/sysctl.d/99-left4me.conf"
|
||||
GLOBAL_REFRESH_SERVICE = DEPLOY / "files/usr/local/lib/systemd/system/left4me-refresh-global-overlays.service"
|
||||
GLOBAL_REFRESH_TIMER = DEPLOY / "files/usr/local/lib/systemd/system/left4me-refresh-global-overlays.timer"
|
||||
NFT_MARK_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-nft-mark.service"
|
||||
CAKE_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-cake.service"
|
||||
SANDBOX_UNIT_DIR = DEPLOY / "files/usr/local/lib/systemd/system"
|
||||
SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
|
||||
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
|
||||
OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay"
|
||||
SCRIPT_SANDBOX_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-script-sandbox"
|
||||
APPLY_CAKE_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-apply-cake"
|
||||
SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf"
|
||||
CAKE_ENV = DEPLOY / "files/etc/left4me/cake.env"
|
||||
SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
|
||||
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
|
||||
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
|
||||
DEPLOY_SCRIPT = DEPLOY / "deploy-test-server.sh"
|
||||
NFT_MARK_FILE = DEPLOY / "files/usr/local/lib/left4me/nft/left4me-mark.nft"
|
||||
|
||||
|
||||
def test_global_unit_files_exist_at_product_level_paths():
|
||||
|
|
@ -212,10 +207,6 @@ def test_sysctl_conf_present_with_perf_settings():
|
|||
"net.core.netdev_max_backlog = 5000",
|
||||
"net.core.netdev_budget = 600",
|
||||
"vm.swappiness = 10",
|
||||
"net.ipv4.udp_rmem_min = 16384",
|
||||
"net.ipv4.udp_wmem_min = 16384",
|
||||
"net.core.default_qdisc = fq_codel",
|
||||
"net.ipv4.tcp_congestion_control = bbr",
|
||||
):
|
||||
assert line in text, f"missing {line!r} in 99-left4me.conf"
|
||||
|
||||
|
|
@ -717,141 +708,3 @@ def test_script_sandbox_helper_dry_run_mode(tmp_path):
|
|||
# verify the dry-run guard short-circuits before systemd-run / bwrap.
|
||||
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
|
||||
assert 'exit 0' in helper_text
|
||||
|
||||
|
||||
def test_nft_mark_file_marks_left4me_udp_with_dscp_ef_and_priority():
|
||||
assert NFT_MARK_FILE.is_file()
|
||||
text = NFT_MARK_FILE.read_text()
|
||||
|
||||
# Own table in the inet family so it cannot conflict with operator nftables config.
|
||||
assert "table inet left4me_mark" in text
|
||||
assert "chain mangle_output" in text
|
||||
assert "type filter hook output priority mangle" in text
|
||||
|
||||
# Match by uid (every srcds runs as `left4me`) restricted to UDP.
|
||||
assert 'meta skuid "left4me"' in text
|
||||
assert "meta l4proto udp" in text
|
||||
|
||||
# DSCP EF for both L3 families; in `inet` tables, `ip` only fires on v4
|
||||
# and `ip6` only on v6.
|
||||
assert "ip dscp set ef" in text
|
||||
assert "ip6 dscp set ef" in text
|
||||
|
||||
# skb->priority class 6:0, set inline alongside DSCP.
|
||||
assert "meta priority set 0006:0000" in text
|
||||
|
||||
|
||||
def test_nft_mark_unit_loads_and_clears_left4me_table():
|
||||
assert NFT_MARK_UNIT.is_file()
|
||||
text = NFT_MARK_UNIT.read_text()
|
||||
|
||||
# Loads the rules early so the very first packet srcds emits is marked.
|
||||
assert "After=network-pre.target" in text
|
||||
assert "Before=network.target" in text
|
||||
assert "Wants=network-pre.target" in text
|
||||
|
||||
# Oneshot lifecycle: load on start, drop on stop.
|
||||
assert "Type=oneshot" in text
|
||||
assert "RemainAfterExit=yes" in text
|
||||
assert (
|
||||
"ExecStart=/usr/sbin/nft -f /usr/local/lib/left4me/nft/left4me-mark.nft"
|
||||
in text
|
||||
)
|
||||
assert "ExecStop=/usr/sbin/nft delete table inet left4me_mark" in text
|
||||
assert "WantedBy=multi-user.target" in text
|
||||
|
||||
|
||||
def test_cake_env_template_documents_required_knobs():
|
||||
assert CAKE_ENV.is_file()
|
||||
text = CAKE_ENV.read_text()
|
||||
|
||||
# Both knobs are documented and present (commented OK; the deploy preserves
|
||||
# operator edits, so the template must not bake in a wrong value).
|
||||
assert "LEFT4ME_UPLINK_MBIT" in text
|
||||
assert "LEFT4ME_UPLINK_IFACE" in text
|
||||
# Empty defaults: shaper unit no-ops with a journal warning when unset.
|
||||
assert "LEFT4ME_UPLINK_MBIT=" in text
|
||||
assert "LEFT4ME_UPLINK_IFACE=" in text
|
||||
|
||||
|
||||
def test_apply_cake_helper_supports_apply_and_clear_modes():
|
||||
assert APPLY_CAKE_HELPER.is_file()
|
||||
text = APPLY_CAKE_HELPER.read_text()
|
||||
|
||||
assert text.startswith("#!/bin/sh")
|
||||
# Both knobs are read from the env file.
|
||||
assert "LEFT4ME_UPLINK_MBIT" in text
|
||||
assert "LEFT4ME_UPLINK_IFACE" in text
|
||||
assert ". /etc/left4me/cake.env" in text
|
||||
# Iface fallback to default route.
|
||||
assert "ip -4 route show default" in text
|
||||
# Two modes; default to apply.
|
||||
assert "mode=${1:-apply}" in text
|
||||
assert 'apply)' in text and 'clear)' in text
|
||||
# Apply: idempotent `tc qdisc replace` with the documented flags.
|
||||
assert "tc qdisc replace" in text
|
||||
assert "cake" in text
|
||||
assert "bandwidth" in text
|
||||
assert "internet" in text
|
||||
assert "diffserv4" in text
|
||||
assert "dual-dsthost" in text
|
||||
# Clear: tolerates a missing qdisc.
|
||||
assert "tc qdisc del" in text
|
||||
assert "|| true" in text
|
||||
# Fail-soft on missing config.
|
||||
assert "LEFT4ME_UPLINK_MBIT unset" in text
|
||||
|
||||
|
||||
def test_apply_cake_helper_passes_shell_syntax_check():
|
||||
subprocess.run(["sh", "-n", str(APPLY_CAKE_HELPER)], check=True)
|
||||
|
||||
|
||||
def test_cake_unit_runs_helper_in_apply_and_clear_modes():
|
||||
assert CAKE_UNIT.is_file()
|
||||
text = CAKE_UNIT.read_text()
|
||||
|
||||
assert "After=network-online.target" in text
|
||||
assert "Wants=network-online.target" in text
|
||||
assert "Type=oneshot" in text
|
||||
assert "RemainAfterExit=yes" in text
|
||||
# `-` prefix: missing env file is non-fatal (deploy ships one, but be safe).
|
||||
assert "EnvironmentFile=-/etc/left4me/cake.env" in text
|
||||
assert (
|
||||
"ExecStart=/usr/local/libexec/left4me/left4me-apply-cake apply" in text
|
||||
)
|
||||
assert (
|
||||
"ExecStop=/usr/local/libexec/left4me/left4me-apply-cake clear" in text
|
||||
)
|
||||
assert "WantedBy=multi-user.target" in text
|
||||
|
||||
|
||||
def test_deploy_script_installs_network_shaping_artifacts():
|
||||
script = DEPLOY_SCRIPT.read_text()
|
||||
|
||||
# nftables: package install on both apt and dnf paths.
|
||||
apt_lines = [l for l in script.splitlines() if "apt-get install" in l]
|
||||
dnf_lines = [l for l in script.splitlines() if "dnf install" in l]
|
||||
assert apt_lines and dnf_lines
|
||||
for line in apt_lines:
|
||||
assert "nftables" in line, line
|
||||
for line in dnf_lines:
|
||||
assert "nftables" in line, line
|
||||
|
||||
# nft rules + unit copied to system paths.
|
||||
assert "/usr/local/lib/left4me/nft/left4me-mark.nft" in script
|
||||
assert (
|
||||
"/usr/local/lib/systemd/system/left4me-nft-mark.service" in script
|
||||
)
|
||||
assert "systemctl enable --now left4me-nft-mark.service" in script
|
||||
|
||||
# CAKE helper + unit copied; helper made executable.
|
||||
assert "/usr/local/libexec/left4me/left4me-apply-cake" in script
|
||||
assert (
|
||||
"/usr/local/lib/systemd/system/left4me-cake.service" in script
|
||||
)
|
||||
assert "chmod 0755" in script and "left4me-apply-cake" in script
|
||||
assert "systemctl enable --now left4me-cake.service" in script
|
||||
|
||||
# cake.env: copied only if absent (operator edits survive re-deploys).
|
||||
assert "/etc/left4me/cake.env" in script
|
||||
assert "[ -e /etc/left4me/cake.env ]" in script
|
||||
|
|
|
|||
|
|
@ -1,895 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"""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,10 +27,7 @@ def load_current_user() -> None:
|
|||
g.user = None
|
||||
return
|
||||
with session_scope() as db:
|
||||
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
|
||||
g.user = db.scalar(select(User).where(User.id == int(user_id)))
|
||||
|
||||
|
||||
def current_user() -> User | None:
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ class User(Base):
|
|||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
password_digest: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, server_default=text("1"),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ def login() -> Response:
|
|||
user = db.scalar(select(User).where(User.username == username))
|
||||
digest = user.password_digest if user is not None else _TIMING_DUMMY_DIGEST
|
||||
password_ok = verify_password(password, digest)
|
||||
if user is None or not password_ok or not user.active:
|
||||
# Same generic response for missing user, wrong password, or
|
||||
# deactivated account — no timing oracle for deactivation status.
|
||||
if user is None or not password_ok:
|
||||
return Response("invalid credentials", status=401)
|
||||
login_user(user.id)
|
||||
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Routes for the overlay 'Files' section.
|
||||
|
||||
Read-only endpoints (any overlay):
|
||||
Two GETs, both gated to the overlay's owner or any admin (mirrors the
|
||||
overlay detail page rule):
|
||||
|
||||
- `GET /overlays/<id>/files?path=<rel>` — HTML fragment listing one
|
||||
directory level. Used both for the initial server-rendered root and
|
||||
for HTMX swaps when a folder expands.
|
||||
|
|
@ -8,68 +10,26 @@ Read-only endpoints (any overlay):
|
|||
Symlinks resolving anywhere under `LEFT4ME_ROOT` are allowed (so
|
||||
workshop addons stream from the shared cache); anything escaping it
|
||||
is refused.
|
||||
|
||||
Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
|
||||
- `GET /overlays/<id>/files/content?path=` — JSON `{path, content}` for
|
||||
an editable text file, 415 if not editable.
|
||||
- `POST /overlays/<id>/files/save` — JSON `{path, content, new_path?}`,
|
||||
text-mode write with optional atomic rename.
|
||||
- `POST /overlays/<id>/files/replace` — multipart `path`, `file`,
|
||||
optional `new_path`. Binary-mode replace with optional atomic rename.
|
||||
- `POST /overlays/<id>/files/upload` — multipart `target_path`, single
|
||||
`file` (carrying `webkitRelativePath`). One file per request; client
|
||||
fans out for multi-file or whole-folder drops.
|
||||
- `POST /overlays/<id>/files/move` — JSON `{src, dst}`. Internal-drag
|
||||
rename or move; refuses cycles via `safe_resolve_for_move`.
|
||||
- `POST /overlays/<id>/files/mkdir` — JSON `{path}`. Slashes allowed in
|
||||
`path`; intermediate directories are created.
|
||||
- `POST /overlays/<id>/files/delete` — form `path`. Refuses non-empty
|
||||
directories.
|
||||
- `GET /overlays/<id>/files/download_zip?path=` — streams a zip of the
|
||||
folder's contents.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
)
|
||||
from flask import Blueprint, Response, render_template, request, send_file
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.auth import current_user, require_login
|
||||
from l4d2web.db import session_scope
|
||||
from l4d2web.models import Overlay, Server
|
||||
from l4d2web.services.overlay_files import (
|
||||
is_editable,
|
||||
list_directory,
|
||||
safe_resolve_for_delete,
|
||||
safe_resolve_for_download,
|
||||
safe_resolve_for_listing,
|
||||
safe_resolve_for_move,
|
||||
safe_resolve_for_server_download,
|
||||
safe_resolve_for_server_listing,
|
||||
safe_resolve_for_write,
|
||||
)
|
||||
|
||||
|
||||
# Same caps as is_editable so /save can't sneak in something the listing
|
||||
# would have flagged non-editable.
|
||||
_SAVE_MAX_BYTES = 1 * 1024 * 1024
|
||||
# Per-upload byte cap — keeps a single hostile request from filling disk.
|
||||
# 200 MiB picked generously for vpks; tune later if needed.
|
||||
_UPLOAD_MAX_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
bp = Blueprint("files", __name__)
|
||||
|
||||
|
||||
|
|
@ -86,18 +46,6 @@ def _load_overlay_for_user(overlay_id: int, user) -> Overlay | Response:
|
|||
return overlay
|
||||
|
||||
|
||||
def _load_files_overlay(overlay_id: int, user) -> Overlay | Response:
|
||||
"""Like `_load_overlay_for_user`, but additionally 404s for any overlay
|
||||
whose type isn't `files`. Mutating endpoints use this; read-only ones
|
||||
keep working across all types."""
|
||||
result = _load_overlay_for_user(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
if result.type != "files":
|
||||
return Response(status=404)
|
||||
return result
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>/files")
|
||||
@require_login
|
||||
def overlay_files_fragment(overlay_id: int):
|
||||
|
|
@ -128,7 +76,6 @@ def overlay_files_fragment(overlay_id: int):
|
|||
truncated_count=truncated_count,
|
||||
files_base_url=f"/overlays/{overlay_id}",
|
||||
download_supported=True,
|
||||
files_overlay=(overlay.type == "files"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -197,416 +144,6 @@ def server_files_fragment(server_id: int):
|
|||
)
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>/files/content")
|
||||
@require_login
|
||||
def overlay_file_content(overlay_id: int):
|
||||
"""Return `{path, content}` for an editable text file."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
sub_path = request.args.get("path", "")
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
target = safe_resolve_for_listing(overlay.path, sub_path)
|
||||
except ValueError:
|
||||
return Response("invalid path", status=400)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
return Response(status=404)
|
||||
if not is_editable(target):
|
||||
return Response("not editable", status=415)
|
||||
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return Response("read failed", status=500)
|
||||
except UnicodeDecodeError:
|
||||
# is_editable sniffed only the first 8 KiB; the tail can still fail.
|
||||
return Response("not editable", status=415)
|
||||
|
||||
return jsonify({"path": sub_path, "content": content})
|
||||
|
||||
|
||||
def _validate_save_content(content: str) -> Response | None:
|
||||
if len(content.encode("utf-8")) > _SAVE_MAX_BYTES:
|
||||
return Response("content exceeds 1 MiB", status=413)
|
||||
if "\x00" in content:
|
||||
return Response("content contains NUL bytes", status=415)
|
||||
return None
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/files/save")
|
||||
@require_login
|
||||
def overlay_file_save(overlay_id: int):
|
||||
"""Write text content to `path`. Optional `new_path` performs a rename
|
||||
in the same call (atomic: rename then write; both succeed or neither)."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
path = (payload.get("path") or "").strip()
|
||||
new_path = payload.get("new_path")
|
||||
new_path = new_path.strip() if isinstance(new_path, str) and new_path.strip() else None
|
||||
content = payload.get("content")
|
||||
if not path or not isinstance(content, str):
|
||||
return Response("missing path or content", status=400)
|
||||
|
||||
err = _validate_save_content(content)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
write_target = safe_resolve_for_write(overlay.path, new_path or path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
|
||||
# Rename branch: source must exist, dst must not collide.
|
||||
if new_path is not None and new_path != path:
|
||||
try:
|
||||
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
if dst_path.exists():
|
||||
return Response("destination already exists", status=409)
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.rename(src_path, dst_path)
|
||||
write_target = dst_path
|
||||
else:
|
||||
write_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Creation branch: must not collide with an existing path.
|
||||
if write_target.exists() and not _is_existing_file(write_target):
|
||||
return Response("destination is not a file", status=409)
|
||||
|
||||
try:
|
||||
write_target.write_text(content, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
return Response(f"write failed: {exc}", status=500)
|
||||
|
||||
return jsonify({"path": new_path or path})
|
||||
|
||||
|
||||
def _is_existing_file(path) -> bool:
|
||||
return path.is_file() and not path.is_symlink()
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/files/replace")
|
||||
@require_login
|
||||
def overlay_file_replace(overlay_id: int):
|
||||
"""Replace the bytes of `path` with the uploaded `file`. Optional
|
||||
`new_path` performs an atomic rename in the same call."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
path = (request.form.get("path") or "").strip()
|
||||
new_path = (request.form.get("new_path") or "").strip() or None
|
||||
upload = request.files.get("file")
|
||||
if not path or upload is None:
|
||||
return Response("missing path or file", status=400)
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
if new_path and new_path != path:
|
||||
try:
|
||||
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
if dst_path.exists():
|
||||
return Response("destination already exists", status=409)
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.rename(src_path, dst_path)
|
||||
write_target = dst_path
|
||||
echo_path = new_path
|
||||
else:
|
||||
try:
|
||||
write_target = safe_resolve_for_write(overlay.path, path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
write_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
echo_path = path
|
||||
|
||||
return _stream_upload_into(upload, write_target, echo_path)
|
||||
|
||||
|
||||
def _stream_upload_into(upload, write_target, echo_path: str) -> Response:
|
||||
"""Write the multipart upload's stream into `write_target`, enforcing
|
||||
the per-upload size cap. Cleans up the partial file on failure or
|
||||
over-cap so a cancelled / oversized upload doesn't leak bytes.
|
||||
|
||||
`echo_path` is what the route reports back as the canonical relative
|
||||
path for the new file (the rename target if a rename happened, else the
|
||||
original path). The function doesn't recompute this so it can be passed
|
||||
through verbatim.
|
||||
"""
|
||||
tmp_dir = write_target.parent
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".upload-", dir=str(tmp_dir))
|
||||
bytes_seen = 0
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = upload.stream.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
bytes_seen += len(chunk)
|
||||
if bytes_seen > _UPLOAD_MAX_BYTES:
|
||||
raise _UploadTooLarge()
|
||||
out.write(chunk)
|
||||
os.replace(tmp_name, write_target)
|
||||
except _UploadTooLarge:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
return Response("upload exceeds size cap", status=413)
|
||||
except OSError as exc:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
return Response(f"upload failed: {exc}", status=500)
|
||||
return jsonify({"path": echo_path})
|
||||
|
||||
|
||||
class _UploadTooLarge(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _join_clean(folder: str, leaf: str) -> str:
|
||||
"""Join target_path + relative path safely, trimming slashes."""
|
||||
folder = (folder or "").strip("/").strip()
|
||||
leaf = (leaf or "").strip("/").strip()
|
||||
if folder and leaf:
|
||||
return f"{folder}/{leaf}"
|
||||
return folder or leaf
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/files/upload")
|
||||
@require_login
|
||||
def overlay_file_upload(overlay_id: int):
|
||||
"""Single-file upload. Multi-file or whole-folder drops fan out client
|
||||
side into one POST per file, each carrying its `webkitRelativePath` in
|
||||
`relative_path`. Conflicts return 409 unless `overwrite=1`."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
target_folder = (request.form.get("target_path") or "").strip()
|
||||
relative_path = (request.form.get("relative_path") or "").strip()
|
||||
overwrite = request.form.get("overwrite") == "1"
|
||||
upload = request.files.get("file")
|
||||
if upload is None:
|
||||
return Response("missing file", status=400)
|
||||
|
||||
# Filename fallback for browsers that don't send relative_path.
|
||||
filename = relative_path or (upload.filename or "").strip()
|
||||
if not filename:
|
||||
return Response("missing filename", status=400)
|
||||
|
||||
# Normalise: strip any DOS-style path components from the filename.
|
||||
filename = filename.replace("\\", "/")
|
||||
|
||||
full_rel = _join_clean(target_folder, filename)
|
||||
if not full_rel:
|
||||
return Response("missing destination path", status=400)
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
write_target = safe_resolve_for_write(overlay.path, full_rel)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
|
||||
if write_target.exists() and not overwrite:
|
||||
return Response("file already exists", status=409)
|
||||
|
||||
write_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
return _stream_upload_into(upload, write_target, full_rel)
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/files/move")
|
||||
@require_login
|
||||
def overlay_file_move(overlay_id: int):
|
||||
"""Rename / move a file or folder."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
src = (payload.get("src") or "").strip()
|
||||
dst = (payload.get("dst") or "").strip()
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
if not src or not dst:
|
||||
return Response("missing src or dst", status=400)
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
src_path, dst_path = safe_resolve_for_move(overlay.path, src, dst)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
|
||||
if dst_path.exists() and not overwrite:
|
||||
return Response("destination already exists", status=409)
|
||||
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if dst_path.exists() and overwrite:
|
||||
if dst_path.is_dir() and not dst_path.is_symlink():
|
||||
shutil.rmtree(dst_path)
|
||||
else:
|
||||
os.unlink(dst_path)
|
||||
os.rename(src_path, dst_path)
|
||||
except OSError as exc:
|
||||
return Response(f"move failed: {exc}", status=500)
|
||||
|
||||
return jsonify({"src": src, "dst": dst})
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/files/mkdir")
|
||||
@require_login
|
||||
def overlay_file_mkdir(overlay_id: int):
|
||||
"""Create empty directory `path`. Slashes in `path` create intermediates."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
path = (payload.get("path") or "").strip("/").strip()
|
||||
if not path:
|
||||
return Response("missing path", status=400)
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
target = safe_resolve_for_write(overlay.path, path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
|
||||
if target.exists():
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
# Idempotent — folder already there.
|
||||
return jsonify({"path": path})
|
||||
return Response("destination already exists and is not a directory", status=409)
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=False)
|
||||
except FileExistsError:
|
||||
return Response("directory already exists", status=409)
|
||||
except OSError as exc:
|
||||
return Response(f"mkdir failed: {exc}", status=500)
|
||||
|
||||
return jsonify({"path": path})
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/files/delete")
|
||||
@require_login
|
||||
def overlay_file_delete(overlay_id: int):
|
||||
"""Delete a file or empty folder. Refuses recursive directory removal."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
path = (request.form.get("path") or "").strip()
|
||||
if not path:
|
||||
return Response("missing path", status=400)
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
target = safe_resolve_for_delete(overlay.path, path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=422)
|
||||
|
||||
if not target.exists() and not target.is_symlink():
|
||||
return Response(status=404)
|
||||
|
||||
try:
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
return Response("directory is not empty", status=409)
|
||||
else:
|
||||
os.unlink(target)
|
||||
except OSError as exc:
|
||||
return Response(f"delete failed: {exc}", status=500)
|
||||
|
||||
return jsonify({"path": path})
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>/files/download_zip")
|
||||
@require_login
|
||||
def overlay_file_download_zip(overlay_id: int):
|
||||
"""Stream a zip of the folder at `path` (or the overlay root). Symlinks
|
||||
are written as their resolved file content (matches the regular download
|
||||
endpoint's behavior — workshop-cache symlinks streamed as bytes)."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
sub_path = request.args.get("path", "")
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
target = safe_resolve_for_listing(overlay.path, sub_path)
|
||||
except ValueError:
|
||||
return Response("invalid path", status=400)
|
||||
|
||||
if not target.exists() or not target.is_dir():
|
||||
return Response(status=404)
|
||||
|
||||
folder_name = os.path.basename(str(target)) or "overlay"
|
||||
download_name = f"{folder_name}.zip"
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(target, followlinks=False):
|
||||
for name in files:
|
||||
abs_path = os.path.join(root, name)
|
||||
rel = os.path.relpath(abs_path, str(target))
|
||||
try:
|
||||
zf.write(abs_path, arcname=rel)
|
||||
except OSError:
|
||||
continue
|
||||
# Include empty directories so the structure round-trips.
|
||||
for name in dirs:
|
||||
abs_dir = os.path.join(root, name)
|
||||
rel_dir = os.path.relpath(abs_dir, str(target)) + "/"
|
||||
if not any(True for _ in os.scandir(abs_dir)):
|
||||
zf.writestr(zipfile.ZipInfo(rel_dir), b"")
|
||||
|
||||
buffer.seek(0)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype="application/zip",
|
||||
as_attachment=True,
|
||||
download_name=download_name,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/servers/<int:server_id>/files/download")
|
||||
@require_login
|
||||
def server_files_download(server_id: int):
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from l4d2web.services.overlay_creation import (
|
|||
)
|
||||
|
||||
|
||||
CREATABLE_OVERLAY_TYPES = {"workshop", "script", "files"}
|
||||
CREATABLE_OVERLAY_TYPES = {"workshop", "script"}
|
||||
WIPE_SCRIPT = "find /overlay -mindepth 1 -delete"
|
||||
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ def _can_edit_overlay(overlay: Overlay, user) -> bool:
|
|||
return False
|
||||
if user.admin:
|
||||
return True
|
||||
if overlay.type in {"workshop", "script", "files"}:
|
||||
if overlay.type in {"workshop", "script"}:
|
||||
return overlay.user_id == user.id
|
||||
return False
|
||||
|
||||
|
|
@ -68,14 +68,7 @@ def create_overlay() -> Response:
|
|||
if _name_already_taken(db, name, scope_user_id):
|
||||
return Response("overlay already exists", status=409)
|
||||
|
||||
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,
|
||||
)
|
||||
overlay = Overlay(name=name, path="", type=overlay_type, user_id=scope_user_id)
|
||||
db.add(overlay)
|
||||
db.flush()
|
||||
overlay.path = generate_overlay_path(overlay.id)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import json
|
||||
|
||||
from flask import Blueprint, Response, redirect, render_template, request
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.auth import current_user, require_admin, require_login
|
||||
from l4d2web.db import session_scope
|
||||
|
|
@ -55,76 +55,6 @@ def admin_users() -> str:
|
|||
return render_template("admin_users.html", users=users)
|
||||
|
||||
|
||||
@bp.post("/admin/users/<int:user_id>/deactivate")
|
||||
@require_admin
|
||||
def admin_users_deactivate(user_id: int) -> Response:
|
||||
actor = current_user()
|
||||
assert actor is not None
|
||||
if actor.id == user_id:
|
||||
return Response("cannot deactivate yourself", status=409)
|
||||
with session_scope() as db:
|
||||
target = db.scalar(select(User).where(User.id == user_id))
|
||||
if target is None:
|
||||
return Response(status=404)
|
||||
target.active = False
|
||||
return redirect("/admin/users")
|
||||
|
||||
|
||||
@bp.post("/admin/users/<int:user_id>/activate")
|
||||
@require_admin
|
||||
def admin_users_activate(user_id: int) -> Response:
|
||||
with session_scope() as db:
|
||||
target = db.scalar(select(User).where(User.id == user_id))
|
||||
if target is None:
|
||||
return Response(status=404)
|
||||
target.active = True
|
||||
return redirect("/admin/users")
|
||||
|
||||
|
||||
@bp.post("/admin/users/<int:user_id>/delete")
|
||||
@require_admin
|
||||
def admin_users_delete(user_id: int) -> Response:
|
||||
actor = current_user()
|
||||
assert actor is not None
|
||||
if actor.id == user_id:
|
||||
return Response("cannot delete yourself", status=409)
|
||||
|
||||
with session_scope() as db:
|
||||
target = db.scalar(select(User).where(User.id == user_id))
|
||||
if target is None:
|
||||
return Response(status=404)
|
||||
|
||||
if target.admin:
|
||||
other_admins = db.scalar(
|
||||
select(func.count(User.id)).where(User.admin.is_(True), User.id != user_id)
|
||||
) or 0
|
||||
if other_admins == 0:
|
||||
return Response("cannot delete the last admin", status=409)
|
||||
|
||||
owned_servers = db.scalar(
|
||||
select(func.count(Server.id)).where(Server.user_id == user_id)
|
||||
) or 0
|
||||
owned_blueprints = db.scalar(
|
||||
select(func.count(BlueprintModel.id)).where(BlueprintModel.user_id == user_id)
|
||||
) or 0
|
||||
owned_overlays = db.scalar(
|
||||
select(func.count(Overlay.id)).where(Overlay.user_id == user_id)
|
||||
) or 0
|
||||
if owned_servers or owned_blueprints or owned_overlays:
|
||||
return Response(
|
||||
f"user owns content: {owned_servers} server(s), "
|
||||
f"{owned_blueprints} blueprint(s), {owned_overlays} overlay(s) — "
|
||||
"delete those first",
|
||||
status=409,
|
||||
)
|
||||
|
||||
# Job rows have nullable user_id — keep them as audit trail with the FK nulled out.
|
||||
db.execute(update(Job).where(Job.user_id == user_id).values(user_id=None))
|
||||
db.delete(target)
|
||||
|
||||
return redirect("/admin/users")
|
||||
|
||||
|
||||
@bp.get("/admin/jobs")
|
||||
@require_admin
|
||||
def admin_jobs() -> str:
|
||||
|
|
|
|||
|
|
@ -280,27 +280,7 @@ def _is_under(path: Path, root: Path) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
class FilesBuilder:
|
||||
"""No-op builder for `files` overlays. Their content IS the overlay
|
||||
directory — every save / upload / move / delete is immediately
|
||||
authoritative. The build step exists only so the overlay-build subsystem
|
||||
can dispatch uniformly across all overlay types; here it simply ensures
|
||||
the overlay directory exists."""
|
||||
|
||||
def build(
|
||||
self,
|
||||
overlay: Overlay,
|
||||
*,
|
||||
on_stdout: LogSink,
|
||||
on_stderr: LogSink,
|
||||
should_cancel: CancelCheck,
|
||||
) -> None:
|
||||
overlay_path_for_id(overlay.id).mkdir(parents=True, exist_ok=True)
|
||||
on_stdout(f"files overlay {overlay.name!r}: directory ensured (no-op build)")
|
||||
|
||||
|
||||
BUILDERS: dict[str, OverlayBuilder] = {
|
||||
"workshop": WorkshopBuilder(),
|
||||
"script": ScriptBuilder(),
|
||||
"files": FilesBuilder(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,113 +33,6 @@ def safe_resolve_for_listing(overlay_path_value: str, sub_path: str) -> Path:
|
|||
return candidate
|
||||
|
||||
|
||||
def safe_resolve_for_write(overlay_path_value: str, sub_path: str) -> Path:
|
||||
"""Resolve a destination path for create / write / replace operations.
|
||||
|
||||
Refuses empty `sub_path`, escape via `..` or symlink target, refuses to
|
||||
overwrite an existing symlink, and refuses a path whose parent resolves
|
||||
to a non-directory. Returns the lexical (un-resolved) candidate so the
|
||||
caller can write through to the user-visible path; the safety check is
|
||||
done against the resolved variant.
|
||||
"""
|
||||
if sub_path == "":
|
||||
raise ValueError("write requires a sub-path")
|
||||
validate_overlay_ref(sub_path)
|
||||
overlay_root = resolve_overlay_root(overlay_path_value).resolve()
|
||||
candidate = overlay_root / sub_path
|
||||
resolved_candidate = candidate.resolve(strict=False)
|
||||
if not _is_under(resolved_candidate, overlay_root):
|
||||
raise ValueError("path escapes overlay root")
|
||||
if candidate.is_symlink():
|
||||
raise ValueError("refusing to overwrite an existing symlink")
|
||||
parent = candidate.parent
|
||||
if parent.exists() and not parent.is_dir():
|
||||
raise ValueError("parent path is not a directory")
|
||||
return candidate
|
||||
|
||||
|
||||
def safe_resolve_for_delete(overlay_path_value: str, sub_path: str) -> Path:
|
||||
"""Resolve a path the user wants to delete. Same root-escape rules as
|
||||
`safe_resolve_for_listing`. Allows files and (empty) directories; the
|
||||
caller is responsible for refusing recursive directory removal."""
|
||||
if sub_path == "":
|
||||
raise ValueError("delete requires a sub-path")
|
||||
validate_overlay_ref(sub_path)
|
||||
overlay_root = resolve_overlay_root(overlay_path_value).resolve()
|
||||
candidate = overlay_root / sub_path
|
||||
resolved_candidate = candidate.resolve(strict=False)
|
||||
if not _is_under(resolved_candidate, overlay_root):
|
||||
raise ValueError("path escapes overlay root")
|
||||
return candidate
|
||||
|
||||
|
||||
def safe_resolve_for_move(
|
||||
overlay_path_value: str, src: str, dst: str
|
||||
) -> tuple[Path, Path]:
|
||||
"""Resolve src + dst for a rename/move. Both must be inside the overlay
|
||||
root. Refuses cycle (dst inside src), missing src, missing/non-dir parent
|
||||
of dst, and overwriting a symlink at dst."""
|
||||
if src == "" or dst == "":
|
||||
raise ValueError("move requires non-empty src and dst")
|
||||
validate_overlay_ref(src)
|
||||
validate_overlay_ref(dst)
|
||||
overlay_root = resolve_overlay_root(overlay_path_value).resolve()
|
||||
src_path = overlay_root / src
|
||||
dst_path = overlay_root / dst
|
||||
src_resolved = src_path.resolve(strict=False)
|
||||
dst_resolved = dst_path.resolve(strict=False)
|
||||
if not _is_under(src_resolved, overlay_root):
|
||||
raise ValueError("src escapes overlay root")
|
||||
if not _is_under(dst_resolved, overlay_root):
|
||||
raise ValueError("dst escapes overlay root")
|
||||
if not src_path.exists() and not src_path.is_symlink():
|
||||
raise ValueError("src does not exist")
|
||||
if dst_path.is_symlink():
|
||||
raise ValueError("refusing to overwrite an existing symlink at dst")
|
||||
dst_parent = dst_path.parent
|
||||
if dst_parent.exists() and not dst_parent.is_dir():
|
||||
raise ValueError("dst parent is not a directory")
|
||||
if src_path.is_dir():
|
||||
# Cycle check: dst must not be src or any descendant of src.
|
||||
if dst_resolved == src_resolved or src_resolved in dst_resolved.parents:
|
||||
raise ValueError("cannot move a directory into itself")
|
||||
return src_path, dst_path
|
||||
|
||||
|
||||
_EDITABLE_MAX_BYTES = 1 * 1024 * 1024
|
||||
_UTF8_SNIFF_BYTES = 8 * 1024
|
||||
|
||||
|
||||
def is_editable(path: Path) -> bool:
|
||||
"""True iff the file is a regular file (not symlink), ≤ 1 MiB, the first
|
||||
8 KiB decodes as strict UTF-8, and contains no NUL bytes. NULs decode as
|
||||
valid UTF-8 (U+0000) but render badly in textareas and almost always
|
||||
indicate binary content; rejecting them matches the "is this a text
|
||||
file" intuition users expect. Save endpoint enforces the same rules."""
|
||||
try:
|
||||
if path.is_symlink():
|
||||
return False
|
||||
if not path.is_file():
|
||||
return False
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
if size > _EDITABLE_MAX_BYTES:
|
||||
return False
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
sample = f.read(_UTF8_SNIFF_BYTES)
|
||||
except OSError:
|
||||
return False
|
||||
if b"\x00" in sample:
|
||||
return False
|
||||
try:
|
||||
sample.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def safe_resolve_for_server_listing(server_id: int, sub_path: str) -> Path | None:
|
||||
"""Resolve a path inside `runtime/<server_id>/merged` (the kernel-overlayfs
|
||||
composed view of a running server). Returns None if the merged dir doesn't
|
||||
|
|
@ -215,11 +108,6 @@ def _entry_dict(entry: "os.DirEntry[str]", overlay_root: Path) -> dict:
|
|||
|
||||
rel_str = "/".join(Path(entry.path).relative_to(overlay_root).parts)
|
||||
|
||||
if kind == "file" and not broken:
|
||||
editable = is_editable(Path(entry.path))
|
||||
else:
|
||||
editable = False
|
||||
|
||||
return {
|
||||
"name": entry.name,
|
||||
"rel": rel_str,
|
||||
|
|
@ -228,7 +116,6 @@ def _entry_dict(entry: "os.DirEntry[str]", overlay_root: Path) -> dict:
|
|||
"broken": broken,
|
||||
"size": size,
|
||||
"size_human": _format_size(size) if size is not None else "",
|
||||
"editable": editable,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -510,347 +510,3 @@ button.danger-outline:hover {
|
|||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Files-overlay editor: tree-row hover actions, drop targets,
|
||||
editor / new-folder / conflict / delete modals, and the
|
||||
floating Uploads panel.
|
||||
============================================================ */
|
||||
|
||||
/* The display: flex / grid declarations on the elements below have the
|
||||
same specificity as the UA's `[hidden]{display:none}` rule and come
|
||||
later in the cascade, so without this they'd win and elements would
|
||||
stay visible after JS sets `el.hidden = true`. Targeted rather than
|
||||
a global `[hidden]!important` so we don't fight unknown UA defaults. */
|
||||
.files-uploads[hidden],
|
||||
.files-editor-binary[hidden],
|
||||
.files-editor-text[hidden],
|
||||
.files-editor-replace-idle[hidden],
|
||||
.files-editor-replace-queued[hidden],
|
||||
.files-editor-rename-hint[hidden],
|
||||
.files-uploads-clear[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.files-manager {
|
||||
display: grid;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.files-manager-hint {
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.files-tree-root {
|
||||
border: var(--line-soft);
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--space-s);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.files-tree-root .file-tree {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.files-tree-root.is-drop-target {
|
||||
outline: 2px solid var(--color-success);
|
||||
outline-offset: -2px;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
}
|
||||
|
||||
.files-row-root > .files-row-root-label {
|
||||
color: var(--color-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.files-root-children {
|
||||
flex-basis: 100%;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.files-empty {
|
||||
margin: var(--space-s) var(--space-xs);
|
||||
}
|
||||
|
||||
/* Per-row action column. Lives inside a .file-tree-row. Hidden until row
|
||||
is hovered, except on touch devices where hover doesn't apply. */
|
||||
.files-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.files-row .files-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: auto;
|
||||
padding-left: var(--space-m);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 80ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.files-row:hover > .files-row-actions,
|
||||
.files-row:focus-within > .files-row-actions,
|
||||
.files-row.is-drop-target > .files-row-actions,
|
||||
.files-row.is-drag-source > .files-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.files-row .files-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.files-row-action {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-link);
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: 0.85em;
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.files-row-action:hover {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: var(--color-link);
|
||||
}
|
||||
|
||||
.files-row-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.files-row-danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.files-row.is-drag-source {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.files-row.is-drop-target {
|
||||
outline: 2px solid var(--color-success);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Wider modal for the editor (textarea needs the breathing room). */
|
||||
dialog.modal.modal-wide {
|
||||
width: min(48rem, 92vw);
|
||||
}
|
||||
|
||||
.files-editor-field {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.files-editor-field input,
|
||||
.files-editor-field textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.files-field-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.files-editor-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.files-editor-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.files-editor-rename-hint {
|
||||
color: var(--color-success);
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.files-editor-rename-hint code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.files-editor-binary {
|
||||
background: var(--color-surface-muted);
|
||||
border: var(--line);
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--space-m);
|
||||
display: grid;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.files-editor-binary-note {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.files-editor-binary-replace-label {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.files-editor-replace-zone {
|
||||
border: 1.5px dashed var(--color-success);
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--space-m);
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--color-success) 5%, transparent);
|
||||
}
|
||||
|
||||
.files-editor-replace-zone.is-drop-target {
|
||||
background: color-mix(in srgb, var(--color-success) 22%, transparent);
|
||||
}
|
||||
|
||||
.files-editor-replace-idle {
|
||||
color: var(--color-success);
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.files-editor-replace-queued {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.files-editor-footer {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.files-editor-footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.files-editor-delete {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.files-editor-save {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.files-editor-save[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.files-new-folder-error {
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.files-conflict-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Floating uploads panel — bottom-right of the page. */
|
||||
.files-uploads {
|
||||
position: fixed;
|
||||
right: var(--space-l);
|
||||
bottom: var(--space-l);
|
||||
width: min(22rem, calc(100vw - 2 * var(--space-l)));
|
||||
background: var(--color-surface);
|
||||
border: var(--line);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
z-index: 50;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.files-uploads-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-s) var(--space-m);
|
||||
border-bottom: var(--line);
|
||||
}
|
||||
|
||||
.files-uploads-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-s) var(--space-m);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.files-uploads-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.files-uploads-row-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-s);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.files-uploads-row-name {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.files-uploads-row-target {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.files-uploads-row-progress {
|
||||
height: 3px;
|
||||
background: var(--color-surface-muted);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.files-uploads-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: var(--color-link);
|
||||
transition: width 80ms linear;
|
||||
}
|
||||
|
||||
.files-uploads-bar.is-done {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.files-uploads-bar.is-error {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.files-uploads-bar.is-cancelled {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.files-uploads-row-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,13 @@
|
|||
// carries `data-files-url`. First expand fires a fetch and innerHTMLs the
|
||||
// returned partial into the next `.file-tree-children`; subsequent clicks
|
||||
// just toggle visibility — no re-fetch.
|
||||
//
|
||||
// Children-div lookup goes through the row's <li> rather than the button's
|
||||
// nextElementSibling so the files-overlay variant — where a per-row action
|
||||
// column sits between the toggle button and the children div — works too.
|
||||
(function () {
|
||||
document.addEventListener("click", function (event) {
|
||||
const button = event.target.closest(".file-tree-toggle");
|
||||
if (!button) return;
|
||||
|
||||
const row = button.closest(".file-tree-row");
|
||||
const children = row ? row.querySelector(":scope > .file-tree-children") : null;
|
||||
if (!children) return;
|
||||
const children = button.nextElementSibling;
|
||||
if (!children || !children.classList.contains("file-tree-children")) return;
|
||||
|
||||
const wasExpanded = button.getAttribute("aria-expanded") === "true";
|
||||
button.setAttribute("aria-expanded", wasExpanded ? "false" : "true");
|
||||
|
|
|
|||
|
|
@ -1,984 +0,0 @@
|
|||
// 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,25 +1,15 @@
|
|||
{% if entry.kind == '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 %}>
|
||||
<li class="file-tree-row file-tree-row-dir">
|
||||
<button type="button"
|
||||
class="file-tree-toggle"
|
||||
aria-expanded="false"
|
||||
data-files-url="{{ files_base_url }}/files?path={{ entry.rel|urlencode }}">
|
||||
<span class="chevron" aria-hidden="true">›</span>{{ entry.name }}/
|
||||
</button>
|
||||
{% if files_overlay %}
|
||||
<span class="files-row-actions" aria-label="Folder actions">
|
||||
<button type="button" class="files-row-action" data-action="new-file" data-target-path="{{ entry.rel }}" title="New file">+ file</button>
|
||||
<button type="button" class="files-row-action" data-action="new-folder" data-target-path="{{ entry.rel }}" title="New folder">+ folder</button>
|
||||
<button type="button" class="files-row-action" data-action="zip" data-target-path="{{ entry.rel }}" title="Download as zip">⬇ zip</button>
|
||||
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="dir" data-row-name="{{ entry.name }}" title="Delete folder">✕</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
<div class="file-tree-children" hidden></div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="file-tree-row file-tree-row-file{% 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 %}>
|
||||
<li class="file-tree-row file-tree-row-file">
|
||||
{% if entry.broken %}
|
||||
<span>{{ entry.name }}</span>
|
||||
<span class="file-tree-badge file-tree-badge-warn">broken link</span>
|
||||
|
|
@ -32,12 +22,5 @@
|
|||
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
|
||||
<span class="muted">{{ entry.size_human }}</span>
|
||||
{% endif %}
|
||||
{% if files_overlay and not entry.broken %}
|
||||
<span class="files-row-actions" aria-label="File actions">
|
||||
<button type="button" class="files-row-action" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Edit">edit</button>
|
||||
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download">⬇</a>
|
||||
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete">✕</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<ul class="file-tree" role="group" {% if files_overlay %}data-files-overlay="1"{% endif %}>
|
||||
<ul class="file-tree" role="group">
|
||||
{% for entry in entries %}{% include "_overlay_file_node.html" %}{% endfor %}
|
||||
{% if truncated %}
|
||||
<li class="file-tree-row file-tree-row-truncated muted">
|
||||
|
|
|
|||
|
|
@ -6,70 +6,14 @@
|
|||
<section class="panel">
|
||||
<h1>Users</h1>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Admin</th>
|
||||
<th>Active</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>Username</th><th>Admin</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<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>
|
||||
<tr><td>{{ user.username }}</td><td>{{ "yes" if user.admin else "no" }}</td><td>{{ user.created_at }}</td><td>{{ user.updated_at }}</td></tr>
|
||||
{% else %}
|
||||
{% if user.active %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/deactivate" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit" class="button-secondary">Deactivate</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/activate" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit" class="button-secondary">Activate</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="danger-outline" data-modal-open="delete-user-{{ user.id }}-modal">Delete</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">No users found.</td></tr>
|
||||
<tr><td colspan="4" class="muted">No users found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% for user in users %}
|
||||
{% if user.id != g.user.id %}
|
||||
<dialog id="delete-user-{{ user.id }}-modal" class="modal" aria-labelledby="delete-user-{{ user.id }}-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="delete-user-{{ user.id }}-title">Delete user "{{ user.username }}"?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</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 %}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@
|
|||
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% 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 %}
|
||||
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Overlay: {{ overlay.name }}</h1>
|
||||
|
|
@ -61,38 +59,6 @@
|
|||
{% endif %}
|
||||
|
||||
<h2 class="section-title">Files</h2>
|
||||
{% if files_can_edit %}
|
||||
<div class="files-manager"
|
||||
data-overlay-id="{{ overlay.id }}"
|
||||
data-base-url="/overlays/{{ overlay.id }}">
|
||||
<p class="muted files-manager-hint">Drop files or folders onto a folder row to upload. Drag rows inside the tree to move them.</p>
|
||||
<ul class="file-tree files-tree-root" data-files-overlay="1">
|
||||
<li class="file-tree-row file-tree-row-dir files-row files-row-root"
|
||||
data-target-path=""
|
||||
data-row-kind="dir">
|
||||
<span class="files-row-root-label">/</span>
|
||||
<span class="files-row-actions" aria-label="Overlay root actions">
|
||||
<button type="button" class="files-row-action" data-action="new-file" data-target-path="">+ new file</button>
|
||||
<button type="button" class="files-row-action" data-action="new-folder" data-target-path="">+ new folder</button>
|
||||
<button type="button" class="files-row-action" data-action="zip" data-target-path="">⬇ zip</button>
|
||||
</span>
|
||||
<div class="file-tree-children files-root-children">
|
||||
{% if not file_tree_root_entries %}
|
||||
<p class="muted files-empty">Empty — drop files here, or click "+ new file" on this row.</p>
|
||||
{% else %}
|
||||
{% set entries = file_tree_root_entries %}
|
||||
{% set truncated = file_tree_truncated %}
|
||||
{% set truncated_count = file_tree_truncated_count %}
|
||||
{% set files_base_url = "/overlays/" ~ overlay.id %}
|
||||
{% set download_supported = True %}
|
||||
{% set files_overlay = True %}
|
||||
{% include "_overlay_file_tree.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if not file_tree_root_entries %}
|
||||
<p class="muted">No files yet — build this overlay to populate it.</p>
|
||||
{% else %}
|
||||
|
|
@ -103,7 +69,6 @@
|
|||
{% set download_supported = True %}
|
||||
{% include "_overlay_file_tree.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-title">Used by</h2>
|
||||
{% if using_blueprints %}
|
||||
|
|
@ -154,119 +119,4 @@
|
|||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
{% if files_can_edit %}
|
||||
<dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text">…</span></h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</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 %}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@
|
|||
<legend>Type</legend>
|
||||
<label><input type="radio" name="type" value="workshop" checked> Workshop (downloads from Steam)</label>
|
||||
<label><input type="radio" name="type" value="script"> Script (runs sandboxed bash)</label>
|
||||
<label><input type="radio" name="type" value="files"> Files (upload / edit text files online)</label>
|
||||
</fieldset>
|
||||
<label>Name <input name="name" required></label>
|
||||
{% if g.user and g.user.admin %}
|
||||
|
|
|
|||
|
|
@ -1,260 +0,0 @@
|
|||
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:
|
||||
assert set(overlay_builders.BUILDERS) == {"workshop", "script", "files"}
|
||||
assert set(overlay_builders.BUILDERS) == {"workshop", "script"}
|
||||
|
||||
|
||||
def test_registry_excludes_legacy_types() -> None:
|
||||
|
|
@ -72,29 +72,6 @@ def test_registry_excludes_legacy_types() -> None:
|
|||
assert legacy not in overlay_builders.BUILDERS
|
||||
|
||||
|
||||
def test_files_builder_is_idempotent_no_op(monkeypatch, tmp_path) -> None:
|
||||
"""Files builder ensures the overlay directory exists. Running twice
|
||||
against an already-populated overlay must not clobber its contents."""
|
||||
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||
overlay = type("O", (), {"id": 42, "name": "files-fixture"})()
|
||||
out, err, on_stdout, on_stderr = _capture_logs()
|
||||
|
||||
overlay_builders.BUILDERS["files"].build(
|
||||
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False
|
||||
)
|
||||
|
||||
overlay_dir = tmp_path / "overlays" / "42"
|
||||
assert overlay_dir.is_dir()
|
||||
(overlay_dir / "kept.txt").write_text("preserved")
|
||||
|
||||
overlay_builders.BUILDERS["files"].build(
|
||||
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False
|
||||
)
|
||||
|
||||
assert (overlay_dir / "kept.txt").read_text() == "preserved"
|
||||
assert err == []
|
||||
|
||||
|
||||
def test_registry_unknown_type_raises_keyerror() -> None:
|
||||
with pytest.raises(KeyError):
|
||||
overlay_builders.BUILDERS["nope"]
|
||||
|
|
|
|||
|
|
@ -265,243 +265,3 @@ def test_list_directory_includes_size_human_for_files(overlay_root: Path) -> Non
|
|||
# Files only — directories don't have size_human.
|
||||
assert by_name["tiny.txt"]["size_human"] == "5 B"
|
||||
assert by_name["big.bin"]["size_human"] == "3.0 MB"
|
||||
|
||||
|
||||
# ---- safe_resolve_for_write -------------------------------------------------
|
||||
|
||||
|
||||
def test_safe_resolve_for_write_returns_path_under_overlay_root(
|
||||
overlay_root: Path,
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_write
|
||||
|
||||
resolved = safe_resolve_for_write("7", "left4dead2/cfg/server.cfg")
|
||||
|
||||
assert resolved == overlay_root / "left4dead2" / "cfg" / "server.cfg"
|
||||
|
||||
|
||||
def test_safe_resolve_for_write_rejects_empty_path(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_write
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_write("7", "")
|
||||
|
||||
|
||||
def test_safe_resolve_for_write_rejects_dotdot(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_write
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_write("7", "../escape.txt")
|
||||
|
||||
|
||||
def test_safe_resolve_for_write_rejects_overwriting_symlink(
|
||||
overlay_root: Path, tmp_path: Path
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_write
|
||||
|
||||
target = tmp_path / "outside.txt"
|
||||
target.write_text("nope")
|
||||
(overlay_root / "evil").symlink_to(target)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_write("7", "evil")
|
||||
|
||||
|
||||
def test_safe_resolve_for_write_rejects_path_with_non_dir_parent(
|
||||
overlay_root: Path,
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_write
|
||||
|
||||
(overlay_root / "blocker").write_text("file, not dir")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_write("7", "blocker/child.txt")
|
||||
|
||||
|
||||
# ---- safe_resolve_for_delete -----------------------------------------------
|
||||
|
||||
|
||||
def test_safe_resolve_for_delete_returns_resolvable_path(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
||||
|
||||
target = overlay_root / "cfg" / "server.cfg"
|
||||
target.parent.mkdir()
|
||||
target.write_text("x")
|
||||
|
||||
resolved = safe_resolve_for_delete("7", "cfg/server.cfg")
|
||||
|
||||
assert resolved == target
|
||||
|
||||
|
||||
def test_safe_resolve_for_delete_rejects_dotdot(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_delete("7", "../neighbour")
|
||||
|
||||
|
||||
def test_safe_resolve_for_delete_rejects_empty(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_delete("7", "")
|
||||
|
||||
|
||||
def test_safe_resolve_for_delete_rejects_symlink_escaping_root(
|
||||
overlay_root: Path, tmp_path: Path
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
||||
|
||||
outside = tmp_path / "outside-link.txt"
|
||||
outside.write_text("nope")
|
||||
(overlay_root / "evil").symlink_to(outside)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_delete("7", "evil")
|
||||
|
||||
|
||||
# ---- safe_resolve_for_move -------------------------------------------------
|
||||
|
||||
|
||||
def test_safe_resolve_for_move_returns_paths_when_valid(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_move
|
||||
|
||||
src = overlay_root / "motd.txt"
|
||||
src.write_text("welcome")
|
||||
(overlay_root / "addons").mkdir()
|
||||
|
||||
src_path, dst_path = safe_resolve_for_move("7", "motd.txt", "addons/motd.txt")
|
||||
|
||||
assert src_path == src
|
||||
assert dst_path == overlay_root / "addons" / "motd.txt"
|
||||
|
||||
|
||||
def test_safe_resolve_for_move_rejects_missing_src(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_move
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_move("7", "nope.txt", "addons/nope.txt")
|
||||
|
||||
|
||||
def test_safe_resolve_for_move_rejects_dst_parent_not_directory(
|
||||
overlay_root: Path,
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_move
|
||||
|
||||
(overlay_root / "src.txt").write_text("x")
|
||||
(overlay_root / "blocker").write_text("file, not dir")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_move("7", "src.txt", "blocker/dst.txt")
|
||||
|
||||
|
||||
def test_safe_resolve_for_move_rejects_dst_inside_src(overlay_root: Path) -> None:
|
||||
"""Moving a directory into itself or a descendant must fail before any
|
||||
rename happens."""
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_move
|
||||
|
||||
src = overlay_root / "addons"
|
||||
src.mkdir()
|
||||
(src / "child").mkdir()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_move("7", "addons", "addons/child/addons")
|
||||
|
||||
|
||||
def test_safe_resolve_for_move_rejects_overwrite_of_symlink(
|
||||
overlay_root: Path, tmp_path: Path
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_move
|
||||
|
||||
(overlay_root / "src.txt").write_text("x")
|
||||
(overlay_root / "dst").symlink_to(tmp_path / "outside.txt")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_move("7", "src.txt", "dst")
|
||||
|
||||
|
||||
def test_safe_resolve_for_move_rejects_dotdot(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import safe_resolve_for_move
|
||||
|
||||
(overlay_root / "src.txt").write_text("x")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_resolve_for_move("7", "src.txt", "../escape.txt")
|
||||
|
||||
|
||||
# ---- is_editable -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_editable_true_for_small_utf8_file(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import is_editable
|
||||
|
||||
target = overlay_root / "motd.txt"
|
||||
target.write_text("Welcome\nHave fun.\n")
|
||||
|
||||
assert is_editable(target) is True
|
||||
|
||||
|
||||
def test_is_editable_false_for_oversized_file(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import is_editable
|
||||
|
||||
target = overlay_root / "huge.bin"
|
||||
# 1 MiB + 1 byte
|
||||
target.write_bytes(b"a" * (1024 * 1024 + 1))
|
||||
|
||||
assert is_editable(target) is False
|
||||
|
||||
|
||||
def test_is_editable_false_for_binary_content_in_first_8kib(
|
||||
overlay_root: Path,
|
||||
) -> None:
|
||||
from l4d2web.services.overlay_files import is_editable
|
||||
|
||||
target = overlay_root / "fake.vpk"
|
||||
# Random binary bytes in the sniff window — should fail strict UTF-8.
|
||||
target.write_bytes(bytes(range(256)) * 40)
|
||||
|
||||
assert is_editable(target) is False
|
||||
|
||||
|
||||
def test_is_editable_false_for_symlink(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import is_editable
|
||||
|
||||
real = overlay_root / "real.txt"
|
||||
real.write_text("hi")
|
||||
link = overlay_root / "link.txt"
|
||||
link.symlink_to(real)
|
||||
|
||||
assert is_editable(link) is False
|
||||
|
||||
|
||||
def test_is_editable_false_for_directory(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import is_editable
|
||||
|
||||
sub = overlay_root / "subdir"
|
||||
sub.mkdir()
|
||||
|
||||
assert is_editable(sub) is False
|
||||
|
||||
|
||||
def test_entry_dict_marks_editable_for_small_utf8_file(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import list_directory
|
||||
|
||||
(overlay_root / "small.txt").write_text("hello")
|
||||
(overlay_root / "big.bin").write_bytes(b"\x00" * 200)
|
||||
|
||||
entries, _ = list_directory(overlay_root, overlay_root)
|
||||
by_name = {e["name"]: e for e in entries}
|
||||
|
||||
assert by_name["small.txt"]["editable"] is True
|
||||
assert by_name["big.bin"]["editable"] is False
|
||||
|
||||
|
||||
def test_entry_dict_marks_directories_not_editable(overlay_root: Path) -> None:
|
||||
from l4d2web.services.overlay_files import list_directory
|
||||
|
||||
(overlay_root / "subdir").mkdir()
|
||||
|
||||
entries, _ = list_directory(overlay_root, overlay_root)
|
||||
by_name = {e["name"]: e for e in entries}
|
||||
|
||||
assert by_name["subdir"]["editable"] is False
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""HTTP-level tests for the overlay 'Files' tree-fragment + download routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -494,515 +493,3 @@ def test_server_detail_renders_files_section(app, left4me_root: Path) -> None:
|
|||
assert response.status_code == 200
|
||||
assert ">Files<" in text
|
||||
assert "left4dead2" in text
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Files-overlay mutating endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _make_files_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int:
|
||||
with session_scope() as s:
|
||||
overlay = Overlay(name=name, path="", type="files", user_id=user_id, last_build_status="ok")
|
||||
s.add(overlay)
|
||||
s.flush()
|
||||
overlay.path = str(overlay.id)
|
||||
overlay_id = overlay.id
|
||||
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
||||
return overlay_id
|
||||
|
||||
|
||||
def _csrf_headers():
|
||||
return {"X-CSRF-Token": "test-token"}
|
||||
|
||||
|
||||
# ---- /content -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_content_returns_text(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_text("welcome")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.get_json() == {"path": "motd.txt", "content": "welcome"}
|
||||
|
||||
|
||||
def test_content_returns_415_for_binary(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "image.bin").write_bytes(b"\x00\x01\x02")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.get(f"/overlays/{overlay_id}/files/content?path=image.bin")
|
||||
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
def test_content_404_for_non_files_overlay(app, left4me_root: Path) -> None:
|
||||
"""`content` is gated to type=='files' to keep the new editor scoped."""
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws") # type='script' helper
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_text("hi")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt")
|
||||
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---- /save ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_creates_new_file(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/save",
|
||||
json={"path": "left4dead2/cfg/admins.txt", "content": "STEAM_1:0:1\n"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "left4dead2" / "cfg" / "admins.txt").read_text() == "STEAM_1:0:1\n"
|
||||
|
||||
|
||||
def test_save_overwrites_existing_file(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_text("old")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/save",
|
||||
json={"path": "motd.txt", "content": "new"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "motd.txt").read_text() == "new"
|
||||
|
||||
|
||||
def test_save_with_new_path_renames_atomically(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_text("welcome")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/save",
|
||||
json={"path": "motd.txt", "new_path": "motd_de.txt", "content": "willkommen"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert not (overlay_dir / "motd.txt").exists()
|
||||
assert (overlay_dir / "motd_de.txt").read_text() == "willkommen"
|
||||
|
||||
|
||||
def test_save_rejects_oversized_content(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
|
||||
big = "x" * (1024 * 1024 + 1)
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/save",
|
||||
json={"path": "huge.txt", "content": big},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_save_rejects_dotdot_path(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/save",
|
||||
json={"path": "../escape.txt", "content": "x"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_save_404_for_non_files_overlay(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/save",
|
||||
json={"path": "motd.txt", "content": "x"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---- /upload -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_writes_file_at_target_path(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/upload",
|
||||
data={"target_path": "addons", "file": (io.BytesIO(b"vpk-bytes"), "map.vpk")},
|
||||
headers=_csrf_headers(),
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "addons" / "map.vpk").read_bytes() == b"vpk-bytes"
|
||||
|
||||
|
||||
def test_upload_with_relative_path_creates_intermediates(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/upload",
|
||||
data={
|
||||
"target_path": "addons",
|
||||
"relative_path": "sourcemod/configs/admins.cfg",
|
||||
"file": (io.BytesIO(b"#admins"), "admins.cfg"),
|
||||
},
|
||||
headers=_csrf_headers(),
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (
|
||||
overlay_dir / "addons" / "sourcemod" / "configs" / "admins.cfg"
|
||||
).read_bytes() == b"#admins"
|
||||
|
||||
|
||||
def test_upload_409_on_existing_without_overwrite(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_bytes(b"existing")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/upload",
|
||||
data={"target_path": "", "file": (io.BytesIO(b"new"), "motd.txt")},
|
||||
headers=_csrf_headers(),
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert (overlay_dir / "motd.txt").read_bytes() == b"existing"
|
||||
|
||||
|
||||
def test_upload_overwrite_replaces_existing(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_bytes(b"existing")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/upload",
|
||||
data={"target_path": "", "overwrite": "1", "file": (io.BytesIO(b"new"), "motd.txt")},
|
||||
headers=_csrf_headers(),
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "motd.txt").read_bytes() == b"new"
|
||||
|
||||
|
||||
# ---- /move ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_move_renames_file(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_text("hi")
|
||||
(overlay_dir / "addons").mkdir()
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/move",
|
||||
json={"src": "motd.txt", "dst": "addons/motd.txt"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert not (overlay_dir / "motd.txt").exists()
|
||||
assert (overlay_dir / "addons" / "motd.txt").read_text() == "hi"
|
||||
|
||||
|
||||
def test_move_409_on_existing_dst(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "motd.txt").write_text("a")
|
||||
(overlay_dir / "other.txt").write_text("b")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/move",
|
||||
json={"src": "motd.txt", "dst": "other.txt"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert (overlay_dir / "motd.txt").read_text() == "a"
|
||||
assert (overlay_dir / "other.txt").read_text() == "b"
|
||||
|
||||
|
||||
def test_move_rejects_cycle(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "addons" / "child").mkdir(parents=True)
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/move",
|
||||
json={"src": "addons", "dst": "addons/child/addons"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ---- /mkdir --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mkdir_creates_directory(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/mkdir",
|
||||
json={"path": "addons/sourcemod/configs"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "addons" / "sourcemod" / "configs").is_dir()
|
||||
|
||||
|
||||
def test_mkdir_idempotent_on_existing_dir(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "addons").mkdir()
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/mkdir",
|
||||
json={"path": "addons"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "addons").is_dir()
|
||||
|
||||
|
||||
def test_mkdir_409_when_path_is_file(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "blocker").write_text("x")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/mkdir",
|
||||
json={"path": "blocker"},
|
||||
headers=_csrf_headers(),
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ---- /delete -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_removes_file(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
target = overlay_dir / "motd.txt"
|
||||
target.write_text("hi")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/delete",
|
||||
data={"path": "motd.txt", "csrf_token": "test-token"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_delete_removes_empty_dir(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
target = overlay_dir / "empty-dir"
|
||||
target.mkdir()
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/delete",
|
||||
data={"path": "empty-dir", "csrf_token": "test-token"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_delete_409_on_non_empty_dir(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "addons" / "file.txt").parent.mkdir()
|
||||
(overlay_dir / "addons" / "file.txt").write_text("x")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/delete",
|
||||
data={"path": "addons", "csrf_token": "test-token"},
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert (overlay_dir / "addons" / "file.txt").exists()
|
||||
|
||||
|
||||
# ---- /replace -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_replace_overwrites_file(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "map.vpk").write_bytes(b"old-bytes")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/replace",
|
||||
data={
|
||||
"path": "map.vpk",
|
||||
"csrf_token": "test-token",
|
||||
"file": (io.BytesIO(b"new-bytes"), "map.vpk"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert (overlay_dir / "map.vpk").read_bytes() == b"new-bytes"
|
||||
|
||||
|
||||
def test_replace_with_new_path_renames(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "map.vpk").write_bytes(b"old")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.post(
|
||||
f"/overlays/{overlay_id}/files/replace",
|
||||
data={
|
||||
"path": "map.vpk",
|
||||
"new_path": "renamed.vpk",
|
||||
"csrf_token": "test-token",
|
||||
"file": (io.BytesIO(b"new"), "ignored"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert not (overlay_dir / "map.vpk").exists()
|
||||
assert (overlay_dir / "renamed.vpk").read_bytes() == b"new"
|
||||
|
||||
|
||||
# ---- /download_zip --------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_zip_streams_folder_contents(app, left4me_root: Path) -> None:
|
||||
import zipfile
|
||||
|
||||
user_id = _make_user()
|
||||
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||
(overlay_dir / "left4dead2" / "cfg").mkdir(parents=True)
|
||||
(overlay_dir / "left4dead2" / "cfg" / "server.cfg").write_text("hostname x")
|
||||
(overlay_dir / "left4dead2" / "cfg" / "motd.txt").write_text("welcome")
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
r = client.get(
|
||||
f"/overlays/{overlay_id}/files/download_zip?path=left4dead2/cfg"
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.headers["Content-Disposition"].startswith("attachment")
|
||||
body = io.BytesIO(r.get_data())
|
||||
with zipfile.ZipFile(body) as zf:
|
||||
names = sorted(zf.namelist())
|
||||
assert names == ["motd.txt", "server.cfg"]
|
||||
assert zf.read("motd.txt") == b"welcome"
|
||||
|
||||
|
||||
# ---- type-isolation across all new endpoints ------------------------------
|
||||
|
||||
|
||||
def test_mutating_endpoints_404_for_workshop_overlay(app, left4me_root: Path) -> None:
|
||||
user_id = _make_user()
|
||||
with session_scope() as s:
|
||||
overlay = Overlay(name="ws", path="", type="workshop", user_id=user_id)
|
||||
s.add(overlay)
|
||||
s.flush()
|
||||
overlay.path = str(overlay.id)
|
||||
overlay_id = overlay.id
|
||||
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
||||
|
||||
client = _client_for(app, user_id)
|
||||
headers = _csrf_headers()
|
||||
paths = [
|
||||
("post", f"/overlays/{overlay_id}/files/save", {"json": {"path": "x.txt", "content": "x"}}),
|
||||
("post", f"/overlays/{overlay_id}/files/replace",
|
||||
{"data": {"path": "x.bin", "csrf_token": "test-token", "file": (io.BytesIO(b"y"), "x.bin")},
|
||||
"content_type": "multipart/form-data"}),
|
||||
("post", f"/overlays/{overlay_id}/files/upload",
|
||||
{"data": {"target_path": "", "csrf_token": "test-token", "file": (io.BytesIO(b"y"), "x.bin")},
|
||||
"content_type": "multipart/form-data"}),
|
||||
("post", f"/overlays/{overlay_id}/files/move", {"json": {"src": "a", "dst": "b"}}),
|
||||
("post", f"/overlays/{overlay_id}/files/mkdir", {"json": {"path": "x"}}),
|
||||
("post", f"/overlays/{overlay_id}/files/delete",
|
||||
{"data": {"path": "x", "csrf_token": "test-token"}}),
|
||||
("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}),
|
||||
("get", f"/overlays/{overlay_id}/files/content?path=x.txt", {}),
|
||||
]
|
||||
for method, url, kwargs in paths:
|
||||
kwargs = dict(kwargs)
|
||||
if method == "post" and "json" in kwargs:
|
||||
kwargs["headers"] = headers
|
||||
r = getattr(client, method)(url, **kwargs)
|
||||
assert r.status_code == 404, f"{method.upper()} {url} returned {r.status_code}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue