Compare commits

...

15 commits

40 changed files with 2534 additions and 290 deletions

View file

@ -19,6 +19,10 @@ Do not invent architecture outside these plans unless explicitly requested.
## Non-Negotiable Constraints
### Workspace and tools
- Do not use git worktrees.
### Naming and boundaries
- Use `l4d2` naming consistently.
@ -46,7 +50,7 @@ Do not invent architecture outside these plans unless explicitly requested.
- Flask + server-rendered templates + vendored HTMX.
- No external frontend framework/dependencies.
- Custom CSS with consistent link color `#0F766E`.
- Custom CSS with tokenized, consistent link and accent colors.
- Local username/password auth and `admin` flag.
- Persist command logs in `job_logs` table (retain indefinitely).
- Desired vs actual server state model.

View file

@ -13,7 +13,7 @@
## Scope and Constraints
- In scope:
- public signup/login
- local username/password login; public signup removed by the 2026-05-06 auth-pages follow-up
- one-time CLI admin promotion
- admin-managed overlay catalog
- user-private blueprints with ordered overlays
@ -35,7 +35,7 @@
- no external frontend dependencies
- HTMX vendored locally only
- custom CSS only
- consistent link color `#0F766E`
- tokenized, consistent link and accent colors
- Runtime rules:
- single-process deployment in v1
- periodic status refresh every 8 seconds
@ -277,9 +277,9 @@ git commit -m "feat(l4d2-web): add sqlite schema including blueprints and job lo
- [ ] **Step 1: Write failing auth tests**
```python
def test_public_signup(client):
r = client.post("/signup", data={"username": "alice", "password": "secret"})
assert r.status_code == 302
def test_signup_routes_are_gone(client):
assert client.get("/signup").status_code == 404
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
def test_login_sets_session(client, seed_user):
@ -329,7 +329,7 @@ Expected: PASS.
```bash
git add l4d2web/{auth.py,cli.py,app.py} l4d2web/routes/auth_routes.py l4d2web/tests/test_auth.py
git commit -m "feat(l4d2-web): add public auth and admin bootstrap command"
git commit -m "feat(l4d2-web): add local auth and admin bootstrap command"
```
### Task 4: Implement admin overlay catalog CRUD with path safety
@ -867,7 +867,7 @@ Expected: FAIL.
```css
:root {
--color-link: #0F766E;
--color-link: var(--color-primary);
--color-bg: #F6FBFA;
--color-text: #12302B;
--color-card: #FFFFFF;

View file

@ -0,0 +1,447 @@
# l4d2 Web Auth Pages Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not use git worktrees.
**Goal:** Add a real login page, remove signup, and redirect anonymous users back to their requested page after login when safe.
**Architecture:** Keep the current username/password data model and Flask session authentication. Add a small safe-redirect helper in `l4d2web/auth.py`, render login through Jinja, and keep routing changes scoped to auth decorators plus the app root route.
**Tech Stack:** Flask, Jinja templates, SQLAlchemy models, pytest, existing custom CSS tokens.
---
## File Structure
- `l4d2web/auth.py`: owns password hashing, session helpers, current user lookup, login-required/admin-required decorators, and safe local redirect target validation.
- `l4d2web/routes/auth_routes.py`: owns login GET/POST and logout. Signup handlers will be removed from this file.
- `l4d2web/app.py`: owns app setup, CSRF exemptions, and public root/health routes. Root will become auth-aware.
- `l4d2web/templates/base.html`: owns shared shell navigation. It will hide app navigation for anonymous users.
- `l4d2web/templates/login.html`: new server-rendered login page.
- `l4d2web/static/css/components.css`: minor form-control styling only if needed for the login form.
- `l4d2web/tests/test_auth.py`: auth route tests for login page, signup removal, safe next redirect, and unsafe next fallback.
- `l4d2web/tests/test_pages.py`: protected-page redirect and root redirect tests.
- `l4d2web/tests/test_security.py`: CSRF/rate-limit regression tests after signup is removed from exemptions.
## Task 1: Remove Signup and Add Login Page Tests
**Files:**
- Modify: `l4d2web/tests/test_auth.py`
- Modify: `l4d2web/tests/test_pages.py`
- [ ] **Step 1: Update auth tests for login page, signup removal, and next redirects**
Replace `test_public_signup` in `l4d2web/tests/test_auth.py` with these tests, and update existing login test data keys only if needed:
```python
def test_login_page_renders_form(client) -> None:
response = client.get("/login")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert '<form method="post" action="/login"' in text
assert 'name="username"' in text
assert 'name="password"' in text
assert "signup" not in text.lower()
def test_login_page_drops_unsafe_encoded_next(client) -> None:
response = client.get("/login?next=/%5Cevil.com")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'name="next"' not in text
assert "evil.com" not in text
def test_signup_routes_are_gone(client) -> None:
assert client.get("/signup").status_code == 404
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
def test_login_redirects_to_safe_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "/servers"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/servers")
def test_login_ignores_unsafe_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "https://example.com/steal"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")
def test_login_ignores_backslash_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "/\\evil.com"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")
def test_login_ignores_percent_encoded_backslash_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "/%5Cevil.com"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")
```
- [ ] **Step 2: Add protected-page and root redirect tests**
Append these tests to `l4d2web/tests/test_pages.py`:
```python
def test_anonymous_protected_page_redirects_to_login_with_next(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'anonymous-pages.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
client = app.test_client()
response = client.get("/servers")
assert response.status_code == 302
assert response.headers["Location"].endswith("/login?next=/servers")
def test_root_redirects_by_auth_state(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'root-redirect.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
client = app.test_client()
anonymous_response = client.get("/")
assert anonymous_response.status_code == 302
assert anonymous_response.headers["Location"].endswith("/login")
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
user_id = user.id
with client.session_transaction() as sess:
sess["user_id"] = user_id
logged_in_response = client.get("/")
assert logged_in_response.status_code == 302
assert logged_in_response.headers["Location"].endswith("/dashboard")
```
- [ ] **Step 3: Run tests and verify they fail**
Run: `pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q`
Expected: FAIL because `/login` is still plain text, signup routes still exist, `next` is not implemented, and `/` still returns JSON.
## Task 2: Implement Safe Redirects and Remove Signup
**Files:**
- Modify: `l4d2web/auth.py`
- Modify: `l4d2web/routes/auth_routes.py`
- Modify: `l4d2web/app.py`
- [ ] **Step 1: Add safe redirect helpers and next-aware decorators**
Update `l4d2web/auth.py` imports and add helpers near the session helpers:
```python
from urllib.parse import quote, unquote
from flask import abort, g, redirect, request, session
```
```python
def is_safe_next(target: str | None) -> bool:
if not target:
return False
if not target.startswith("/"):
return False
if target.startswith("//"):
return False
if "://" in target:
return False
if "\\" in target:
return False
decoded_target = unquote(target)
if decoded_target.startswith("//"):
return False
if "://" in decoded_target:
return False
if "\\" in decoded_target:
return False
return True
def login_redirect_for_current_request():
target = request.full_path.rstrip("?")
if is_safe_next(target):
return redirect(f"/login?next={quote(target, safe='/')}")
return redirect("/login")
```
Change `require_login` and the anonymous branch of `require_admin` to call `login_redirect_for_current_request()`:
```python
def require_login(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
if current_user() is None:
return login_redirect_for_current_request()
return func(*args, **kwargs)
return wrapper # type: ignore[return-value]
def require_admin(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
user = current_user()
if user is None:
return login_redirect_for_current_request()
if not user.admin:
abort(403)
return func(*args, **kwargs)
return wrapper # type: ignore[return-value]
```
- [ ] **Step 2: Remove signup and render login template**
Update `l4d2web/routes/auth_routes.py` imports:
```python
from flask import Blueprint, Response, redirect, render_template, request
```
Update the auth import:
```python
from l4d2web.auth import hash_password, is_safe_next, login_user, logout_user, verify_password
```
Delete the `@bp.get("/signup")` and `@bp.post("/signup")` route functions.
Replace `login_form` with:
```python
@bp.get("/login")
def login_form() -> str:
next_target = request.args.get("next", "")
return render_template("login.html", next_target=next_target if is_safe_next(next_target) else "")
```
Replace the final line of `login` with a safe redirect target:
```python
next_target = request.form.get("next", "")
return redirect(next_target if is_safe_next(next_target) else "/dashboard")
```
Keep the existing rate limiting and invalid credential behavior unchanged.
- [ ] **Step 3: Update CSRF exemptions and root route**
In `l4d2web/app.py`, update imports:
```python
from flask import Flask, Response, jsonify, redirect, request, session
from l4d2web.auth import current_user, load_current_user
```
Change the CSRF exemptions to remove signup:
```python
CSRF_EXEMPT_PATHS={"/login", "/health"},
```
Replace the root route with:
```python
@app.get("/")
def root():
if current_user() is None:
return redirect("/login")
return redirect("/dashboard")
```
- [ ] **Step 4: Run focused tests and verify they still fail only for missing template/nav**
Run: `pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q`
Expected: FAIL only because `login.html` does not exist or the rendered page does not yet contain the expected form markup.
## Task 3: Add Login Template and Anonymous Shell Behavior
**Files:**
- Create: `l4d2web/templates/login.html`
- Modify: `l4d2web/templates/base.html`
- Modify: `l4d2web/static/css/components.css`
- [ ] **Step 1: Hide main app navigation for anonymous users**
Update the header section in `l4d2web/templates/base.html` so the main section links render only for logged-in users:
```html
<nav class="primary-nav" aria-label="Main navigation">
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
{% if g.user %}
<a href="/servers">servers</a>
<a href="/blueprints">blueprints</a>
<a href="/overlays">overlays</a>
{% endif %}
</nav>
```
Keep the existing account nav wrapped in `{% if g.user %}`.
- [ ] **Step 2: Create the login template**
Create `l4d2web/templates/login.html`:
```html
{% extends "base.html" %}
{% block title %}Log In | left4me{% endblock %}
{% block content %}
<section class="panel auth-panel">
<h1>Log in to left4me</h1>
<p class="muted">Use your local account to manage Left 4 Dead 2 servers.</p>
<form method="post" action="/login" class="stack">
{% if next_target %}<input type="hidden" name="next" value="{{ next_target }}">{% endif %}
<label>
Username
<input name="username" autocomplete="username" required autofocus>
</label>
<label>
Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">log in</button>
</form>
</section>
{% endblock %}
```
- [ ] **Step 3: Add minimal form styling if needed**
Append to `l4d2web/static/css/components.css`:
```css
label {
display: grid;
gap: var(--space-xs);
}
input,
select,
textarea {
background: var(--color-surface);
border: var(--line);
border-radius: var(--radius-s);
color: var(--color-text);
padding: var(--space-s) var(--space-m);
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.auth-panel {
max-width: 28rem;
}
```
- [ ] **Step 4: Run focused tests and verify they pass**
Run: `pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q`
Expected: PASS.
- [ ] **Step 5: Commit auth page implementation**
```bash
git add l4d2web/auth.py l4d2web/routes/auth_routes.py l4d2web/app.py l4d2web/templates/base.html l4d2web/templates/login.html l4d2web/static/css/components.css l4d2web/tests/test_auth.py l4d2web/tests/test_pages.py
git commit -m "feat(l4d2-web): add login page and safe redirects"
```
## Task 4: Security Regression and Full Web Verification
**Files:**
- Modify: `l4d2web/tests/test_security.py` only if existing tests need path-specific updates.
- Modify: `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md` only if stale public-signup requirements should be corrected after implementation.
- Modify: `AGENTS.md` only if the auth contract must be changed from public signup to admin-created local users.
- [ ] **Step 1: Run security tests**
Run: `pytest l4d2web/tests/test_security.py -q`
Expected: PASS. `test_csrf_required` should still prove protected POSTs require CSRF. `test_login_rate_limit` should still pass with `/login` exempt from CSRF.
- [ ] **Step 2: Run page tests**
Run: `pytest l4d2web/tests/test_pages.py -q`
Expected: PASS. Existing logged-in shell tests should still see app navigation; new anonymous tests should see login redirects.
- [ ] **Step 3: Run auth tests**
Run: `pytest l4d2web/tests/test_auth.py -q`
Expected: PASS.
- [ ] **Step 4: Run full web test suite**
Run: `pytest l4d2web/tests -q`
Expected: PASS.
- [ ] **Step 5: Commit test/doc cleanup if needed**
If Task 4 required changes, commit them:
```bash
git add l4d2web/tests/test_security.py docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md AGENTS.md
git commit -m "test(l4d2-web): cover auth redirect regressions"
```
If no files changed in Task 4, do not create an empty commit.
## Final Verification
- [ ] Run `pytest l4d2web/tests -q` and confirm all tests pass.
- [ ] Run `git status --short` and confirm only intentional changes remain.
- [ ] Report exact command results and commits created.

View file

@ -0,0 +1,278 @@
# L4D2 Web Queue Worker Implementation Plan
> **Approval gate:** This plan may be written and refined without further approval. Do not implement code changes from this plan until the user explicitly approves implementation.
**Goal:** Complete the `l4d2web` async lifecycle queue so queued jobs are claimed, executed through the direct `l4d2host` Python APIs, logged to `job_logs`, reflected in server state, and streamed live to the UI.
**Architecture:** Keep the v1 single-process Flask architecture. Use DB-backed queued jobs as the durable source of truth, worker threads inside the Flask process, SQLite-safe process-local locks, and direct imports through `l4d2web.services.l4d2_facade`. Do not shell out to `l4d2ctl` from the web app.
---
## Current Gap
- Server lifecycle routes create `Job(state="queued")` rows.
- `l4d2web.services.job_worker` has scheduler helpers, stale recovery, command-log append, and actual-state refresh helpers.
- No worker claims queued jobs.
- No code dispatches queued operations to `l4d2_facade`.
- No command callbacks persist live stdout/stderr while jobs run.
- Job-log SSE currently replays existing rows once and does not live-follow new rows.
- Job-log SSE emits `stdout`/`stderr` custom events, while `static/js/sse.js` only handles default messages.
- No web route currently enqueues global `install` jobs.
---
## Locked Decisions
- Queue execution uses direct Python imports through `l4d2web.services.l4d2_facade`.
- The queue is DB-backed, not an in-memory `queue.Queue`.
- Worker threads are in-process daemon threads.
- SQLite concurrency is protected with process-local locks; no distributed lock manager is added.
- Workers are not started during normal tests.
- `POST /admin/install` is added as the admin-only runtime install/update entry point.
- `install` jobs have `server_id=None` and are globally exclusive.
- Server-specific jobs do not overlap on the same `server_id`.
- Different server jobs can run concurrently when no install job is running.
- A web `start` job applies the live-linked blueprint before start by running `initialize_server(server_id)` and then `start_server(server_id)`. This satisfies “blueprint updates apply on next action.”
- `delete` removes the host instance/runtime through `l4d2host`; it does not delete the web `Server` row in v1.
- Command log rows are retained indefinitely.
---
## Task 1: Extend Worker Tests First
**Files:**
- Modify: `l4d2web/tests/test_job_worker.py`
- Modify as needed: `l4d2web/tests/test_job_logs.py`
Add tests that verify the worker behavior without touching real systemd, Steam, or `/opt/l4d2`. Use monkeypatched `l4d2web.services.l4d2_facade` functions.
Required coverage:
- `run_worker_once()` claims the oldest runnable queued job.
- A successful server job transitions `queued -> running -> succeeded` and sets `exit_code=0`, `started_at`, `finished_at`, and `updated_at`.
- A successful job persists stdout/stderr callback lines in `job_logs`.
- A `subprocess.CalledProcessError` transitions the job to `failed` and stores `exit_code=exc.returncode`.
- An unexpected exception transitions the job to `failed` with `exit_code=1`.
- Same-server jobs do not overlap.
- Different-server jobs can be claimed concurrently by separate worker passes.
- An `install` job is not claimed while any server job is running.
- Server jobs are not claimed while an `install` job is running.
- Startup recovery marks stale `running` jobs as `failed`.
- Actual server state is refreshed after server-specific lifecycle jobs.
- `Server.last_error` is cleared on success and set on failure.
Verification command:
```bash
pytest l4d2web/tests/test_job_worker.py -q
```
Expected before implementation: FAIL.
---
## Task 2: Implement Queue Claiming And Job Execution
**Files:**
- Modify: `l4d2web/services/job_worker.py`
Add worker-core functions:
- `build_scheduler_state(session) -> SchedulerState`
- `claim_next_job() -> int | None`
- `run_worker_once() -> bool`
- `run_job(job_id: int) -> None`
- `finish_job(job_id: int, state: str, exit_code: int | None, error: str = "") -> None`
- `append_job_log_line(job_id: int, stream: str, line: str, max_chars: int = 4096) -> int`
Implementation rules:
- Use a module-level claim lock around scheduler-state construction, queued-job selection, and `queued -> running` transition.
- Commit the `running` transition before executing any host operation.
- Do not keep a DB session open while a host operation runs.
- Use a module-level log lock around `append_job_log()` so concurrent stdout/stderr callback threads cannot duplicate `seq` values.
- Recompute scheduler state from `running` jobs in the DB, not from only in-memory state.
- Select queued jobs by `created_at`, then `id` for deterministic order.
- Skip malformed server operations with no `server_id` by failing the job cleanly.
- Treat unknown operations as failed jobs, not worker-thread crashes.
Operation dispatch:
```text
install -> l4d2_facade.install_runtime(...)
initialize -> l4d2_facade.initialize_server(server_id, ...)
start -> l4d2_facade.initialize_server(server_id, ...), then l4d2_facade.start_server(server_id, ...)
stop -> l4d2_facade.stop_server(server_id, ...)
delete -> l4d2_facade.delete_server(server_id, ...)
```
Failure handling:
- `subprocess.CalledProcessError`: append remaining stderr if useful, fail with `exit_code=returncode`.
- Any other exception: append exception text to stderr, fail with `exit_code=1`.
- Never let a job exception kill the worker loop.
Verification command:
```bash
pytest l4d2web/tests/test_job_worker.py -q
```
Expected after implementation: PASS.
---
## Task 3: Add Worker Thread Startup
**Files:**
- Modify: `l4d2web/config.py`
- Modify: `l4d2web/app.py`
- Modify: `l4d2web/services/job_worker.py`
- Modify: `l4d2web/tests/test_job_worker.py`
Add config:
```python
"JOB_WORKER_ENABLED": True
"JOB_WORKER_POLL_SECONDS": 1
```
Add worker lifecycle functions:
- `start_job_workers(app) -> None`
- `worker_loop(app, poll_seconds: float) -> None`
Startup behavior:
- `create_app()` still calls `recover_stale_jobs()`.
- After recovery, `create_app()` starts workers only when enabled and not in `TESTING`.
- Guard against duplicate worker startup in the same process.
- Worker threads run as daemon threads.
- Each worker loop uses `app.app_context()` around `run_worker_once()`.
- If no job was run, sleep for `JOB_WORKER_POLL_SECONDS`.
Testing requirements:
- Tests should not accidentally start real background workers.
- Add a focused startup test with monkeypatched `start_job_workers` if needed.
Verification command:
```bash
pytest l4d2web/tests/test_job_worker.py -q
```
---
## Task 4: Make Job Log SSE Live-Follow
**Files:**
- Modify: `l4d2web/routes/job_routes.py`
- Modify: `l4d2web/static/js/sse.js`
- Modify: `l4d2web/tests/test_job_logs.py`
Route behavior:
- Authorize the job before streaming.
- Replay rows with `seq > last_seq` up to `JOB_LOG_REPLAY_LIMIT`.
- Continue polling for new rows while the job is not terminal.
- Close the stream after all available logs are sent and the job state is terminal.
- Keep emitting `id: <seq>` so EventSource can resume.
- Keep `event: stdout` and `event: stderr` for job logs.
JS behavior:
- Keep handling default server-log messages via `source.onmessage`.
- Also register `stdout` and `stderr` listeners that append job-log lines to the same element.
- Prefix custom job-log events with the stream name only if useful for readability.
Terminal states:
```text
succeeded
failed
cancelled
```
`cancelled` is reserved for future use and does not require cancellation support in this task.
Verification command:
```bash
pytest l4d2web/tests/test_job_logs.py -q
```
---
## Task 5: Add Admin Runtime Install Action
**Files:**
- Modify: `l4d2web/routes/page_routes.py`
- Modify: `l4d2web/templates/admin.html`
- Modify: `l4d2web/tests/test_pages.py` or add a focused admin route test
Behavior:
- `POST /admin/install` requires `@require_admin`.
- Creates `Job(user_id=current_admin.id, server_id=None, operation="install", state="queued")`.
- Redirects to `/admin/jobs`.
- Non-admin logged-in users receive `403`.
- Anonymous users are redirected to login.
- Admin page shows a CSRF-protected form/button for runtime install/update.
Verification command:
```bash
pytest l4d2web/tests/test_pages.py -q
```
---
## Task 6: Full Verification And Review
Run focused suites first:
```bash
pytest l4d2web/tests/test_job_worker.py -q
pytest l4d2web/tests/test_job_logs.py -q
pytest l4d2web/tests/test_pages.py -q
```
Then run the full web suite:
```bash
pytest l4d2web/tests -q
```
Refresh the code index after implementation:
```bash
ccc index
```
Request a final read-only review focused on:
- queue claiming races
- duplicate worker startup
- job-log sequence ordering
- error handling and `last_error`
- live SSE behavior
- `start` applying blueprint updates before host start
---
## Commit Strategy
Use small commits after passing relevant tests:
1. `feat(l4d2-web): execute queued lifecycle jobs`
2. `feat(l4d2-web): live-follow queued job logs`
3. `feat(l4d2-web): add admin runtime install job`
Do not commit unless the user explicitly asks for commits.
---
## Open Approval Gate
Before modifying implementation files, ask the user for explicit approval to proceed with the queue-worker implementation.

View file

@ -0,0 +1,105 @@
# l4d2 Web Auth Pages Design
Date: 2026-05-06
## Goal
Replace the current plain-text login/signup placeholders with a small, functional authentication UI for the Flask web app. Keep the current username/password schema for now, remove public signup, and redirect users back to the page they originally wanted after login when that target is safe.
## Scope
In scope:
- Real server-rendered `/login` page using the existing neutral shell and CSS tokens.
- Username/password login using the existing `users.username` and `users.password_digest` fields.
- Removal of `/signup` handlers, links, and CSRF exemptions.
- Redirect anonymous protected requests to `/login?next=<wanted-path>`.
- Redirect successful logins to a safe local `next` target, otherwise `/dashboard`.
- Redirect `/` to `/login` for anonymous users and `/dashboard` for logged-in users.
- Keep `/health` public for monitoring.
Out of scope:
- Email-based login.
- Steam login.
- Account self-registration.
- Password reset or password change flows.
- User invitation flows.
- Schema changes to split users, credentials, and external identities.
## Routing Behavior
`GET /login` renders the login page. The page accepts an optional `next` query parameter and preserves it in a hidden form field so a successful POST can return the user to the requested page.
`POST /login` validates the username and password with the existing password hash helpers. On success, it stores `session["user_id"]` and redirects to the safe local `next` value if present. If `next` is missing, empty, or unsafe, it redirects to `/dashboard`.
`GET /signup` and `POST /signup` are removed. They should return Flask's normal `404 Not Found` response.
`GET /` redirects to `/login` for anonymous users and `/dashboard` for logged-in users.
Protected page decorators keep their current authorization semantics, but anonymous redirects include the requested path in `next`. Logged-in non-admin users still receive `403 Forbidden` for admin-only pages.
## Safe Redirect Rules
A `next` target is safe only when it is a local absolute path:
- It must start with `/`.
- It must not start with `//`.
- It must not contain a URL scheme such as `https://`.
- It must not contain backslashes, which browsers may normalize into path separators.
- The same checks apply after one percent-decoding pass so encoded backslashes and encoded protocol-relative paths are rejected.
Unsafe values are ignored and replaced with `/dashboard`. This avoids open redirects while keeping the implementation simple.
## UI Behavior
The login page should be minimal and consistent with the current admin-console UI:
- Brand/title: `left4me`.
- Short explanatory text.
- Fields: `username` and `password`.
- Submit button: `log in`.
- No signup link.
The shared shell should not show main application navigation to anonymous users. Anonymous users may see the brand only. Logged-in users keep the existing navigation and account controls.
## Data Model
No schema change is planned for this iteration. `User.username` remains the local login identifier. This intentionally defers email-based and Steam-based identity modeling until those flows are designed.
Future Steam login should use a separate identity model rather than forcing Steam users into an email/password shape, but that is not part of this change.
## Error Handling
Invalid credentials keep the current simple behavior: `401 invalid credentials`.
Missing username or password also remain simple response errors. The first UI pass does not need inline validation messages or flashed form errors.
Rate limiting on `POST /login` remains unchanged.
## Testing
Tests should cover:
- `GET /login` renders a form with username and password inputs.
- `GET /signup` returns `404`.
- `POST /signup` returns `404` rather than being intercepted by CSRF handling.
- Anonymous protected pages redirect to `/login?next=<path>`.
- A successful login with a safe `next` redirects to that target.
- A successful login with an unsafe `next` redirects to `/dashboard`.
- A successful login with a backslash-containing `next` redirects to `/dashboard`.
- A successful login with a percent-encoded backslash in `next` redirects to `/dashboard`.
- Anonymous `/` redirects to `/login`.
- Logged-in `/` redirects to `/dashboard`.
- Non-admin users still receive `403` for admin pages.
## Verification
Run these checks after implementation:
```bash
pytest l4d2web/tests/test_auth.py -q
pytest l4d2web/tests/test_pages.py -q
pytest l4d2web/tests/test_security.py -q
pytest l4d2web/tests -q
```

View file

@ -4,7 +4,7 @@ Flask web app for managing L4D2 servers through user-private blueprints.
## Key v1 behaviors
- Public signup/login with local username/password
- Local username/password login; no public signup
- Admin-managed overlay catalog
- Private blueprints per user
- Server creation from blueprints (live-linked; no per-server blueprint overrides)
@ -17,7 +17,7 @@ Flask web app for managing L4D2 servers through user-private blueprints.
- Server-rendered templates (Jinja)
- Vendored HTMX (`static/vendor/htmx.min.js`)
- Custom CSS only
- Consistent link color: `#0F766E`
- Tokenized, consistent link and accent colors
## Development

View file

@ -1,9 +1,9 @@
import os
import secrets
from flask import Flask, Response, jsonify, request, session
from flask import Flask, Response, jsonify, redirect, request, session
from l4d2web.auth import load_current_user
from l4d2web.auth import current_user, load_current_user
from l4d2web.cli import register_cli
from l4d2web.config import DEFAULT_CONFIG
from l4d2web.db import init_db
@ -15,7 +15,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.services.job_worker import recover_stale_jobs
from l4d2web.services.job_worker import recover_stale_jobs, start_job_workers
def create_app(test_config: dict[str, object] | None = None) -> Flask:
@ -24,7 +24,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
CSRF_EXEMPT_PATHS={"/login", "/signup", "/health"},
CSRF_EXEMPT_PATHS={"/login", "/health"},
)
if test_config is not None:
app.config.update(test_config)
@ -40,6 +40,9 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
return None
if request.endpoint is None:
return None
if request.path in app.config["CSRF_EXEMPT_PATHS"]:
return None
@ -60,6 +63,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
if app.config.get("TESTING"):
reset_login_rate_limits()
recover_stale_jobs()
if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"):
start_job_workers(app)
@app.get("/health")
def health():
@ -67,6 +72,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
@app.get("/")
def root():
return jsonify({"status": "ok"})
if current_user() is None:
return redirect("/login")
return redirect("/dashboard")
return app

