Adds two managed system overlays (l4d2center-maps, cedapug-maps) that fetch curated map archives from upstream sources and reconcile addons symlinks for non-Steam maps. A daily systemd timer enqueues a coalesced refresh_global_overlays worker job; downloads, extraction, and rebuilds run in the existing job worker and surface in the job log UI. Schema: GlobalOverlaySource / GlobalOverlayItem / GlobalOverlayItemFile plus nullable Job.user_id so system jobs render as "system" in the UI. The new builder reconciles symlinks against the per-source vpk cache and leaves foreign symlinks untouched. Initialize-time guard refuses to mount a partial overlay if any expected vpk is missing from cache. Refresh service uses shutil.move to handle EXDEV when /tmp and the cache live on different filesystems. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
5.6 KiB
Python
163 lines
5.6 KiB
Python
import json
|
|
|
|
from flask import Blueprint, Response, jsonify, redirect, request
|
|
from sqlalchemy import delete, func, select
|
|
|
|
from l4d2web.auth import current_user, require_login
|
|
from l4d2web.db import session_scope
|
|
from l4d2web.models import Blueprint as BlueprintModel
|
|
from l4d2web.models import BlueprintOverlay, Overlay, Server
|
|
|
|
|
|
bp = Blueprint("blueprint", __name__)
|
|
|
|
|
|
def split_textarea_lines(raw: str) -> list[str]:
|
|
return [line.strip() for line in raw.splitlines() if line.strip()]
|
|
|
|
|
|
def ordered_overlay_ids_from_form() -> list[int]:
|
|
ordered = []
|
|
for fallback_position, value in enumerate(request.form.getlist("overlay_ids")):
|
|
if not value:
|
|
continue
|
|
overlay_id = int(value)
|
|
raw_position = request.form.get(f"overlay_position_{overlay_id}", "").strip()
|
|
try:
|
|
position = int(raw_position)
|
|
except ValueError:
|
|
position = fallback_position + 1
|
|
ordered.append((position, fallback_position, overlay_id))
|
|
return [overlay_id for _, _, overlay_id in sorted(ordered)]
|
|
|
|
|
|
def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None:
|
|
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id))
|
|
for position, overlay_id in enumerate(overlay_ids):
|
|
db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position))
|
|
|
|
|
|
def overlay_ids_authorized(db, overlay_ids: list[int], user_id: int) -> bool:
|
|
unique_ids = set(overlay_ids)
|
|
if not unique_ids:
|
|
return True
|
|
allowed_count = db.scalar(
|
|
select(func.count(Overlay.id)).where(
|
|
Overlay.id.in_(unique_ids),
|
|
Overlay.user_id.is_(None) | (Overlay.user_id == user_id),
|
|
)
|
|
)
|
|
return allowed_count == len(unique_ids)
|
|
|
|
|
|
@bp.post("/blueprints")
|
|
@require_login
|
|
def create_blueprint() -> Response:
|
|
user = current_user()
|
|
assert user is not None
|
|
|
|
if request.is_json:
|
|
payload = request.get_json(silent=True) or {}
|
|
name = str(payload.get("name", "")).strip()
|
|
arguments = [str(item) for item in payload.get("arguments", [])]
|
|
config = [str(item) for item in payload.get("config", [])]
|
|
overlay_ids = [int(item) for item in payload.get("overlay_ids", [])]
|
|
json_response = True
|
|
else:
|
|
name = request.form.get("name", "").strip()
|
|
arguments = split_textarea_lines(request.form.get("arguments", ""))
|
|
config = split_textarea_lines(request.form.get("config", ""))
|
|
overlay_ids = ordered_overlay_ids_from_form()
|
|
json_response = False
|
|
|
|
if not name:
|
|
return Response("name is required", status=400)
|
|
|
|
with session_scope() as db:
|
|
if not overlay_ids_authorized(db, overlay_ids, user.id):
|
|
return Response("overlay not authorized", status=403)
|
|
blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
|
|
db.add(blueprint)
|
|
db.flush()
|
|
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
|
|
blueprint_id = blueprint.id
|
|
|
|
if json_response:
|
|
return jsonify({"id": blueprint_id}), 201
|
|
return redirect(f"/blueprints/{blueprint_id}")
|
|
|
|
|
|
@bp.post("/blueprints/<int:blueprint_id>")
|
|
@require_login
|
|
def update_blueprint_form(blueprint_id: int) -> Response:
|
|
user = current_user()
|
|
assert user is not None
|
|
name = request.form.get("name", "").strip()
|
|
if not name:
|
|
return Response("name is required", status=400)
|
|
|
|
with session_scope() as db:
|
|
blueprint = db.scalar(
|
|
select(BlueprintModel).where(BlueprintModel.id == blueprint_id, BlueprintModel.user_id == user.id)
|
|
)
|
|
if blueprint is None:
|
|
return Response(status=404)
|
|
overlay_ids = ordered_overlay_ids_from_form()
|
|
if not overlay_ids_authorized(db, overlay_ids, user.id):
|
|
return Response("overlay not authorized", status=403)
|
|
|
|
blueprint.name = name
|
|
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
|
|
blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", "")))
|
|
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
|
|
|
|
return redirect(f"/blueprints/{blueprint_id}")
|
|
|
|
|
|
def _delete_blueprint(db, user_id: int, blueprint_id: int) -> Response | None:
|
|
blueprint = db.scalar(
|
|
select(BlueprintModel).where(
|
|
BlueprintModel.id == blueprint_id,
|
|
BlueprintModel.user_id == user_id,
|
|
)
|
|
)
|
|
if blueprint is None:
|
|
return Response(status=404)
|
|
|
|
linked_count = db.scalar(
|
|
select(func.count(Server.id)).where(Server.blueprint_id == blueprint.id)
|
|
) or 0
|
|
if linked_count > 0:
|
|
return Response("blueprint is in use", status=409)
|
|
|
|
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint.id))
|
|
db.delete(blueprint)
|
|
return None
|
|
|
|
|
|
@bp.delete("/blueprints/<int:blueprint_id>")
|
|
@require_login
|
|
def delete_blueprint(blueprint_id: int) -> Response:
|
|
user = current_user()
|
|
assert user is not None
|
|
|
|
with session_scope() as db:
|
|
error = _delete_blueprint(db, user.id, blueprint_id)
|
|
if error is not None:
|
|
return error
|
|
|
|
return Response(status=204)
|
|
|
|
|
|
@bp.post("/blueprints/<int:blueprint_id>/delete")
|
|
@require_login
|
|
def delete_blueprint_form(blueprint_id: int) -> Response:
|
|
user = current_user()
|
|
assert user is not None
|
|
|
|
with session_scope() as db:
|
|
error = _delete_blueprint(db, user.id, blueprint_id)
|
|
if error is not None:
|
|
return error
|
|
|
|
return redirect("/blueprints")
|