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:
127
app/main.py
127
app/main.py
@@ -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__)
|
||||
|
||||
Reference in New Issue
Block a user