View file

@ -1,7 +1,8 @@
from functools import wraps
from typing import Callable, TypeVar
from urllib.parse import quote, unquote
from flask import abort, g, redirect, session
from flask import abort, g, redirect, request, session
from sqlalchemy import select
from werkzeug.security import check_password_hash, generate_password_hash
@ -41,11 +42,39 @@ def logout_user() -> None:
session.pop("user_id", None)
def is_safe_next(target: str | None) -> bool:
if not target:
return False
if not target.startswith("/"):
return False
if target.startswith("//"):
return False
if "://" in target:
return False
if "\\" in target:
return False
decoded_target = unquote(target)
if decoded_target.startswith("//"):
return False
if "://" in decoded_target:
return False
if "\\" in decoded_target:
return False
return True
def login_redirect_for_current_request():
target = request.full_path.rstrip("?")
if is_safe_next(target):
return redirect(f"/login?next={quote(target, safe='/')}")
return redirect("/login")
def require_login(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
if current_user() is None:
return redirect("/login")
return login_redirect_for_current_request()
return func(*args, **kwargs)
return wrapper # type: ignore[return-value]
@ -56,7 +85,7 @@ def require_admin(func: F) -> F:
def wrapper(*args, **kwargs):
user = current_user()
if user is None:
return redirect("/login")
return login_redirect_for_current_request()
if not user.admin:
abort(403)
return func(*args, **kwargs)

View file

@ -3,6 +3,8 @@ DEFAULT_CONFIG: dict[str, object] = {
"DATABASE_URL": "sqlite:///l4d2web.db",
"STATUS_REFRESH_SECONDS": 8,
"JOB_WORKER_THREADS": 4,
"JOB_WORKER_ENABLED": True,
"JOB_WORKER_POLL_SECONDS": 1,
"JOB_LOG_REPLAY_LIMIT": 2000,
"JOB_LOG_LINE_MAX_CHARS": 4096,
}

View file

@ -1,9 +1,9 @@
import time
from flask import Blueprint, Response, request, redirect
from flask import Blueprint, Response, redirect, render_template, request
from sqlalchemy import select
from l4d2web.auth import hash_password, login_user, logout_user, verify_password
from l4d2web.auth import is_safe_next, login_user, logout_user, verify_password
from l4d2web.db import session_scope
from l4d2web.models import User
@ -29,33 +29,10 @@ def is_login_rate_limited(remote_addr: str) -> bool:
return False
@bp.get("/signup")
def signup_form() -> Response:
return Response("signup", mimetype="text/plain")
@bp.post("/signup")
def signup() -> Response:
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
return Response("missing credentials", status=400)
with session_scope() as db:
existing = db.scalar(select(User).where(User.username == username))
if existing is not None:
return Response("username already exists", status=409)
user = User(username=username, password_digest=hash_password(password), admin=False)
db.add(user)
db.flush()
login_user(user.id)
return redirect("/dashboard")
@bp.get("/login")
def login_form() -> Response:
return Response("login", mimetype="text/plain")
def login_form() -> str:
next_target = request.args.get("next", "")
return render_template("login.html", next_target=next_target if is_safe_next(next_target) else "")
@bp.post("/login")
@ -72,7 +49,8 @@ def login() -> Response:
return Response("invalid credentials", status=401)
login_user(user.id)
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
return redirect("/dashboard")
next_target = request.form.get("next", "")
return redirect(next_target if is_safe_next(next_target) else "/dashboard")
@bp.post("/logout")

View file

@ -1,6 +1,6 @@
import json
from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, Response, jsonify, redirect, request
from sqlalchemy import delete, func, select
from l4d2web.auth import current_user, require_login
@ -12,39 +12,88 @@ from l4d2web.models import BlueprintOverlay, 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))
@bp.post("/blueprints")
@require_login
def create_blueprint() -> Response:
payload = request.get_json(silent=True) or {}
user = current_user()
assert user is not None
name = str(payload.get("name", "")).strip()
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:
blueprint = BlueprintModel(
user_id=user.id,
name=name,
arguments=json.dumps(payload.get("arguments", [])),
config=json.dumps(payload.get("config", [])),
)
blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
db.add(blueprint)
db.flush()
for position, overlay_id in enumerate(payload.get("overlay_ids", [])):
db.add(
BlueprintOverlay(
blueprint_id=blueprint.id,
overlay_id=int(overlay_id),
position=position,
)
)
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
blueprint_id = blueprint.id
return jsonify({"id": blueprint_id}), 201
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)
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, ordered_overlay_ids_from_form())
return redirect(f"/blueprints/{blueprint_id}")
@bp.delete("/blueprints/<int:blueprint_id>")

