diff --git a/deploy/deploy-test-server.sh b/deploy/deploy-test-server.sh index 2989a28..8cebe84 100755 --- a/deploy/deploy-test-server.sh +++ b/deploy/deploy-test-server.sh @@ -258,6 +258,16 @@ print(\" \".join(str(r[0]) for r in c.execute(\"SELECT id FROM overlays\"))) $sudo_cmd chown left4me:left4me "$overlay_orphan_sentinel" fi +# Seed example script overlays (cedapug, l4d2center, competitive_rework, ...) +# as system-wide rows from /opt/left4me/examples/script-overlays/. Idempotent: +# subsequent deploys refresh the script body in place, leaving the row id and +# overlay directory intact. +run_left4me_with_env env \ + JOB_WORKER_ENABLED=false \ + PYTHONPATH=/opt/left4me \ + /opt/left4me/.venv/bin/flask --app l4d2web.app:create_app \ + seed-script-overlays /opt/left4me/examples/script-overlays + $sudo_cmd systemctl daemon-reload $sudo_cmd systemctl enable --now left4me-web.service $sudo_cmd systemctl restart left4me-web.service diff --git a/examples/script-overlays/cedapug_maps.sh b/examples/script-overlays/cedapug_maps.sh new file mode 100644 index 0000000..88a81c1 --- /dev/null +++ b/examples/script-overlays/cedapug_maps.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Cedapug custom-maps overlay. +# Scrapes the JS map list at https://cedapug.com/custom and keeps +# $OVERLAY/left4dead2/addons/ in sync. Re-downloads only when the zip's +# Last-Modified header changed. +set -euo pipefail + +CEDAPUG_HOST="https://cedapug.com" +LIST_URL="$CEDAPUG_HOST/custom" +ADDONS_DIR="$OVERLAY/left4dead2/addons" +STATE_DIR="$OVERLAY/.cedapug" +WORK_DIR="$STATE_DIR/work" +MANIFEST="$STATE_DIR/manifest.tsv" # zip_basename \t last_modified \t vpk1,vpk2,... + +mkdir -p "$ADDONS_DIR" "$STATE_DIR" "$WORK_DIR" + +# --- 1. Discover the current zip list ----------------------------------- +# The page embeds the map list inside renderCustomMapDownloads([...]) with +# JSON-escaped slashes (\/maps\/foo.zip). Unescape with sed before grepping. +echo ":: fetching $LIST_URL" +mapfile -t ZIP_PATHS < <( + curl -fsSL --retry 3 --retry-delay 2 "$LIST_URL" \ + | sed 's@\\/@/@g' \ + | grep -oE '"/maps/[A-Za-z0-9_.+-]+\.zip"' \ + | tr -d '"' \ + | sort -u +) +if [[ ${#ZIP_PATHS[@]} -eq 0 ]]; then + echo "!! no map zips found on $LIST_URL — page format may have changed" >&2 + exit 1 +fi +echo ":: ${#ZIP_PATHS[@]} custom map zip(s) listed" + +# --- 2. Load previous manifest ------------------------------------------ +declare -A PREV_LM PREV_VPKS +if [[ -f "$MANIFEST" ]]; then + while IFS=$'\t' read -r zname lm vpks; do + [[ -n "$zname" ]] || continue + PREV_LM[$zname]=$lm + PREV_VPKS[$zname]=$vpks + done < "$MANIFEST" +fi + +# --- 3. Download / extract each desired zip ----------------------------- +declare -A NEW_LM NEW_VPKS +for zpath in "${ZIP_PATHS[@]}"; do + zname="${zpath##*/}" + url="$CEDAPUG_HOST$zpath" + + lm="$(curl -fsSI --retry 3 --retry-delay 2 "$url" \ + | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {sub(/^[^:]*:[[:space:]]*/, ""); gsub(/\r/, ""); print; exit}')" + NEW_LM[$zname]=$lm + + prev_lm="${PREV_LM[$zname]:-}" + prev_vpks="${PREV_VPKS[$zname]:-}" + + # Skip if Last-Modified matches AND every recorded vpk is still on disk. + if [[ -n "$lm" && "$lm" == "$prev_lm" && -n "$prev_vpks" ]]; then + all_present=1 + IFS=',' read -r -a v_arr <<<"$prev_vpks" + for v in "${v_arr[@]}"; do + [[ -f "$ADDONS_DIR/$v" ]] || { all_present=0; break; } + done + if (( all_present )); then + echo ":: $zname unchanged (Last-Modified: $lm) — skipped" + NEW_VPKS[$zname]=$prev_vpks + continue + fi + fi + + echo ":: downloading $zname" + zfile="$WORK_DIR/$zname" + curl -fSL --retry 3 --retry-delay 2 -o "$zfile" "$url" + + 7z x -y -bd -o"$ADDONS_DIR" "$zfile" '*.vpk' >/dev/null + + vpk_list="$(7z l -slt -ba "$zfile" \ + | awk '/^Path = .*\.vpk$/ {sub(/^Path = /, ""); print}' \ + | paste -sd, -)" + if [[ -z "$vpk_list" ]]; then + echo "!! warning: $zname contained no .vpk — leaving manifest entry empty" >&2 + fi + NEW_VPKS[$zname]=$vpk_list + + rm -f "$zfile" +done + +# --- 4. Prune vpks from zips that disappeared from the listing ---------- +for old_zname in "${!PREV_VPKS[@]}"; do + if [[ -z "${NEW_LM[$old_zname]:-}" ]]; then + IFS=',' read -r -a old_v_arr <<<"${PREV_VPKS[$old_zname]}" + for v in "${old_v_arr[@]}"; do + if [[ -n "$v" && -f "$ADDONS_DIR/$v" ]]; then + echo ":: pruning $v (zip $old_zname no longer listed)" + rm -f "$ADDONS_DIR/$v" + fi + done + fi +done + +# --- 5. Rewrite manifest ------------------------------------------------- +tmp_manifest="$(mktemp -p "$STATE_DIR")" +for zname in "${!NEW_LM[@]}"; do + printf '%s\t%s\t%s\n' "$zname" "${NEW_LM[$zname]}" "${NEW_VPKS[$zname]:-}" >>"$tmp_manifest" +done +mv -f "$tmp_manifest" "$MANIFEST" + +rm -rf "$WORK_DIR" +echo ":: done — ${#NEW_LM[@]} zip(s) tracked, addons/ in sync" diff --git a/examples/script-overlays/competitive_rework.sh b/examples/script-overlays/competitive_rework.sh new file mode 100644 index 0000000..b5412d2 --- /dev/null +++ b/examples/script-overlays/competitive_rework.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# L4D2 Competitive Rework overlay. +# Pulls the master branch of SirPlease/L4D2-Competitive-Rework as a tarball +# and extracts it into $OVERLAY/left4dead2/. Skipped on subsequent rebuilds +# once cfg/cfgogl/ is present — wipe the overlay to force a refresh. +# https://github.com/SirPlease/L4D2-Competitive-Rework +set -xeuo pipefail + +DEST="$OVERLAY/left4dead2" +mkdir -p "$DEST" + +if [[ ! -d "$DEST/cfg/cfgogl" ]]; then + curl -fsSL https://github.com/SirPlease/L4D2-Competitive-Rework/archive/refs/heads/master.tar.gz \ + | tar -xz --strip-components=1 -C "$DEST" +fi diff --git a/examples/script-overlays/l4d2center_maps.sh b/examples/script-overlays/l4d2center_maps.sh new file mode 100644 index 0000000..6a7cc5e --- /dev/null +++ b/examples/script-overlays/l4d2center_maps.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -xeuo pipefail + +mkdir -p /overlay/left4dead2/addons +cd /overlay/left4dead2/addons + +for cmd in curl md5sum 7z; do + command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 1; } +done + +CSV_URL="https://l4d2center.com/maps/servers/index.csv" +TEMP_CSV=$(mktemp) +trap 'rm -f "$TEMP_CSV"' EXIT +curl -fsSL -o "$TEMP_CSV" "$CSV_URL" + +declare -A map_md5 map_links +{ + IFS= read -r _header + while IFS=';' read -r Name _Size MD5 DownloadLink || [[ $Name ]]; do + Name=$(echo "$Name" | xargs) + MD5=$(echo "$MD5" | xargs) + DownloadLink=$(echo "$DownloadLink" | xargs) + map_md5["$Name"]="$MD5" + map_links["$Name"]="$DownloadLink" + done +} < "$TEMP_CSV" + +shopt -s nullglob +for file in *.vpk; do + if [[ -z "${map_md5[$file]:-}" ]]; then + rm -f "$file" + elif [[ "$(md5sum "$file" | awk '{print $1}')" != "${map_md5[$file]}" ]]; then + rm -f "$file" + fi +done + +for vpk in "${!map_md5[@]}"; do + [[ -f "$vpk" ]] && continue + url="${map_links[$vpk]}" + [[ -n "$url" ]] || continue + encoded=$(printf '%s' "$url" | sed 's/ /%20/g') + TEMP_7Z=$(mktemp --suffix=.7z) + curl -fsSL -o "$TEMP_7Z" "$encoded" + 7z x -y "$TEMP_7Z" + rm -f "$TEMP_7Z" +done + +echo "Synchronization complete." diff --git a/examples/script-overlays/tickrate.sh b/examples/script-overlays/tickrate.sh new file mode 100644 index 0000000..5765935 --- /dev/null +++ b/examples/script-overlays/tickrate.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Tickrate overlay: server.cfg with high-rate settings + the tickrate_enabler +# addon (DLL/SO/VDF) from SirPlease/L4D2-Competitive-Rework. +# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69 +# https://www.programmersought.com/article/513810199514/ +set -xeuo pipefail + +CFG_DIR="$OVERLAY/left4dead2/cfg" +ADDONS_DIR="$OVERLAY/left4dead2/addons" +mkdir -p "$CFG_DIR" "$ADDONS_DIR" + +cat <<'EOF' > "$CFG_DIR/server.cfg" +# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69 +sv_minrate 100000 +sv_maxrate 100000 +nb_update_frequency 0.014 +net_splitpacket_maxrate 50000 +net_maxcleartime 0.0001 +fps_max 0 +EOF + +for file in tickrate_enabler.dll tickrate_enabler.so tickrate_enabler.vdf; do + curl -fsSL "https://github.com/SirPlease/L4D2-Competitive-Rework/raw/refs/heads/master/addons/${file}" \ + -o "$ADDONS_DIR/${file}" +done diff --git a/l4d2web/cli.py b/l4d2web/cli.py index a9495be..8ac84e3 100644 --- a/l4d2web/cli.py +++ b/l4d2web/cli.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import click from sqlalchemy.exc import IntegrityError @@ -6,7 +7,11 @@ from sqlalchemy import select from l4d2web.auth import hash_password from l4d2web.db import session_scope -from l4d2web.models import User +from l4d2web.models import Overlay, User +from l4d2web.services.overlay_creation import ( + create_overlay_directory, + generate_overlay_path, +) @click.command("promote-admin") @@ -41,6 +46,50 @@ def create_user(username: str, admin: bool) -> None: click.echo(f"created user {username}") +@click.command("seed-script-overlays") +@click.argument( + "directory", + type=click.Path(exists=True, file_okay=False, path_type=Path), +) +def seed_script_overlays(directory: Path) -> None: + """Upsert one system-wide script overlay per *.sh file in DIRECTORY. + + Overlay name = filename stem; user_id stays NULL. Existing rows by name + have their script refreshed in place. Hard-errors if a name collides + with a non-script overlay. + """ + sh_files = sorted(p for p in directory.glob("*.sh") if p.stem) + if not sh_files: + click.echo(f"no *.sh files in {directory}", err=True) + return + + with session_scope() as db: + for sh in sh_files: + name = sh.stem + content = sh.read_text() + existing = db.scalar( + select(Overlay).where(Overlay.name == name, Overlay.user_id.is_(None)) + ) + if existing is not None: + if existing.type != "script": + raise click.ClickException( + f"overlay {name!r} exists but is type={existing.type!r}, not script" + ) + existing.script = content + click.echo(f"updated {name} (id={existing.id})") + else: + overlay = Overlay( + name=name, path="", type="script", user_id=None, script=content + ) + db.add(overlay) + db.flush() + overlay.path = generate_overlay_path(overlay.id) + db.flush() + create_overlay_directory(overlay) + click.echo(f"created {name} (id={overlay.id})") + + def register_cli(app) -> None: app.cli.add_command(promote_admin) app.cli.add_command(create_user) + app.cli.add_command(seed_script_overlays) diff --git a/l4d2web/tests/test_seed_script_overlays.py b/l4d2web/tests/test_seed_script_overlays.py new file mode 100644 index 0000000..d7765f7 --- /dev/null +++ b/l4d2web/tests/test_seed_script_overlays.py @@ -0,0 +1,79 @@ +from pathlib import Path + +from l4d2web.app import create_app +from l4d2web.db import init_db, session_scope +from l4d2web.models import Overlay + + +def _make_app(tmp_path: Path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'seed.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + return app + + +def test_seed_creates_system_wide_script_overlays(tmp_path: Path, monkeypatch) -> None: + app = _make_app(tmp_path, monkeypatch) + examples = tmp_path / "examples" + examples.mkdir() + (examples / "alpha.sh").write_text("#!/bin/bash\necho alpha\n") + (examples / "beta.sh").write_text("#!/bin/bash\necho beta\n") + + result = app.test_cli_runner().invoke(args=["seed-script-overlays", str(examples)]) + + assert result.exit_code == 0, result.output + with session_scope() as db: + rows = db.query(Overlay).order_by(Overlay.name).all() + assert [(o.name, o.type, o.user_id, o.script.strip()) for o in rows] == [ + ("alpha", "script", None, "#!/bin/bash\necho alpha"), + ("beta", "script", None, "#!/bin/bash\necho beta"), + ] + for o in rows: + assert (tmp_path / "overlays" / o.path).is_dir() + + +def test_seed_refreshes_existing_script_overlay(tmp_path: Path, monkeypatch) -> None: + app = _make_app(tmp_path, monkeypatch) + examples = tmp_path / "examples" + examples.mkdir() + (examples / "alpha.sh").write_text("v1\n") + + runner = app.test_cli_runner() + runner.invoke(args=["seed-script-overlays", str(examples)]) + (examples / "alpha.sh").write_text("v2\n") + result = runner.invoke(args=["seed-script-overlays", str(examples)]) + + assert result.exit_code == 0, result.output + with session_scope() as db: + rows = db.query(Overlay).filter(Overlay.name == "alpha").all() + assert len(rows) == 1 + assert rows[0].script.strip() == "v2" + + +def test_seed_errors_on_type_collision(tmp_path: Path, monkeypatch) -> None: + app = _make_app(tmp_path, monkeypatch) + examples = tmp_path / "examples" + examples.mkdir() + (examples / "shared.sh").write_text("#!/bin/bash\n") + + with session_scope() as db: + db.add(Overlay(name="shared", path="shared", type="workshop", user_id=None)) + + result = app.test_cli_runner().invoke(args=["seed-script-overlays", str(examples)]) + + assert result.exit_code != 0 + assert "not script" in result.output + + +def test_seed_no_files_is_noop(tmp_path: Path, monkeypatch) -> None: + app = _make_app(tmp_path, monkeypatch) + examples = tmp_path / "examples" + examples.mkdir() + + result = app.test_cli_runner().invoke(args=["seed-script-overlays", str(examples)]) + + assert result.exit_code == 0 + with session_scope() as db: + assert db.query(Overlay).count() == 0