From df1ccb4cca502f809fe6b411bdf5bfb6f7376a71 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 7 May 2026 16:50:54 +0200 Subject: [PATCH] feat(l4d2-web): workshop overlay UI (routes + templates) Adds workshop_routes blueprint with add-items / remove-item / manual- build endpoints plus admin /admin/workshop/refresh. Add-items handles single ID, single URL, multi-line batch, or a collection ID; auto- enqueues a coalesced build_overlay job per call. Reject non-L4D2 items with 400, duplicate associations with friendly toast, intruders with 403. Generalizes overlay_routes: type+name only on create (no path field); external is admin-only and system-wide, workshop is per-user and auto-pathed. Update is name-only. Delete recursively removes the on-disk dir only for managed paths (path == str(id)); legacy externals are left in place. The pre-existing in-use guard is preserved. Page routes filter the overlay listing by user permissions and load workshop items + the latest related job for the detail view. Templates: unified Create modal with type radio (no path field). Type-aware overlay detail: workshop overlays show a multi-line input + items/collection radio + item table partial with thumbnails, manual Rebuild button, and a small status indicator pulled from the latest related job. Admin page gets a "Refresh all workshop items" button. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/app.py | 2 + l4d2web/routes/overlay_routes.py | 120 ++++++--- l4d2web/routes/page_routes.py | 44 +++- l4d2web/routes/workshop_routes.py | 173 +++++++++++++ l4d2web/templates/_overlay_item_table.html | 50 ++++ l4d2web/templates/admin.html | 9 + l4d2web/templates/overlay_detail.html | 60 ++++- l4d2web/templates/overlays.html | 23 +- l4d2web/tests/test_overlays.py | 160 ++++++++---- l4d2web/tests/test_workshop_routes.py | 279 +++++++++++++++++++++ 10 files changed, 824 insertions(+), 96 deletions(-) create mode 100644 l4d2web/routes/workshop_routes.py create mode 100644 l4d2web/templates/_overlay_item_table.html create mode 100644 l4d2web/tests/test_workshop_routes.py diff --git a/l4d2web/app.py b/l4d2web/app.py index 34375ec..8ffd67a 100644 --- a/l4d2web/app.py +++ b/l4d2web/app.py @@ -16,6 +16,7 @@ from l4d2web.routes.log_routes import bp as log_bp from l4d2web.routes.overlay_routes import bp as overlay_bp from l4d2web.routes.page_routes import bp as page_bp from l4d2web.routes.server_routes import bp as server_bp +from l4d2web.routes.workshop_routes import bp as workshop_bp from l4d2web.services.job_worker import recover_stale_jobs, start_job_workers @@ -69,6 +70,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: app.before_request(load_current_user) app.register_blueprint(auth_bp) app.register_blueprint(overlay_bp) + app.register_blueprint(workshop_bp) app.register_blueprint(blueprint_bp) app.register_blueprint(server_bp) app.register_blueprint(job_bp) diff --git a/l4d2web/routes/overlay_routes.py b/l4d2web/routes/overlay_routes.py index 3e0d145..7ebedaf 100644 --- a/l4d2web/routes/overlay_routes.py +++ b/l4d2web/routes/overlay_routes.py @@ -1,72 +1,132 @@ +import shutil + from flask import Blueprint, Response, redirect, request from sqlalchemy import select -from l4d2web.auth import require_admin +from l4d2host.paths import get_left4me_root + +from l4d2web.auth import current_user, require_login from l4d2web.db import session_scope from l4d2web.models import BlueprintOverlay, Overlay -from l4d2web.services.security import validate_overlay_ref +from l4d2web.services.overlay_creation import ( + create_overlay_directory, + generate_overlay_path, +) bp = Blueprint("overlay", __name__) -@bp.post("/overlays") -@require_admin -def create_overlay() -> Response: - name = request.form.get("name", "").strip() - raw_path = request.form.get("path", "") - if not name or not raw_path: - return Response("missing fields", status=400) +VALID_TYPES = {"external", "workshop"} - try: - overlay_ref = validate_overlay_ref(raw_path) - except ValueError as exc: - return Response(str(exc), status=400) + +def _is_managed_path(overlay: Overlay) -> bool: + return overlay.path == str(overlay.id) + + +def _can_edit_overlay(overlay: Overlay, user) -> bool: + if user is None: + return False + if user.admin: + return True + if overlay.type == "external": + return False + if overlay.type == "workshop": + return overlay.user_id == user.id + return False + + +def _name_already_taken(db, name: str, scope_user_id: int | None, *, except_id: int | None = None) -> bool: + query = select(Overlay).where(Overlay.name == name) + if scope_user_id is None: + query = query.where(Overlay.user_id.is_(None)) + else: + query = query.where(Overlay.user_id == scope_user_id) + if except_id is not None: + query = query.where(Overlay.id != except_id) + return db.scalar(query) is not None + + +@bp.post("/overlays") +@require_login +def create_overlay() -> Response: + user = current_user() + assert user is not None + + name = request.form.get("name", "").strip() + overlay_type = request.form.get("type", "external").strip().lower() + if not name: + return Response("missing fields", status=400) + if overlay_type not in VALID_TYPES: + return Response(f"unknown overlay type: {overlay_type}", status=400) + + if overlay_type == "external": + if not user.admin: + return Response("admin only", status=403) + scope_user_id: int | None = None + else: # workshop + scope_user_id = user.id with session_scope() as db: - existing = db.scalar(select(Overlay).where(Overlay.name == name)) - if existing is not None: + if _name_already_taken(db, name, scope_user_id): return Response("overlay already exists", status=409) - db.add(Overlay(name=name, path=overlay_ref)) - return redirect("/overlays") + 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) + db.flush() + create_overlay_directory(overlay) + new_id = overlay.id + + return redirect(f"/overlays/{new_id}") @bp.post("/overlays/") -@require_admin +@require_login def update_overlay(overlay_id: int) -> Response: - name = request.form.get("name", "").strip() - raw_path = request.form.get("path", "") - if not name or not raw_path: - return Response("missing fields", status=400) + user = current_user() + assert user is not None - try: - overlay_ref = validate_overlay_ref(raw_path) - except ValueError as exc: - return Response(str(exc), status=400) + name = request.form.get("name", "").strip() + if not name: + return Response("missing fields", status=400) with session_scope() as db: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) - duplicate = db.scalar(select(Overlay).where(Overlay.name == name, Overlay.id != overlay_id)) - if duplicate is not None: + if not _can_edit_overlay(overlay, user): + return Response(status=403) + if _name_already_taken(db, name, overlay.user_id, except_id=overlay_id): return Response("overlay already exists", status=409) overlay.name = name - overlay.path = overlay_ref return redirect(f"/overlays/{overlay_id}") @bp.post("/overlays//delete") -@require_admin +@require_login def delete_overlay(overlay_id: int) -> Response: + user = current_user() + assert user is not None + with session_scope() as db: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) + if not _can_edit_overlay(overlay, user): + return Response(status=403) in_use = db.scalar(select(BlueprintOverlay).where(BlueprintOverlay.overlay_id == overlay_id)) if in_use is not None: return Response("overlay is in use", status=409) + path_value = overlay.path + path_is_managed = _is_managed_path(overlay) db.delete(overlay) + + if path_is_managed and path_value: + target = get_left4me_root() / "overlays" / path_value + if target.exists(): + shutil.rmtree(target) + return redirect("/overlays") diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index bbd3423..16f0f2f 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -6,7 +6,15 @@ from sqlalchemy import select from l4d2web.auth import current_user, require_admin, require_login from l4d2web.db import session_scope from l4d2web.models import Blueprint as BlueprintModel -from l4d2web.models import BlueprintOverlay, Job, Overlay, Server, User +from l4d2web.models import ( + BlueprintOverlay, + Job, + Overlay, + OverlayWorkshopItem, + Server, + User, + WorkshopItem, +) bp = Blueprint("pages", __name__) @@ -141,28 +149,60 @@ def server_jobs_page(server_id: int): @bp.get("/overlays") @require_login def overlays() -> str: + user = current_user() + assert user is not None with session_scope() as db: - overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all() + query = select(Overlay).order_by(Overlay.name) + if not user.admin: + query = query.where( + (Overlay.type == "external") | (Overlay.user_id == user.id) + ) + overlays = db.scalars(query).all() return render_template("overlays.html", overlays=overlays) @bp.get("/overlays/") @require_login def overlay_detail(overlay_id: int): + user = current_user() + assert user is not None with session_scope() as db: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) + # Visibility: externals are visible to all; workshop overlays are + # visible to the owner and admins. + if overlay.type == "workshop" and not user.admin and overlay.user_id != user.id: + return Response(status=403) using_blueprints = db.scalars( select(BlueprintModel) .join(BlueprintOverlay, BlueprintOverlay.blueprint_id == BlueprintModel.id) .where(BlueprintOverlay.overlay_id == overlay.id) .order_by(BlueprintModel.name) ).all() + workshop_items = [] + if overlay.type == "workshop": + workshop_items = db.scalars( + select(WorkshopItem) + .join( + OverlayWorkshopItem, + OverlayWorkshopItem.workshop_item_id == WorkshopItem.id, + ) + .where(OverlayWorkshopItem.overlay_id == overlay.id) + .order_by(WorkshopItem.created_at) + ).all() + latest_build_job = db.scalar( + select(Job) + .where(Job.operation == "build_overlay", Job.overlay_id == overlay.id) + .order_by(Job.created_at.desc()) + .limit(1) + ) return render_template( "overlay_detail.html", overlay=overlay, using_blueprints=using_blueprints, + workshop_items=workshop_items, + latest_build_job=latest_build_job, ) diff --git a/l4d2web/routes/workshop_routes.py b/l4d2web/routes/workshop_routes.py new file mode 100644 index 0000000..7685a22 --- /dev/null +++ b/l4d2web/routes/workshop_routes.py @@ -0,0 +1,173 @@ +"""Routes for the workshop overlay type (add/remove items, manual rebuild, +admin global refresh).""" +from __future__ import annotations + +from flask import Blueprint, Response, redirect, render_template, request +from sqlalchemy import delete as sa_delete +from sqlalchemy import select + +from l4d2web.auth import current_user, require_admin, require_login +from l4d2web.db import session_scope +from l4d2web.models import ( + Job, + Overlay, + OverlayWorkshopItem, + WorkshopItem, +) +from l4d2web.services import steam_workshop +from l4d2web.services.job_worker import enqueue_build_overlay + + +bp = Blueprint("workshop", __name__) + + +def _check_workshop_overlay_access(overlay_id: int, user, db): + overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) + if overlay is None: + return None, Response(status=404) + if overlay.type != "workshop": + return None, Response("not a workshop overlay", status=400) + if overlay.user_id != user.id and not user.admin: + return None, Response(status=403) + return overlay, None + + +def _render_item_table(overlay_id: int): + with session_scope() as db: + overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) + items = db.scalars( + select(WorkshopItem) + .join( + OverlayWorkshopItem, + OverlayWorkshopItem.workshop_item_id == WorkshopItem.id, + ) + .where(OverlayWorkshopItem.overlay_id == overlay_id) + .order_by(WorkshopItem.created_at) + ).all() + # Detach so attributes survive after the session closes. + for item in items: + db.expunge(item) + if overlay is not None: + db.expunge(overlay) + return render_template( + "_overlay_item_table.html", + overlay=overlay, + workshop_items=items, + ) + + +@bp.post("/overlays//items") +@require_login +def add_items(overlay_id: int) -> Response: + user = current_user() + assert user is not None + + raw_input = request.form.get("input", "").strip() + mode = request.form.get("input_mode", "items") + if not raw_input: + return Response("missing input", status=400) + + try: + ids = steam_workshop.parse_workshop_input(raw_input) + except ValueError as exc: + return Response(str(exc), status=400) + + if mode == "collection": + if len(ids) != 1: + return Response("collection mode expects exactly one id or url", status=400) + try: + ids = steam_workshop.resolve_collection(ids[0]) + except Exception as exc: + return Response(f"failed to resolve collection: {exc}", status=502) + if not ids: + return Response("collection has no items", status=400) + + try: + metas = steam_workshop.fetch_metadata_batch(ids, mode="add") + except steam_workshop.WorkshopValidationError as exc: + return Response(str(exc), status=400) + except Exception as exc: + return Response(f"steam api error: {exc}", status=502) + + with session_scope() as db: + overlay, err = _check_workshop_overlay_access(overlay_id, user, db) + if err is not None: + return err + for meta in metas: + wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == meta.steam_id)) + if wi is None: + wi = WorkshopItem(steam_id=meta.steam_id) + db.add(wi) + wi.title = meta.title + wi.filename = meta.filename + wi.file_url = meta.file_url + wi.file_size = meta.file_size + wi.time_updated = meta.time_updated + wi.preview_url = meta.preview_url + wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}" + db.flush() + + existing = db.scalar( + select(OverlayWorkshopItem).where( + OverlayWorkshopItem.overlay_id == overlay_id, + OverlayWorkshopItem.workshop_item_id == wi.id, + ) + ) + if existing is None: + db.add(OverlayWorkshopItem(overlay_id=overlay_id, workshop_item_id=wi.id)) + + enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + + return _render_item_table(overlay_id) + + +@bp.post("/overlays//items//delete") +@require_login +def remove_item(overlay_id: int, item_id: int) -> Response: + user = current_user() + assert user is not None + with session_scope() as db: + overlay, err = _check_workshop_overlay_access(overlay_id, user, db) + if err is not None: + return err + result = db.execute( + sa_delete(OverlayWorkshopItem).where( + OverlayWorkshopItem.overlay_id == overlay_id, + OverlayWorkshopItem.workshop_item_id == item_id, + ) + ) + if result.rowcount == 0: + return Response(status=404) + enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + return _render_item_table(overlay_id) + + +@bp.post("/overlays//build") +@require_login +def manual_build(overlay_id: int) -> Response: + user = current_user() + assert user is not None + with session_scope() as db: + overlay, err = _check_workshop_overlay_access(overlay_id, user, db) + if err is not None: + return err + job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + job_id = job.id + return redirect(f"/jobs/{job_id}") + + +@bp.post("/admin/workshop/refresh") +@require_admin +def admin_refresh() -> Response: + user = current_user() + assert user is not None + with session_scope() as db: + db.add( + Job( + user_id=user.id, + server_id=None, + operation="refresh_workshop_items", + state="queued", + ) + ) + return redirect("/admin/jobs") diff --git a/l4d2web/templates/_overlay_item_table.html b/l4d2web/templates/_overlay_item_table.html new file mode 100644 index 0000000..20d1130 --- /dev/null +++ b/l4d2web/templates/_overlay_item_table.html @@ -0,0 +1,50 @@ +{% set can_edit = g.user.admin or (overlay and overlay.type == 'workshop' and overlay.user_id == g.user.id) %} + + + + + + + + + + + {% if can_edit %}{% endif %} + + + + {% for item in workshop_items %} + + + + + + + + + {% if can_edit %} + + {% endif %} + + {% else %} + + {% endfor %} + +
Steam IDTitleFilenameSizeUpdatedStatus
+ {% if item.preview_url %} + + {% endif %} + {{ item.steam_id }}{{ item.title }}{{ item.filename }}{{ item.file_size }}{{ item.time_updated }} + {% if item.last_error %} + {{ item.last_error }} + {% elif item.last_downloaded_at %} + cached + {% else %} + pending + {% endif %} + +
+ + +
+
No workshop items yet.
diff --git a/l4d2web/templates/admin.html b/l4d2web/templates/admin.html index c7541aa..f0eea62 100644 --- a/l4d2web/templates/admin.html +++ b/l4d2web/templates/admin.html @@ -19,4 +19,13 @@ + +
+

