feat: phase 4 admin CMS — dashboard, editor, media, CSRF

Head Hen CMS end-to-end: dashboard lists all posts (drafts + published),
Markdown editor with live preview + drag-drop image upload, Pillow media
pipeline re-encoding every upload to JPEG, post CRUD + publish toggle +
hard delete, About page edit, and double-submit CSRF cookie enforced on
every admin mutating endpoint (Phase 3's TODO markers resolved).

Slug auto-generated on create and server-locked once a post has been
published. Unpublish preserves `published_at` so re-publish keeps
original date ordering. Every admin write invalidates the read-side
Post/Page TTL caches and records an `auth_events` audit row.

CSRF middleware is narrow by design — issues/refreshes the `cb_csrf`
cookie only on `GET /admin*`, and mutating endpoints opt in via
`require_csrf_form` or `require_csrf_header` Depends. Public routes,
healthz, and pre-auth login stay untouched.

64 new tests cover slugs, CSRF, media, admin posts/pages services, and
end-to-end CMS routes. Tests never mock the DB — real temp SQLite files
per the CLAUDE.md mandate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 20:42:01 -05:00
parent 76875a455e
commit 9a8506970c
30 changed files with 3831 additions and 74 deletions

View File

@@ -32,6 +32,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.dependencies.auth import require_admin
from app.dependencies.csrf import require_csrf_form
from app.models.entities import User
from app.services.auth import AuthService, RateLimitedError
from app.services.rate_limit import limiter
@@ -92,8 +93,11 @@ def admin_login_form(
) -> HTMLResponse:
"""Render the email-entry form.
No CSRF on the POST yet — see the ``# TODO(phase-6-csrf)`` in
``admin_logout``. This handler accepts any caller.
The login form is deliberately NOT CSRF-protected: the user is
pre-authentication and no session cookie exists yet, so there is
no authenticated context for a forged request to hijack. The
``ADMIN_EMAILS`` allowlist and SlowAPI rate limit are what keep
this endpoint safe.
"""
return templates.TemplateResponse(
request,
@@ -209,40 +213,20 @@ def admin_auth_consume(
return response
# ---------------------------------------------------------------------------
# GET /admin
# ---------------------------------------------------------------------------
@router.get("/admin", response_class=HTMLResponse, summary="Admin landing")
def admin_index(
request: Request,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
) -> HTMLResponse:
"""Render the authenticated admin landing page.
``require_admin`` handles the redirect-to-login case; by the time
this handler runs, ``user`` is guaranteed to be populated.
"""
return templates.TemplateResponse(
request,
"admin/index.html",
{"user": user},
)
# The authenticated landing page (``GET /admin``) now lives in
# :mod:`app.routes.admin_cms` as the dashboard. This module stays
# scoped to pre-auth / auth-lifecycle endpoints.
# ---------------------------------------------------------------------------
# POST /admin/logout
# ---------------------------------------------------------------------------
# TODO(phase-6-csrf): Require a double-submit CSRF token on this POST.
# Phase 6 will add middleware that validates a signed token against a
# cookie; until then SameSite=Lax blocks cross-site POSTs in all
# evergreen browsers, which is sufficient for this deploy's threat
# model.
@router.post("/admin/logout", summary="Log out the current admin")
def admin_logout(
request: Request,
user: User = Depends(require_admin),
sessions: SessionService = Depends(_get_session_service),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Revoke the current session and clear the cookie.