From d0614b90fb1189ae45bb87d6d4d5017a5c0ffd08 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 23 Apr 2026 01:08:41 +0200 Subject: [PATCH] feat(l4d2-web): add admin overlay catalog CRUD with path validation --- components/l4d2-web-app/src/l4d2web/app.py | 2 + .../src/l4d2web/routes/overlay_routes.py | 32 +++++++++++++++ .../src/l4d2web/services/__init__.py | 0 .../src/l4d2web/services/security.py | 11 +++++ .../l4d2-web-app/tests/test_overlays.py | 41 +++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py create mode 100644 components/l4d2-web-app/src/l4d2web/services/__init__.py create mode 100644 components/l4d2-web-app/src/l4d2web/services/security.py create mode 100644 components/l4d2-web-app/tests/test_overlays.py diff --git a/components/l4d2-web-app/src/l4d2web/app.py b/components/l4d2-web-app/src/l4d2web/app.py index ea1417e..96e2456 100644 --- a/components/l4d2-web-app/src/l4d2web/app.py +++ b/components/l4d2-web-app/src/l4d2web/app.py @@ -7,6 +7,7 @@ from l4d2web.cli import register_cli from l4d2web.config import DEFAULT_CONFIG from l4d2web.db import init_db from l4d2web.routes.auth_routes import bp as auth_bp +from l4d2web.routes.overlay_routes import bp as overlay_bp def create_app(test_config: dict[str, object] | None = None) -> Flask: @@ -20,6 +21,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) register_cli(app) @app.get("/health") diff --git a/components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py b/components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py new file mode 100644 index 0000000..450fa45 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py @@ -0,0 +1,32 @@ +from flask import Blueprint, Response, redirect, request +from sqlalchemy import select + +from l4d2web.auth import require_admin +from l4d2web.db import session_scope +from l4d2web.models import Overlay +from l4d2web.services.security import validate_overlay_path + + +bp = Blueprint("overlay", __name__) + + +@bp.post("/admin/overlays") +@require_admin +def create_overlay() -> Response: + name = request.form.get("name", "").strip() + raw_path = request.form.get("path", "").strip() + if not name or not raw_path: + return Response("missing fields", status=400) + + try: + validated_path = validate_overlay_path(raw_path) + except ValueError as exc: + return Response(str(exc), status=400) + + with session_scope() as db: + existing = db.scalar(select(Overlay).where(Overlay.name == name)) + if existing is not None: + return Response("overlay already exists", status=409) + db.add(Overlay(name=name, path=str(validated_path))) + + return redirect("/admin/overlays") diff --git a/components/l4d2-web-app/src/l4d2web/services/__init__.py b/components/l4d2-web-app/src/l4d2web/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/l4d2-web-app/src/l4d2web/services/security.py b/components/l4d2-web-app/src/l4d2web/services/security.py new file mode 100644 index 0000000..5a6713d --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/services/security.py @@ -0,0 +1,11 @@ +from pathlib import Path + + +OVERLAY_ROOT = Path("/opt/l4d2/overlays").resolve() + + +def validate_overlay_path(raw: str) -> Path: + path = Path(raw).resolve() + if OVERLAY_ROOT not in path.parents and path != OVERLAY_ROOT: + raise ValueError("overlay path must be under /opt/l4d2/overlays") + return path diff --git a/components/l4d2-web-app/tests/test_overlays.py b/components/l4d2-web-app/tests/test_overlays.py new file mode 100644 index 0000000..5de62ca --- /dev/null +++ b/components/l4d2-web-app/tests/test_overlays.py @@ -0,0 +1,41 @@ +import pytest + +from l4d2web.app import create_app +from l4d2web.auth import hash_password +from l4d2web.db import init_db, session_scope +from l4d2web.models import User + + +@pytest.fixture +def admin_client(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'overlay.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 session: + admin = User(username="admin", password_digest=hash_password("secret"), admin=True) + session.add(admin) + session.flush() + admin_id = admin.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = admin_id + return client + + +def test_admin_can_create_overlay(admin_client) -> None: + response = admin_client.post( + "/admin/overlays", + data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, + ) + assert response.status_code == 302 + + +def test_overlay_path_must_be_under_root(admin_client) -> None: + response = admin_client.post( + "/admin/overlays", + data={"name": "bad", "path": "/tmp/bad"}, + ) + assert response.status_code == 400