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

@@ -24,6 +24,20 @@ Phase 3 additions:
``RateLimitExceeded`` exception handler (renders
``admin/rate_limited.html`` + HTTP 429 + audit row).
- Include the admin router.
Phase 4 additions:
- Build a separate :class:`itsdangerous.URLSafeTimedSerializer`
salted with ``"csrf"`` and wrap it in a :class:`CSRFService`.
- Instantiate :class:`MarkdownService`,
:class:`AdminPostsService`, :class:`AdminPagesService`, and
:class:`MediaService` and attach them to ``app.state``.
- Mount the admin CMS router.
- Install a lightweight middleware that issues / refreshes the
CSRF cookie on admin GET requests and exposes the token via
``request.state.csrf_token`` for template rendering.
- Mount ``settings.media_root`` at ``/media`` as a StaticFiles
route so uploaded images are publicly reachable under the
Markdown URLs the admin inserts.
"""
from __future__ import annotations
@@ -32,10 +46,12 @@ from pathlib import Path
import structlog
from fastapi import FastAPI, Request
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from itsdangerous import URLSafeTimedSerializer
from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from app import __version__
from app.config import get_settings
@@ -43,11 +59,17 @@ from app.db import build_engine, run_migrations
from app.logging_config import configure_logging
from app.models.seed import run_seed
from app.routes.admin import router as admin_router
from app.routes.admin_cms import router as admin_cms_router
from app.routes.health import router as health_router
from app.routes.public import router as public_router
from app.services.admin_pages import AdminPagesService
from app.services.admin_posts import AdminPostsService
from app.services.audit import AuditService
from app.services.auth import AuthService
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
from app.services.email import EmailService
from app.services.markdown import MarkdownService
from app.services.media import MediaService
from app.services.pages import PageService
from app.services.posts import PostService
from app.services.rate_limit import create_limiter
@@ -62,6 +84,55 @@ _TEMPLATES_DIR: Path = _PACKAGE_ROOT / "templates"
_STATIC_DIR: Path = _PACKAGE_ROOT / "static"
class CSRFCookieMiddleware(BaseHTTPMiddleware):
"""Issue / refresh the CSRF cookie on admin GET responses.
The middleware is intentionally narrow: it only fires for requests
whose path starts with ``/admin`` AND method is GET. That's the
set of responses that render HTML with a form waiting to be
submitted; mutating endpoints never set the cookie themselves.
For every such request we:
1. Read the existing ``cb_csrf`` cookie (if any).
2. Call :meth:`CSRFService.issue` which reuses the underlying
nonce when the cookie is still valid — this means a tab that
GETs the dashboard, then POSTs 30 minutes later, still
matches.
3. Stash the token on ``request.state.csrf_token`` so route
handlers pass it into Jinja.
4. After the downstream response is produced, set / refresh the
cookie header.
"""
def __init__(self, app, csrf_service: CSRFService) -> None:
"""Store the service by reference; BaseHTTPMiddleware takes the ASGI app."""
super().__init__(app)
self._csrf_service: CSRFService = csrf_service
async def dispatch(self, request: Request, call_next):
"""Run the admin-GET issue hook around the downstream handler."""
should_issue = (
request.url.path.startswith("/admin")
and request.method == "GET"
)
if should_issue:
existing = request.cookies.get(CSRF_COOKIE_NAME)
token, cookie_value = self._csrf_service.issue(existing)
request.state.csrf_token = token
else:
cookie_value = None
response: Response = await call_next(request)
if should_issue and cookie_value is not None:
response.set_cookie(
value=cookie_value,
**self._csrf_service.cookie_params(),
)
return response
def create_app() -> FastAPI:
"""Build and return the FastAPI application.
@@ -94,6 +165,11 @@ def create_app() -> FastAPI:
run_migrations(engine)
run_seed(engine)
# Ensure the media storage directory exists — Starlette's
# StaticFiles mount refuses to start without a real directory.
media_root = Path(settings.media_root)
media_root.mkdir(parents=True, exist_ok=True)
application = FastAPI(
title="Chicken Babies R Us",
version=__version__,
@@ -113,6 +189,15 @@ def create_app() -> FastAPI:
name="static",
)
# Public mount for admin-uploaded images. Kept separate from /static
# so admin uploads never collide with the brand assets shipped with
# the container image.
application.mount(
"/media",
StaticFiles(directory=str(media_root)),
name="media",
)
# Single shared Jinja2 environment. Storing it on ``app.state`` keeps
# route modules free of an import dependency on this module (which
# would be circular once admin/auth routers are added in later
@@ -151,6 +236,47 @@ def create_app() -> FastAPI:
application.state.session_service = session_service
application.state.auth_service = auth_service
# --- Phase 4 wiring -----------------------------------------------------
# CSRF signer: separate salt so a session cookie never validates
# as a CSRF token (domain separation via salt).
csrf_signer = URLSafeTimedSerializer(settings.secret_key, salt="csrf")
csrf_service = CSRFService(
csrf_signer,
production=(settings.app_env == "production"),
)
application.state.csrf_service = csrf_service
markdown_service = MarkdownService()
application.state.markdown_service = markdown_service
admin_posts_service = AdminPostsService(
engine=engine,
markdown=markdown_service,
post_service=application.state.post_service,
page_service=application.state.page_service,
audit=audit_service,
)
admin_pages_service = AdminPagesService(
engine=engine,
markdown=markdown_service,
page_service=application.state.page_service,
post_service=application.state.post_service,
audit=audit_service,
)
media_service = MediaService(
engine=engine,
media_root=str(media_root),
audit=audit_service,
)
application.state.admin_posts_service = admin_posts_service
application.state.admin_pages_service = admin_pages_service
application.state.media_service = media_service
# CSRF cookie middleware — narrow to admin GETs; everything else
# passes through untouched so public routes are unaffected.
application.add_middleware(CSRFCookieMiddleware, csrf_service=csrf_service)
# SlowAPI limiter + exception handler. The limiter is a module-level
# singleton in app.services.rate_limit (because @limiter.limit has
# to be applied at endpoint-definition time, before include_router).
@@ -168,6 +294,7 @@ def create_app() -> FastAPI:
application.include_router(health_router)
application.include_router(public_router)
application.include_router(admin_router)
application.include_router(admin_cms_router)
# Single structured startup event. Do NOT include secret material.
logger = structlog.get_logger(__name__)