View file

@ -1,3 +1,5 @@
import time
from flask import Blueprint, Response, current_app, request
from sqlalchemy import select
@ -7,6 +9,14 @@ from l4d2web.models import Job, JobLog
bp = Blueprint("job", __name__)
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
def format_sse_event(seq: int, event: str, data: str) -> str:
lines = [f"id: {seq}", f"event: {event}"]
for line in data.splitlines() or [""]:
lines.append(f"data: {line}")
return "\n".join(lines) + "\n\n"
@bp.get("/jobs/<int:job_id>/stream")
@ -15,8 +25,9 @@ def stream_job(job_id: int) -> Response:
user = current_user()
assert user is not None
last_seq = int(request.args.get("last_seq", "0"))
last_seq = int(request.args.get("last_seq") or request.headers.get("Last-Event-ID") or "0")
limit = int(current_app.config["JOB_LOG_REPLAY_LIMIT"])
poll_seconds = float(current_app.config.get("JOB_WORKER_POLL_SECONDS", 1))
with session_scope() as db:
job = db.scalar(select(Job).where(Job.id == job_id, Job.user_id == user.id))
@ -24,16 +35,26 @@ def stream_job(job_id: int) -> Response:
return Response(status=404)
def generate():
with session_scope() as db:
rows = db.scalars(
select(JobLog)
.where(JobLog.job_id == job_id, JobLog.seq > last_seq)
.order_by(JobLog.seq)
.limit(limit)
).all()
next_seq = last_seq
while True:
with session_scope() as db:
job = db.scalar(select(Job).where(Job.id == job_id))
if job is None:
return
rows = db.scalars(
select(JobLog)
.where(JobLog.job_id == job_id, JobLog.seq > next_seq)
.order_by(JobLog.seq)
.limit(limit)
).all()
terminal = job.state in TERMINAL_JOB_STATES
for row in rows:
yield f"id: {row.seq}\n"
yield f"event: {row.stream}\n"
yield f"data: {row.line}\n\n"
next_seq = row.seq
yield format_sse_event(row.seq, row.stream, row.line)
if terminal and len(rows) < limit:
return
time.sleep(poll_seconds)
return Response(generate(), mimetype="text/event-stream")

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

