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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user