feat(l4d2-web): consolidate overlay catalog page

This commit is contained in:
mwiegand 2026-05-06 12:07:28 +02:00
parent 881b6635f9
commit 6559cf314e
No known key found for this signature in database
4 changed files with 166 additions and 22 deletions

View file

@ -10,7 +10,7 @@ from l4d2web.services.security import validate_overlay_path
bp = Blueprint("overlay", __name__)
@bp.post("/admin/overlays")
@bp.post("/overlays")
@require_admin
def create_overlay() -> Response:
name = request.form.get("name", "").strip()
@ -29,4 +29,38 @@ def create_overlay() -> Response:
return Response("overlay already exists", status=409)
db.add(Overlay(name=name, path=str(validated_path)))
return redirect("/admin/overlays")
return redirect("/overlays")
@bp.post("/overlays/<int:overlay_id>")
@require_admin
def update_overlay(overlay_id: int) -> 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:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
overlay.name = name
overlay.path = str(validated_path)
return redirect("/overlays")
@bp.post("/overlays/<int:overlay_id>/delete")
@require_admin
def delete_overlay(overlay_id: int) -> Response:
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
db.delete(overlay)
return redirect("/overlays")

View file

@ -15,16 +15,15 @@ bp = Blueprint("pages", __name__)
@bp.get("/dashboard")
@require_login
def dashboard() -> str:
user = current_user()
assert user is not None
return render_template("dashboard.html")
@bp.get("/overlays")
@require_login
def overlays() -> str:
with session_scope() as db:
servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all()
return render_template(
"dashboard.html",
servers=servers,
refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"],
)
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
return render_template("overlays.html", overlays=overlays)
@bp.get("/blueprints/<int:blueprint_id>")
@ -69,10 +68,3 @@ def server_detail(server_id: int):
return render_template("server_detail.html", server=server)
@bp.get("/admin/overlays")
@require_admin
def admin_overlays() -> str:
with session_scope() as db:
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
return render_template("admin_overlays.html", overlays=overlays)

View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Overlays | left4me{% endblock %}
{% block content %}
<section class="panel">
<div class="page-heading">
<h1>Overlays</h1>
</div>
{% if g.user.admin %}
<form method="post" action="/overlays" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Path <input name="path" required placeholder="/opt/l4d2/overlays/example"></label>
<button type="submit">Add overlay</button>
</form>
{% endif %}
<table class="table">
<thead><tr><th>Name</th><th>Path</th>{% if g.user.admin %}<th>Actions</th>{% endif %}</tr></thead>
<tbody>
{% for overlay in overlays %}
<tr>
<td>{{ overlay.name }}</td>
<td class="muted">{{ overlay.path }}</td>
{% if g.user.admin %}
<td>
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ overlay.name }}" required>
<input name="path" value="{{ overlay.path }}" required>
<button type="submit">Save</button>
</form>
<form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{% if g.user.admin %}3{% else %}2{% endif %}" class="muted">No overlays configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -1,14 +1,13 @@
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
from l4d2web.models import Overlay, User
@pytest.fixture
def admin_client(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'overlay.db'}"
db_url = f"sqlite:///{tmp_path/'admin_overlay.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
@ -26,19 +25,90 @@ def admin_client(tmp_path, monkeypatch):
return client
@pytest.fixture
def user_client_with_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'user_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:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.add(Overlay(name="standard", path="/opt/l4d2/overlays/standard"))
session.flush()
user_id = user.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["csrf_token"] = "test-token"
return client
def test_user_can_view_overlay_catalog(user_client_with_overlay) -> None:
response = user_client_with_overlay.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
assert "Add overlay" not in text
def test_admin_can_view_overlay_edit_controls(admin_client) -> None:
response = admin_client.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Add overlay" in text
assert 'action="/overlays"' in text
def test_admin_can_create_overlay(admin_client) -> None:
response = admin_client.post(
"/admin/overlays",
"/overlays",
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/overlays"
def test_overlay_path_must_be_under_root(admin_client) -> None:
response = admin_client.post(
"/admin/overlays",
"/overlays",
data={"name": "bad", "path": "/tmp/bad"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 400
def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "bad", "path": "/opt/l4d2/overlays/bad"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
def test_admin_can_update_and_delete_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
update = admin_client.post(
"/overlays/1",
data={"name": "edited", "path": "/opt/l4d2/overlays/edited"},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
delete = admin_client.post(
"/overlays/1/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert delete.status_code == 302