Workshop

+

Re-fetch metadata for every workshop item; re-download those with newer versions on Steam, then enqueue rebuilds for the affected overlays.

+
+ + +
+
{% endblock %} diff --git a/l4d2web/templates/overlay_detail.html b/l4d2web/templates/overlay_detail.html index 439e250..8a5c64a 100644 --- a/l4d2web/templates/overlay_detail.html +++ b/l4d2web/templates/overlay_detail.html @@ -6,30 +6,74 @@

Overlay: {{ overlay.name }}

- {% if g.user.admin %} + {% set can_edit = g.user.admin or (overlay.type == 'workshop' and overlay.user_id == g.user.id) %} + {% if can_edit %} {% endif %}
- {% if g.user.admin %} + {% if can_edit %}
-
- {% else %} + {% endif %} + - - + + +
Name{{ overlay.name }}
Path{{ overlay.path }}
Type{{ overlay.type }}
Scope{% if overlay.user_id %}private{% else %}system{% endif %}
Path{{ overlay.path }}
- {% endif %}
+{% if overlay.type == 'workshop' %} +
+
+

Workshop items

+ {% if can_edit %} +
+ + +
+ {% endif %} +
+ + {% if can_edit %} +
+ +
+ Input mode + + +
+ +
+ +
+
+ {% endif %} + +
+ {% include "_overlay_item_table.html" with context %} +
+
+ +{% if latest_build_job %} +
+

Latest build

+

+ job #{{ latest_build_job.id }} + — state: {{ latest_build_job.state }} +

+
+{% endif %} +{% endif %} +

Used by

{% if using_blueprints %} @@ -43,7 +87,7 @@ {% endif %}
-{% if g.user.admin %} +{% if can_edit %}