@ -1,12 +1,12 @@
import json
from flask import Blueprint, Response, current_app, render_template
from flask import Blueprint, Response, redirect, render_template
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, Overlay, Server
from l4d2web.models import BlueprintOverlay, Job, Overlay, Server, User
bp = Blueprint("pages", __name__)
@ -15,18 +15,116 @@ bp = Blueprint("pages", __name__)
@bp.get("/dashboard")
@require_login
def dashboard() -> str:
return render_template("dashboard.html")
@bp.get("/admin")
@require_admin
def admin_home() -> str:
return render_template("admin.html")
@bp.post("/admin/install")
@require_admin
def enqueue_runtime_install() -> 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="install", state="queued"))
return redirect("/admin/jobs")
@bp.get("/admin/users")
@require_admin
def admin_users() -> str:
with session_scope() as db:
users = db.scalars(select(User).order_by(User.username)).all()
return render_template("admin_users.html", users=users)
@bp.get("/admin/jobs")
@require_admin
def admin_jobs() -> str:
with session_scope() as db:
rows = db.execute(
select(Job, User, Server)
.join(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.order_by(Job.created_at.desc())
).all()
return render_template("admin_jobs.html", rows=rows)
@bp.get("/servers")
@require_login
def servers_page() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
rows = db.execute(
select(Server, BlueprintModel)
.join(BlueprintModel, BlueprintModel.id == Server.blueprint_id)
.where(Server.user_id == user.id)
.order_by(Server.name)
).all()
return render_template("servers.html", rows=rows)
@bp.get("/servers/<int:server_id>")
@require_login
def server_detail(server_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all()
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
overlay_rows = db.execute(
select(Overlay.name)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == server.blueprint_id)
.order_by(BlueprintOverlay.position)
).all()
latest_job = db.scalar(
select(Job)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(1)
)
return render_template(
"dashboard.html",
servers=servers,
refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"],
"server_detail.html",
server=server,
blueprint=blueprint,
overlay_names=[row[0] for row in overlay_rows],
arguments=json.loads(blueprint.arguments) if blueprint is not None else [],
config_lines=json.loads(blueprint.config) if blueprint is not None else [],
latest_job=latest_job,
)
@bp.get("/overlays")
@require_login
def overlays() -> str:
with session_scope() as db:
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
return render_template("overlays.html", overlays=overlays)
@bp.get("/blueprints")
@require_login
def blueprints_page() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
blueprints = db.scalars(
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all()
return render_template("blueprints.html", blueprints=blueprints)
@bp.get("/blueprints/<int:blueprint_id>")
@require_login
def blueprint_page(blueprint_id: int):
@ -40,39 +138,26 @@ def blueprint_page(blueprint_id: int):
if blueprint.user_id != user.id:
return Response(status=403)
overlay_rows = db.execute(
select(Overlay.name)
selected_overlays = db.scalars(
select(Overlay)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position)
).all()
position_rows = db.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
all_overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
return render_template(
"blueprints.html",
"blueprint_detail.html",
blueprint=blueprint,
overlay_names=[row[0] for row in overlay_rows],
selected_overlays=selected_overlays,
all_overlays=all_overlays,
selected_overlay_ids={overlay.id for overlay in selected_overlays},
overlay_positions=overlay_positions,
arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config),
)
@bp.get("/servers/<int:server_id>")
@require_login
def server_detail(server_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
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

@ -1,10 +1,10 @@
from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, Response, jsonify, redirect, request
from sqlalchemy import 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 Server
from l4d2web.models import Job, Server
bp = Blueprint("server", __name__)
@ -67,3 +67,27 @@ def update_server(server_id: int) -> Response:
server.blueprint_id = blueprint.id
return jsonify({"id": server_id}), 200
LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}
@bp.post("/servers/<int:server_id>/<operation>")
@require_login
def enqueue_server_operation(server_id: int, operation: str) -> Response:
user = current_user()
assert user is not None
if operation not in LIFECYCLE_OPERATIONS:
return Response(status=404)
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued"))
if operation == "start":
server.desired_state = "running"
if operation in {"stop", "delete"}:
server.desired_state = "stopped"
return redirect(f"/servers/{server_id}")

View file

@ -1,5 +1,8 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import subprocess
import threading
import time
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@ -8,6 +11,15 @@ from l4d2web.db import session_scope
from l4d2web.models import Job, JobLog, Server
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
SERVER_OPERATIONS = {"initialize", "start", "stop", "delete"}
_claim_lock = threading.Lock()
_log_lock = threading.RLock()
_worker_start_lock = threading.Lock()
_workers_started = False
@dataclass
class SchedulerState:
install_running: bool = False
@ -24,12 +36,128 @@ def can_start(job, state: SchedulerState) -> bool:
return job.server_id not in state.running_servers
def build_scheduler_state(session: Session) -> SchedulerState:
state = SchedulerState()
running_jobs = session.scalars(select(Job).where(Job.state == "running")).all()
for job in running_jobs:
if job.operation == "install":
state.install_running = True
elif job.server_id is not None:
state.running_servers.add(job.server_id)
return state
def claim_next_job() -> int | None:
with _claim_lock:
with session_scope() as db:
state = build_scheduler_state(db)
jobs = db.scalars(select(Job).where(Job.state == "queued").order_by(Job.created_at, Job.id)).all()
now = datetime.now(UTC)
for job in jobs:
malformed_server_job = job.operation != "install" and job.server_id is None
if not malformed_server_job and not can_start(job, state):
continue
job.state = "running"
job.started_at = now
job.updated_at = now
job.exit_code = None
db.flush()
return job.id
return None
def run_worker_once() -> bool:
job_id = claim_next_job()
if job_id is None:
return False
run_job(job_id)
return True
def run_job(job_id: int) -> None:
from l4d2web.services import l4d2_facade
with session_scope() as db:
job = db.scalar(select(Job).where(Job.id == job_id))
if job is None:
return
operation = job.operation
server_id = job.server_id
max_chars = 4096
def on_stdout(line: str) -> None:
append_job_log_line(job_id, "stdout", line, max_chars=max_chars)
def on_stderr(line: str) -> None:
append_job_log_line(job_id, "stderr", line, max_chars=max_chars)
try:
if operation == "install":
l4d2_facade.install_runtime(on_stdout=on_stdout, on_stderr=on_stderr)
elif operation in SERVER_OPERATIONS and server_id is None:
raise ValueError(f"{operation} job has no server_id")
elif operation == "initialize":
l4d2_facade.initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
elif operation == "start":
l4d2_facade.initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
l4d2_facade.start_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
elif operation == "stop":
l4d2_facade.stop_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
elif operation == "delete":
l4d2_facade.delete_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
else:
raise ValueError(f"unknown job operation: {operation}")
if server_id is not None:
refresh_server_actual_state_after_job(job_id, server_id)
finish_job(job_id, "succeeded", 0)
except subprocess.CalledProcessError as exc:
error = exc.stderr or str(exc)
if exc.stderr:
append_job_log_line(job_id, "stderr", str(exc.stderr), max_chars=max_chars)
if server_id is not None:
refresh_server_actual_state_after_job(job_id, server_id)
finish_job(job_id, "failed", exc.returncode, error=error)
except Exception as exc:
error = str(exc)
append_job_log_line(job_id, "stderr", error, max_chars=max_chars)
if server_id is not None:
refresh_server_actual_state_after_job(job_id, server_id)
finish_job(job_id, "failed", 1, error=error)
def finish_job(job_id: int, state: str, exit_code: int | None, error: str = "") -> None:
now = datetime.now(UTC)
with session_scope() as db:
job = db.scalar(select(Job).where(Job.id == job_id))
if job is None:
return
job.state = state
job.exit_code = exit_code
job.finished_at = now
job.updated_at = now
if job.server_id is not None:
server = db.scalar(select(Server).where(Server.id == job.server_id))
if server is not None:
server.last_error = "" if state == "succeeded" else error
server.updated_at = now
def append_job_log_line(job_id: int, stream: str, line: str, max_chars: int = 4096) -> int:
with _log_lock:
with session_scope() as db:
return append_job_log(db, job_id, stream, line, max_chars=max_chars)
def recover_stale_jobs() -> int:
now = datetime.now(UTC)
with session_scope() as db:
jobs = db.scalars(select(Job).where(Job.state == "running")).all()
for job in jobs:
job.state = "failed"
job.exit_code = 1
job.finished_at = now
job.updated_at = now
return len(jobs)
@ -42,11 +170,12 @@ def append_job_log(
line: str,
max_chars: int = 4096,
) -> int:
last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0
next_seq = int(last_seq) + 1
session.add(JobLog(job_id=job_id, seq=next_seq, stream=stream, line=line[:max_chars]))
session.flush()
return next_seq
with _log_lock:
last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0
next_seq = int(last_seq) + 1
session.add(JobLog(job_id=job_id, seq=next_seq, stream=stream, line=line[:max_chars]))
session.flush()
return next_seq
def refresh_server_actual_state(server_id: int) -> str:
@ -62,3 +191,41 @@ def refresh_server_actual_state(server_id: int) -> str:
server.actual_state_updated_at = now
server.updated_at = now
return server.actual_state
def refresh_server_actual_state_after_job(job_id: int, server_id: int) -> None:
try:
refresh_server_actual_state(server_id)
except Exception as exc:
append_job_log_line(job_id, "stderr", f"status refresh failed: {exc}")
def start_job_workers(app) -> None:
global _workers_started
with _worker_start_lock:
if _workers_started:
return
_workers_started = True
threads = int(app.config.get("JOB_WORKER_THREADS", 4))
poll_seconds = float(app.config.get("JOB_WORKER_POLL_SECONDS", 1))
for index in range(threads):
thread = threading.Thread(
target=worker_loop,
args=(app, poll_seconds),
name=f"l4d2-job-worker-{index + 1}",
daemon=True,
)
thread.start()
def worker_loop(app, poll_seconds: float) -> None:
while True:
ran_job = False
try:
with app.app_context():
ran_job = run_worker_once()
except Exception:
ran_job = False
if not ran_job:
time.sleep(poll_seconds)

View file

@ -1,10 +1,10 @@
.panel,
.card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-4);
margin-bottom: var(--space-4);
box-shadow: 0 8px 20px #0F766E12;
background: var(--color-surface);
border: var(--line);
border-radius: var(--radius-m);
padding: var(--space-l);
margin-bottom: var(--space-l);
}
.table {
@ -15,8 +15,8 @@
.table th,
.table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
padding: var(--space-s) var(--space-m);
border-bottom: var(--line);
}
.muted {
@ -25,7 +25,7 @@
.stack {
display: grid;
gap: var(--space-3);
gap: var(--space-m);
}
input,
@ -35,11 +35,56 @@ textarea {
font: inherit;
}
label {
display: grid;
gap: var(--space-xs);
}
input,
select,
textarea {
background: var(--color-surface);
border: var(--line);
border-radius: var(--radius-s);
color: var(--color-text);
padding: var(--space-s) var(--space-m);
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
button {
background: var(--color-link);
background: var(--color-primary);
border: none;
border-radius: 8px;
border-radius: var(--radius-s);
color: #fff;
padding: var(--space-2) var(--space-4);
padding: var(--space-s) var(--space-l);
cursor: pointer;
}
button.danger {
background: var(--color-danger);
}
.link-button {
background: none;
border: none;
color: var(--color-link);
padding: 0;
text-decoration: underline;
cursor: pointer;
}
.inline-form {
display: inline;
}
.auth-panel {
max-width: 28rem;
}

View file

@ -4,35 +4,42 @@
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
background: radial-gradient(circle at top right, #DDEFEA, #F3F7F6 45%);
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: var(--color-text);
}
.site-header {
background: #FFFFFFD9;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
border-bottom: var(--line);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
}
.site-header-inner {
max-width: 960px;
margin: 0 auto;
padding: var(--space-4);
padding: var(--space-l);
display: flex;
justify-content: space-between;
align-items: center;
}
.primary-nav,
.account-nav {
display: flex;
gap: var(--space-l);
align-items: center;
}
.brand {
font-weight: 700;
text-decoration: none;
margin-right: var(--space-l);
}
.container {
max-width: 960px;
margin: 0 auto;
padding: var(--space-6) var(--space-4) var(--space-6);
padding: var(--space-2xl) var(--space-l);
}

View file

@ -1,10 +1,13 @@
.log-stream {
min-height: 180px;
max-height: 360px;
max-height: 480px;
overflow: auto;
background: #0A1412;
color: #CCE9E1;
border-radius: 8px;
padding: var(--space-3);
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
background: var(--color-log-bg);
color: var(--color-log-text);
border-radius: var(--radius-s);
padding: var(--space-m);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.4;
white-space: pre-wrap;
}

View file

@ -1,15 +1,49 @@
:root {
--color-link: #0F766E;
--color-bg: #F3F7F6;
--color-text: #11201D;
--color-card: #FFFFFF;
--color-border: #D4E4DF;
--color-muted: #4A6A63;
--radius: 10px;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--color-bg: #f4f4f5;
--color-surface: #ffffff;
--color-surface-muted: #f8fafc;
--color-text: #18181b;
--color-muted: #60646c;
--color-border: #d4d4d8;
--color-link: #1d4ed8;
--color-primary: #1d4ed8;
--color-danger: #b42318;
--color-warning: #a15c07;
--color-success: #067647;
--color-focus: #2563eb;
--color-log-bg: #111827;
--color-log-text: #e5e7eb;
--space-base: 0.25rem;
--space-xs: var(--space-base);
--space-s: calc(var(--space-base) * 2);
--space-m: calc(var(--space-base) * 3);
--space-l: calc(var(--space-base) * 4);
--space-xl: calc(var(--space-base) * 6);
--space-2xl: calc(var(--space-base) * 8);
--radius-base: 0.25rem;
--radius-s: var(--radius-base);
--radius-m: calc(var(--radius-base) * 2);
--line: 1px solid var(--color-border);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #18181b;
--color-surface: #27272a;
--color-surface-muted: #1f1f23;
--color-text: #f4f4f5;
--color-muted: #a1a1aa;
--color-border: #3f3f46;
--color-link: #93c5fd;
--color-primary: #93c5fd;
--color-danger: #fca5a5;
--color-warning: #fcd34d;
--color-success: #86efac;
--color-focus: #bfdbfe;
}
}
a {

View file

@ -1,19 +1,29 @@
function streamTextToElement(url, elementId) {
const target = document.getElementById(elementId);
if (!target) {
function streamTextToElement(element) {
const url = element.dataset.sseUrl;
if (!url) {
return;
}
const source = new EventSource(url);
source.onmessage = (event) => {
target.textContent += `${event.data}\n`;
target.scrollTop = target.scrollHeight;
const appendLine = (line) => {
element.textContent += `${line}\n`;
element.scrollTop = element.scrollHeight;
};
source.onmessage = (event) => {
appendLine(event.data);
};
source.addEventListener("stdout", (event) => {
appendLine(event.data);
});
source.addEventListener("stderr", (event) => {
appendLine(`[stderr] ${event.data}`);
});
}
document.addEventListener("DOMContentLoaded", () => {
const serverLog = document.getElementById("server-log-stream");
if (serverLog) {
streamTextToElement(serverLog.dataset.serverLogUrl, "server-log-stream");
}
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement);
});

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Admin | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Admin</h1>
<ul class="link-list">
<li><a href="/admin/users">Users</a></li>
<li><a href="/admin/jobs">Jobs</a></li>
</ul>
</section>
<section class="panel">
<h2>Runtime</h2>
<p class="muted">Queue a Steam runtime install/update job for the local host.</p>
<form method="post" action="/admin/install">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">Install or update runtime</button>
</form>
</section>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Admin Jobs | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Jobs</h1>
<table class="table">
<thead><tr><th>ID</th><th>Operation</th><th>State</th><th>User</th><th>Server</th><th>Created</th><th>Finished</th></tr></thead>
<tbody>
{% for job, user, server in rows %}
<tr>
<td>{{ job.id }}</td>
<td>{{ job.operation }}</td>
<td>{{ job.state }}</td>
<td>{{ user.username }}</td>
<td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td>
<td>{{ job.created_at }}</td>
<td>{{ job.finished_at or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">No jobs found.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block title %}Admin Overlays | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Overlay Catalog</h1>
<form method="post" action="/admin/overlays" class="stack">
<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>
<h2>Known overlays</h2>
<ul>
{% for overlay in overlays %}
<li><strong>{{ overlay.name }}</strong> <span class="muted">{{ overlay.path }}</span></li>
{% else %}
<li class="muted">No overlays configured.</li>
{% endfor %}
</ul>
</section>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Admin Users | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Users</h1>
<table class="table">
<thead><tr><th>Username</th><th>Admin</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>
{% for user in users %}
<tr><td>{{ user.username }}</td><td>{{ "yes" if user.admin else "no" }}</td><td>{{ user.created_at }}</td><td>{{ user.updated_at }}</td></tr>
{% else %}
<tr><td colspan="4" class="muted">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -13,11 +13,24 @@
<body>
<header class="site-header">
<div class="site-header-inner">
<a class="brand" href="/dashboard">left4me</a>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/admin/overlays">Overlays</a>
<nav class="primary-nav" aria-label="Main navigation">
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
{% if g.user %}
<a href="/servers">servers</a>
<a href="/blueprints">blueprints</a>
<a href="/overlays">overlays</a>
{% endif %}
</nav>
{% if g.user %}
<nav class="account-nav" aria-label="Account navigation">
{% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
<span class="muted">{{ g.user.username }}</span>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="link-button" type="submit">logout</button>
</form>
</nav>
{% endif %}
</div>
</header>
<main class="container">

View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Blueprint: {{ blueprint.name }}</h1>
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
<table class="table">
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
<tbody>
{% for overlay in all_overlays %}
<tr>
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
<td>{{ overlay.name }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
{% endfor %}
</tbody>
</table>
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
<button type="submit">Save blueprint</button>
</form>
</section>
{% endblock %}

View file

@ -1,21 +1,36 @@
{% extends "base.html" %}
{% block title %}Blueprint | left4me{% endblock %}
{% block title %}Blueprints | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Blueprint: {{ blueprint.name }}</h1>
<h2>Overlays</h2>
<ul>
{% for name in overlay_names %}
<li>{{ name }}</li>
{% else %}
<li class="muted">No overlays configured.</li>
{% endfor %}
</ul>
<h2>Arguments</h2>
<pre>{{ arguments | join('\n') }}</pre>
<h2>Config</h2>
<pre>{{ config_lines | join('\n') }}</pre>
<section class="panel">
<h1>Blueprints</h1>
<form method="post" action="/blueprints" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments"></textarea></label>
<label>Config <textarea name="config"></textarea></label>
<button type="submit">Create blueprint</button>
</form>
<table class="table">
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
<tbody>
{% for blueprint in blueprints %}
<tr>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ blueprint.created_at }}</td>
<td>{{ blueprint.updated_at }}</td>
<td>
<form method="post" action="/blueprints/{{ blueprint.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>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -3,26 +3,8 @@
{% block title %}Dashboard | left4me{% endblock %}
{% block content %}
<section class="card">
<section class="panel">
<h1>Dashboard</h1>
<p class="muted">Status refresh every {{ refresh_seconds }}s.</p>
<table class="table">
<thead>
<tr><th>Name</th><th>Port</th><th>Desired</th><th>Actual</th><th></th></tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>{{ server.name }}</td>
<td>{{ server.port }}</td>
<td>{{ server.desired_state }}</td>
<td>{{ server.actual_state }}</td>
<td><a href="/servers/{{ server.id }}">View</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No servers yet.</td></tr>
{% endfor %}
</tbody>
</table>
<p class="muted">Use the navigation to manage servers, blueprints, and overlays.</p>
</section>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Log In | left4me{% endblock %}
{% block content %}
<section class="panel auth-panel">
<h1>Log in to left4me</h1>
<p class="muted">Use your local account to manage Left 4 Dead 2 servers.</p>
<form method="post" action="/login" class="stack">
{% if next_target %}<input type="hidden" name="next" value="{{ next_target }}">{% endif %}
<label>
Username
<input name="username" autocomplete="username" required autofocus>
</label>
<label>
Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">log in</button>
</form>
</section>
{% endblock %}

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

@ -3,14 +3,59 @@
{% block title %}Server {{ server.name }} | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Server: {{ server.name }}</h1>
<p><strong>Port:</strong> {{ server.port }}</p>
<p><strong>Desired:</strong> {{ server.desired_state }} | <strong>Actual:</strong> {{ server.actual_state }}</p>
<section class="panel">
<div class="page-heading">
<h1>Server: {{ server.name }}</h1>
<div class="button-row">
{% for operation in ["initialize", "start", "stop"] %}
<form method="post" action="/servers/{{ server.id }}/{{ operation }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">{{ operation }}</button>
</form>
{% endfor %}
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">delete</button>
</form>
</div>
</div>
<table class="definition-table">
<tbody>
<tr><th>Name</th><td>{{ server.name }}</td></tr>
<tr><th>Port</th><td>{{ server.port }}</td></tr>
<tr><th>Blueprint</th><td>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</td></tr>
<tr><th>Desired state</th><td>{{ server.desired_state }}</td></tr>
<tr><th>Actual state</th><td>{{ server.actual_state }}</td></tr>
<tr><th>Last error</th><td>{{ server.last_error or "-" }}</td></tr>
</tbody>
</table>
</section>
<section class="card">
<h2>Live Logs</h2>
<pre id="server-log-stream" class="log-stream" data-server-log-url="/servers/{{ server.id }}/logs/stream"></pre>
<section class="panel">
<h2>Blueprint</h2>
<h3>Overlay order</h3>
<ol>
{% for name in overlay_names %}<li>{{ name }}</li>{% else %}<li class="muted">No overlays configured.</li>{% endfor %}
</ol>
<h3>Arguments</h3>
<pre class="code-block">{{ arguments | join('\n') }}</pre>
<h3>Config</h3>
<pre class="code-block">{{ config_lines | join('\n') }}</pre>
</section>
<section class="panel">
<h2>Current / Recent Job</h2>
{% if latest_job %}
<table class="definition-table"><tbody><tr><th>Operation</th><td>{{ latest_job.operation }}</td></tr><tr><th>State</th><td>{{ latest_job.state }}</td></tr></tbody></table>
<pre class="log-stream" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
{% else %}
<p class="muted">No jobs have run for this server.</p>
{% endif %}
</section>
<section class="panel">
<h2>Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Servers | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Servers</h1>
<table class="table">
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
<tbody>
{% for server, blueprint in rows %}
<tr>
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
<td>{{ server.port }}</td>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ server.desired_state }}</td>
<td>{{ server.actual_state }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No servers configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -30,9 +30,81 @@ def seed_user(tmp_path, monkeypatch):
return user_id
def test_public_signup(client) -> None:
response = client.post("/signup", data={"username": "alice", "password": "secret"})
def test_login_page_renders_form(client) -> None:
response = client.get("/login")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert '<form method="post" action="/login"' in text
assert 'name="username"' in text
assert 'name="password"' in text
assert "signup" not in text.lower()
def test_login_page_drops_unsafe_encoded_next(client) -> None:
response = client.get("/login?next=/%5Cevil.com")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'name="next"' not in text
assert "evil.com" not in text
def test_signup_routes_are_gone(client) -> None:
assert client.get("/signup").status_code == 404
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
def test_login_redirects_to_safe_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "/servers"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/servers")
def test_login_ignores_unsafe_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "https://example.com/steal"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")
def test_login_ignores_backslash_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "/\\evil.com"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")
def test_login_ignores_percent_encoded_backslash_next(client) -> None:
with session_scope() as session:
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
response = client.post(
"/login",
data={"username": "alice", "password": "secret", "next": "/%5Cevil.com"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")
def test_login_sets_session(client) -> None:

View file

@ -83,3 +83,35 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
client, blueprint_id = linked_blueprint
response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"})
assert response.status_code == 409
def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None:
create = user_client.post(
"/blueprints",
data={
"name": "comp",
"arguments": "-tickrate 100",
"config": "sv_consistency 1",
"overlay_ids": ["2", "1"],
"overlay_position_1": "2",
"overlay_position_2": "1",
},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
update = user_client.post(
"/blueprints/1",
data={
"name": "edited",
"arguments": "-tickrate 100\n+sv_lan 0",
"config": "sv_consistency 1\nsv_allow_lobby_connect_only 0",
"overlay_ids": ["1", "2"],
"overlay_position_1": "1",
"overlay_position_2": "2",
},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
assert update.headers["Location"] == "/blueprints/1"

View file

@ -1,3 +1,5 @@
from pathlib import Path
from sqlalchemy import text
import pytest
@ -21,7 +23,7 @@ def seeded_job_logs(tmp_path, monkeypatch):
session.add(user)
session.flush()
job = Job(user_id=user.id, server_id=None, operation="install", state="queued")
job = Job(user_id=user.id, server_id=None, operation="install", state="succeeded")
session.add(job)
session.flush()
@ -67,3 +69,38 @@ def test_sse_resume_from_last_seq(seeded_job_logs) -> None:
response = client.get(f"/jobs/{job_id}/stream?last_seq=5")
assert response.status_code == 200
def test_sse_replays_custom_job_log_events(seeded_job_logs) -> None:
app, job_id, user_id = seeded_job_logs
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
response = client.get(f"/jobs/{job_id}/stream?last_seq=5")
text = response.get_data(as_text=True)
assert "id: 6\n" in text
assert "event: stdout\n" in text
assert "data: line-6\n\n" in text
assert "data: line-5\n\n" not in text
def test_sse_resumes_from_last_event_id_header(seeded_job_logs) -> None:
app, job_id, user_id = seeded_job_logs
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
response = client.get(f"/jobs/{job_id}/stream", headers={"Last-Event-ID": "6"})
text = response.get_data(as_text=True)
assert "data: line-7\n\n" in text
assert "data: line-6\n\n" not in text
def test_sse_js_handles_job_log_custom_events() -> None:
js = Path("l4d2web/static/js/sse.js").read_text()
assert 'addEventListener("stdout"' in js
assert 'addEventListener("stderr"' in js

View file

@ -1,8 +1,16 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import subprocess
import pytest
from sqlalchemy import select
from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, Job, Server, User
from l4d2web.services import l4d2_facade
from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs, run_worker_once
@dataclass
@ -12,54 +20,303 @@ class DummyJob:
@pytest.fixture
def worker_fixture(tmp_path, monkeypatch):
def worker_app(tmp_path, monkeypatch):
from l4d2web.app import create_app
from l4d2web.db import init_db
db_url = f"sqlite:///{tmp_path/'worker.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
class WorkerFixture:
def run_once(self):
state = SchedulerState()
state.running_servers.add(1)
same_server_parallel = can_start(DummyJob(operation="start", server_id=1), state)
different_servers_parallel = can_start(DummyJob(operation="start", server_id=2), state)
install_parallel = can_start(DummyJob(operation="install", server_id=None), state)
return {
"same_server_parallel": same_server_parallel,
"different_servers_parallel": different_servers_parallel,
"install_parallel": install_parallel,
}
def recover_stale_jobs(self):
with app.app_context():
return recover_stale_jobs()
return WorkerFixture()
return app
def test_same_server_jobs_serialized(worker_fixture) -> None:
result = worker_fixture.run_once()
assert result["same_server_parallel"] is False
@pytest.fixture
def seeded_worker(worker_app):
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
blueprint = Blueprint(user_id=user.id, name="default", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
server_one = Server(
user_id=user.id,
blueprint_id=blueprint.id,
name="alpha",
port=27015,
last_error="old error",
)
server_two = Server(user_id=user.id, blueprint_id=blueprint.id, name="bravo", port=27016)
session.add_all([server_one, server_two])
session.flush()
ids = SimpleNamespace(user=user.id, server_one=server_one.id, server_two=server_two.id)
return worker_app, ids
def test_different_servers_can_run_parallel(worker_fixture) -> None:
result = worker_fixture.run_once()
assert result["different_servers_parallel"] is True
def add_job(
user_id: int,
operation: str,
*,
server_id: int | None,
state: str = "queued",
created_at: datetime | None = None,
) -> int:
now = datetime.now(UTC)
with session_scope() as session:
job = Job(
user_id=user_id,
server_id=server_id,
operation=operation,
state=state,
created_at=created_at or now,
updated_at=created_at or now,
)
if state == "running":
job.started_at = now
session.add(job)
session.flush()
return job.id
def test_install_global_exclusive(worker_fixture) -> None:
result = worker_fixture.run_once()
assert result["install_parallel"] is False
def load_job(job_id: int) -> Job:
with session_scope() as session:
job = session.scalar(select(Job).where(Job.id == job_id))
assert job is not None
return job
def test_recover_stale_running_jobs(worker_fixture) -> None:
recovered = worker_fixture.recover_stale_jobs()
assert recovered >= 0
def test_scheduler_predicates() -> None:
state = SchedulerState()
state.running_servers.add(1)
assert can_start(DummyJob(operation="start", server_id=1), state) is False
assert can_start(DummyJob(operation="start", server_id=2), state) is True
assert can_start(DummyJob(operation="install", server_id=None), state) is False
def test_run_worker_once_claims_oldest_runnable_job(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
calls = []
older = datetime.now(UTC) - timedelta(minutes=2)
newer = datetime.now(UTC)
old_job_id = add_job(ids.user, "initialize", server_id=ids.server_one, created_at=older)
new_job_id = add_job(ids.user, "initialize", server_id=ids.server_two, created_at=newer)
monkeypatch.setattr(l4d2_facade, "initialize_server", lambda server_id, **kwargs: calls.append(server_id))
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="stopped"))
with app.app_context():
assert run_worker_once() is True
assert calls == [ids.server_one]
assert load_job(old_job_id).state == "succeeded"
assert load_job(new_job_id).state == "queued"
def test_successful_start_job_logs_and_refreshes_server_state(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
job_id = add_job(ids.user, "start", server_id=ids.server_one)
calls = []
def fake_initialize(server_id, *, on_stdout=None, on_stderr=None):
calls.append(("initialize", server_id))
on_stdout("initialized")
on_stderr("init warning")
def fake_start(server_id, *, on_stdout=None, on_stderr=None):
calls.append(("start", server_id))
on_stdout("started")
monkeypatch.setattr(l4d2_facade, "initialize_server", fake_initialize)
monkeypatch.setattr(l4d2_facade, "start_server", fake_start)
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="running"))
with app.app_context():
assert run_worker_once() is True
with session_scope() as session:
job = session.scalar(select(Job).where(Job.id == job_id))
server = session.scalar(select(Server).where(Server.id == ids.server_one))
lines = [(row.seq, row.stream, row.line) for row in job_logs_for(session, job_id)]
assert calls == [("initialize", ids.server_one), ("start", ids.server_one)]
assert job is not None
assert job.state == "succeeded"
assert job.exit_code == 0
assert job.started_at is not None
assert job.finished_at is not None
assert job.updated_at is not None
assert lines == [
(1, "stdout", "initialized"),
(2, "stderr", "init warning"),
(3, "stdout", "started"),
]
assert server is not None
assert server.actual_state == "running"
assert server.last_error == ""
def job_logs_for(session, job_id: int):
from l4d2web.models import JobLog
return session.scalars(select(JobLog).where(JobLog.job_id == job_id).order_by(JobLog.seq)).all()
def test_called_process_error_fails_job_and_sets_server_error(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
job_id = add_job(ids.user, "stop", server_id=ids.server_one)
def fail_stop(server_id, **kwargs):
raise subprocess.CalledProcessError(returncode=7, cmd=["stop"], stderr="stop failed")
monkeypatch.setattr(l4d2_facade, "stop_server", fail_stop)
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="stopped"))
with app.app_context():
assert run_worker_once() is True
with session_scope() as session:
job = session.scalar(select(Job).where(Job.id == job_id))
server = session.scalar(select(Server).where(Server.id == ids.server_one))
lines = [row.line for row in job_logs_for(session, job_id)]
assert job is not None
assert job.state == "failed"
assert job.exit_code == 7
assert server is not None
assert server.last_error == "stop failed"
assert "stop failed" in lines
def test_refresh_failure_does_not_hide_operation_failure(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
job_id = add_job(ids.user, "stop", server_id=ids.server_one)
def fail_stop(server_id, **kwargs):
raise subprocess.CalledProcessError(returncode=7, cmd=["stop"], stderr="stop failed")
monkeypatch.setattr(l4d2_facade, "stop_server", fail_stop)
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: (_ for _ in ()).throw(RuntimeError("status down")))
with app.app_context():
assert run_worker_once() is True
with session_scope() as session:
job = session.scalar(select(Job).where(Job.id == job_id))
server = session.scalar(select(Server).where(Server.id == ids.server_one))
lines = [row.line for row in job_logs_for(session, job_id)]
assert job is not None
assert job.state == "failed"
assert job.exit_code == 7
assert server is not None
assert server.last_error == "stop failed"
assert "status refresh failed: status down" in lines
def test_unexpected_exception_fails_job_with_exit_code_one(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
job_id = add_job(ids.user, "delete", server_id=ids.server_one)
monkeypatch.setattr(l4d2_facade, "delete_server", lambda server_id, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")))
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="unknown"))
with app.app_context():
assert run_worker_once() is True
job = load_job(job_id)
assert job.state == "failed"
assert job.exit_code == 1
def test_same_server_jobs_do_not_overlap(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
add_job(ids.user, "start", server_id=ids.server_one, state="running")
queued_id = add_job(ids.user, "stop", server_id=ids.server_one)
monkeypatch.setattr(l4d2_facade, "stop_server", lambda server_id, **kwargs: pytest.fail("must not run"))
with app.app_context():
assert run_worker_once() is False
assert load_job(queued_id).state == "queued"
def test_different_server_jobs_can_be_claimed_while_other_server_runs(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
add_job(ids.user, "start", server_id=ids.server_one, state="running")
queued_id = add_job(ids.user, "stop", server_id=ids.server_two)
monkeypatch.setattr(l4d2_facade, "stop_server", lambda server_id, **kwargs: None)
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="stopped"))
with app.app_context():
assert run_worker_once() is True
assert load_job(queued_id).state == "succeeded"
def test_install_job_not_claimed_while_server_job_runs(seeded_worker) -> None:
app, ids = seeded_worker
add_job(ids.user, "start", server_id=ids.server_one, state="running")
install_id = add_job(ids.user, "install", server_id=None)
with app.app_context():
assert run_worker_once() is False
assert load_job(install_id).state == "queued"
def test_server_job_not_claimed_while_install_runs(seeded_worker) -> None:
app, ids = seeded_worker
add_job(ids.user, "install", server_id=None, state="running")
queued_id = add_job(ids.user, "initialize", server_id=ids.server_one)
with app.app_context():
assert run_worker_once() is False
assert load_job(queued_id).state == "queued"
def test_recover_stale_running_jobs(worker_app) -> None:
with session_scope() as session:
user = User(username="bob", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
job = Job(user_id=user.id, server_id=None, operation="install", state="running")
session.add(job)
session.flush()
job_id = job.id
with worker_app.app_context():
assert recover_stale_jobs() == 1
recovered = load_job(job_id)
assert recovered.state == "failed"
assert recovered.finished_at is not None
def test_worker_startup_skipped_during_testing(monkeypatch, tmp_path) -> None:
from l4d2web import app as app_module
called = []
db_url = f"sqlite:///{tmp_path/'startup.db'}"
monkeypatch.setattr(app_module, "start_job_workers", lambda app: called.append(app))
app_module.create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
assert called == []
def test_worker_startup_when_enabled_outside_testing(monkeypatch, tmp_path) -> None:
from l4d2web import app as app_module
called = []
db_url = f"sqlite:///{tmp_path/'startup-enabled.db'}"
monkeypatch.setattr(app_module, "start_job_workers", lambda app: called.append(app))
app = app_module.create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
assert called == [app]

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

View file

@ -1,9 +1,10 @@
import pytest
from pathlib import Path
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, Server, User
from l4d2web.models import Blueprint, BlueprintOverlay, Job, Overlay, Server, User
@pytest.fixture
@ -22,6 +23,11 @@ def auth_client_with_server(tmp_path, monkeypatch):
session.add(blueprint)
session.flush()
overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard")
session.add(overlay)
session.flush()
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=overlay.id, position=0))
session.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015))
session.flush()
user_id = user.id
@ -59,14 +65,216 @@ def user_client_and_other_blueprint(tmp_path, monkeypatch):
return client, blueprint_id
def test_dashboard_renders_server_and_status(auth_client_with_server) -> None:
def test_dashboard_is_simple_landing_page(auth_client_with_server) -> None:
response = auth_client_with_server.get("/dashboard")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "alpha" in text
assert "Dashboard" in text
assert "Use the navigation to manage servers, blueprints, and overlays." in text
assert "alpha" not in text
def test_shell_nav_uses_main_sections(auth_client_with_server) -> None:
response = auth_client_with_server.get("/dashboard")
text = response.get_data(as_text=True)
assert 'href="/dashboard"' in text
assert 'href="/servers"' in text
assert 'href="/blueprints"' in text
assert 'href="/overlays"' in text
assert 'action="/logout"' in text
assert "csrf_token" in text
def test_css_tokens_define_neutral_light_and_dark_theme() -> None:
css = Path("l4d2web/static/css/tokens.css").read_text()
for token in [
"--color-bg",
"--color-surface",
"--color-text",
"--color-muted",
"--color-border",
"--color-link",
"--space-base",
"--space-xs",
"--space-s",
"--space-m",
"--space-l",
"--space-xl",
"--space-2xl",
"--radius-s",
"--radius-m",
"--line",
]:
assert token in css
assert "prefers-color-scheme: dark" in css
assert "radial-gradient" not in Path("l4d2web/static/css/layout.css").read_text()
def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> None:
response = auth_client_with_server.get("/servers/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Server: alpha" in text
assert 'action="/servers/1/start"' in text
assert 'action="/servers/1/stop"' in text
assert 'action="/servers/1/initialize"' in text
assert 'action="/servers/1/delete"' in text
assert 'href="/blueprints/1"' in text
assert 'data-sse-url="/servers/1/logs/stream"' in text
def test_servers_page_links_server_names(auth_client_with_server) -> None:
response = auth_client_with_server.get("/servers")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert '<a href="/servers/1">alpha</a>' in text
assert "View" not in text
assert ">details<" not in text
def test_non_admin_does_not_see_admin_nav(auth_client_with_server) -> None:
response = auth_client_with_server.get("/dashboard")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'href="/admin"' not in text
def test_admin_can_use_admin_pages(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'admin-pages.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
admin_page = client.get("/admin")
assert admin_page.status_code == 200
assert 'action="/admin/install"' in admin_page.get_data(as_text=True)
assert client.get("/admin/users").status_code == 200
assert client.get("/admin/jobs").status_code == 200
assert 'href="/admin"' in client.get("/dashboard").get_data(as_text=True)
def test_admin_can_enqueue_runtime_install_job(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'admin-install.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
sess["csrf_token"] = "test-token"
response = client.post("/admin/install", headers={"X-CSRF-Token": "test-token"})
assert response.status_code == 302
assert response.headers["Location"].endswith("/admin/jobs")
with session_scope() as session:
job = session.query(Job).one()
assert job.user_id == admin_id
assert job.server_id is None
assert job.operation == "install"
assert job.state == "queued"
def test_non_admin_cannot_open_admin_pages(auth_client_with_server) -> None:
assert auth_client_with_server.get("/admin").status_code == 403
assert auth_client_with_server.get("/admin/users").status_code == 403
assert auth_client_with_server.get("/admin/jobs").status_code == 403
with auth_client_with_server.session_transaction() as sess:
sess["csrf_token"] = "test-token"
assert auth_client_with_server.post("/admin/install", headers={"X-CSRF-Token": "test-token"}).status_code == 403
def test_anonymous_protected_page_redirects_to_login_with_next(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'anonymous-pages.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
client = app.test_client()
response = client.get("/servers")
assert response.status_code == 302
assert response.headers["Location"].endswith("/login?next=/servers")
def test_root_redirects_by_auth_state(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'root-redirect.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
client = app.test_client()
anonymous_response = client.get("/")
assert anonymous_response.status_code == 302
assert anonymous_response.headers["Location"].endswith("/login")
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
user_id = user.id
with client.session_transaction() as sess:
sess["user_id"] = user_id
logged_in_response = client.get("/")
assert logged_in_response.status_code == 302
assert logged_in_response.headers["Location"].endswith("/dashboard")
def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None:
client, blueprint_id = user_client_and_other_blueprint
response = client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 403
def test_blueprint_pages_fixture_has_ordered_overlay_data(auth_client_with_server) -> None:
response = auth_client_with_server.get("/blueprints/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
def test_blueprints_page_links_blueprint_names(auth_client_with_server) -> None:
response = auth_client_with_server.get("/blueprints")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert '<a href="/blueprints/1">default</a>' in text
assert "View" not in text
def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> None:
response = auth_client_with_server.get("/blueprints/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Overlay order matters" in text
assert 'name="arguments"' in text
assert 'name="config"' in text
assert 'name="overlay_ids"' in text
assert 'name="overlay_position_1"' in text

View file

@ -69,3 +69,22 @@ def test_reassign_blueprint_anytime(user_client_with_blueprints) -> None:
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 200
def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
create_response = client.post(
"/servers",
data=json.dumps({"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}),
content_type="application/json",
headers={"X-CSRF-Token": "test-token"},
)
server_id = create_response.get_json()["id"]
response = client.post(
f"/servers/{server_id}/start",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == f"/servers/{server_id}"