Files
chicken_babies_site/app/routes/admin_cms.py
Phillip Tarrant 9a8506970c 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>
2026-04-21 20:42:01 -05:00

501 lines
17 KiB
Python

"""Admin CMS routes — dashboard, post CRUD, About edit, media upload.
These handlers all live behind :func:`require_admin`. Mutating
endpoints additionally pull a CSRF dependency
(:func:`require_csrf_form` or :func:`require_csrf_header`) so the
double-submit cookie is verified before any state change.
Each handler does the absolute minimum: pull services off the app
state, call their methods, translate the result into an HTTP
response. No business logic, no SQL.
Routing map
-----------
- ``GET /admin`` — dashboard.
- ``GET /admin/posts/new`` — create form.
- ``POST /admin/posts`` — create handler (CSRF).
- ``GET /admin/posts/{id}/edit`` — edit form.
- ``POST /admin/posts/{id}`` — update (CSRF).
- ``POST /admin/posts/{id}/delete`` — delete (CSRF).
- ``POST /admin/posts/{id}/publish`` — publish toggle (CSRF).
- ``GET /admin/pages/about/edit`` — About edit form.
- ``POST /admin/pages/about`` — About update (CSRF).
- ``POST /admin/media/upload`` — multipart upload (header CSRF).
- ``POST /admin/preview`` — Markdown → HTML preview (header CSRF).
"""
from __future__ import annotations
from typing import Optional
import structlog
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.dependencies.auth import require_admin
from app.dependencies.csrf import require_csrf_form, require_csrf_header
from app.models.entities import PostStatus, User
from app.services.admin_pages import AdminPagesService
from app.services.admin_posts import AdminPostsService
from app.services.markdown import MarkdownService
from app.services.media import MediaRejectedError, MediaService
router: APIRouter = APIRouter(tags=["admin-cms"])
_log = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# DI helpers
# ---------------------------------------------------------------------------
def _get_templates(request: Request) -> Jinja2Templates:
"""Return the app-scoped :class:`Jinja2Templates`."""
return request.app.state.templates
def _get_admin_posts(request: Request) -> AdminPostsService:
"""Return the app-scoped :class:`AdminPostsService`."""
return request.app.state.admin_posts_service
def _get_admin_pages(request: Request) -> AdminPagesService:
"""Return the app-scoped :class:`AdminPagesService`."""
return request.app.state.admin_pages_service
def _get_media(request: Request) -> MediaService:
"""Return the app-scoped :class:`MediaService`."""
return request.app.state.media_service
def _get_markdown(request: Request) -> MarkdownService:
"""Return the app-scoped :class:`MarkdownService`."""
return request.app.state.markdown_service
def _get_csrf_token_for_template(request: Request) -> str:
"""Return the CSRF token to embed in the rendered admin templates.
The middleware (see :mod:`app.main`) sets ``request.state.csrf_token``
on every admin GET after ensuring the cookie is in sync. Handlers
pull it from request state and pass it into the template context.
"""
return getattr(request.state, "csrf_token", "") or ""
# ---------------------------------------------------------------------------
# GET /admin — dashboard
# ---------------------------------------------------------------------------
@router.get("/admin", response_class=HTMLResponse, summary="Admin dashboard")
def admin_dashboard(
request: Request,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
admin_posts: AdminPostsService = Depends(_get_admin_posts),
admin_pages: AdminPagesService = Depends(_get_admin_pages),
) -> HTMLResponse:
"""Render the dashboard: posts list + About edit link.
Posts are sorted newest-updated-first and include both drafts and
published posts — the admin table surfaces the status badge.
"""
posts = admin_posts.list_all()
about = admin_pages.get_about()
# Optional flash from PRG query param — we keep it minimal.
msg = request.query_params.get("msg") or ""
return templates.TemplateResponse(
request,
"admin/dashboard.html",
{
"user": user,
"posts": posts,
"about": about,
"msg": msg,
"csrf_token": _get_csrf_token_for_template(request),
},
)
# ---------------------------------------------------------------------------
# GET /admin/posts/new
# ---------------------------------------------------------------------------
@router.get(
"/admin/posts/new",
response_class=HTMLResponse,
summary="Admin: new post form",
)
def admin_post_new_form(
request: Request,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
) -> HTMLResponse:
"""Render the empty create form.
No post id → the form POSTs to ``/admin/posts``. Slug is not shown
because we auto-generate it from the title on create.
"""
return templates.TemplateResponse(
request,
"admin/post_form.html",
{
"user": user,
"post": None,
"form": {"title": "", "body_md": "", "status": PostStatus.DRAFT.value},
"errors": {},
"csrf_token": _get_csrf_token_for_template(request),
},
)
# ---------------------------------------------------------------------------
# POST /admin/posts — create
# ---------------------------------------------------------------------------
@router.post("/admin/posts", summary="Admin: create post")
def admin_post_create(
request: Request,
title: str = Form(default=""),
body_md: str = Form(default=""),
status: str = Form(default=PostStatus.DRAFT.value),
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
admin_posts: AdminPostsService = Depends(_get_admin_posts),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Handle the new-post submission and redirect to the dashboard.
Minimal validation:
- title must be non-empty after strip
- status must be a valid :class:`PostStatus` value
On validation error we re-render the form with the submitted
values so Head Hen doesn't retype.
"""
errors: dict[str, str] = {}
clean_title = (title or "").strip()
if not clean_title:
errors["title"] = "Title is required."
try:
status_enum = PostStatus(status)
except ValueError:
errors["status"] = "Invalid status."
status_enum = PostStatus.DRAFT
if errors:
return templates.TemplateResponse(
request,
"admin/post_form.html",
{
"user": user,
"post": None,
"form": {
"title": title,
"body_md": body_md,
"status": status_enum.value,
},
"errors": errors,
"csrf_token": _get_csrf_token_for_template(request),
},
status_code=400,
)
admin_posts.create(
title=clean_title,
body_md=body_md or "",
status=status_enum,
author_id=user.id,
)
return RedirectResponse(url="/admin?msg=created", status_code=303)
# ---------------------------------------------------------------------------
# GET /admin/posts/{id}/edit
# ---------------------------------------------------------------------------
@router.get(
"/admin/posts/{post_id}/edit",
response_class=HTMLResponse,
summary="Admin: edit post form",
)
def admin_post_edit_form(
request: Request,
post_id: int,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
admin_posts: AdminPostsService = Depends(_get_admin_posts),
) -> HTMLResponse:
"""Render the edit form for an existing post."""
post = admin_posts.get_by_id(post_id)
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
return templates.TemplateResponse(
request,
"admin/post_form.html",
{
"user": user,
"post": post,
"form": {
"title": post.title,
"body_md": post.body_md,
"status": post.status.value,
},
"errors": {},
"csrf_token": _get_csrf_token_for_template(request),
},
)
# ---------------------------------------------------------------------------
# POST /admin/posts/{id} — update
# ---------------------------------------------------------------------------
@router.post("/admin/posts/{post_id}", summary="Admin: update post")
def admin_post_update(
request: Request,
post_id: int,
title: str = Form(default=""),
body_md: str = Form(default=""),
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
admin_posts: AdminPostsService = Depends(_get_admin_posts),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Apply title + body edits to an existing post.
Slug changes are not permitted via this path — server-side
enforcement of the "slug lock on publish" policy (see
:class:`AdminPostsService`).
"""
existing = admin_posts.get_by_id(post_id)
if existing is None:
raise HTTPException(status_code=404, detail="Post not found")
clean_title = (title or "").strip()
errors: dict[str, str] = {}
if not clean_title:
errors["title"] = "Title is required."
if errors:
return templates.TemplateResponse(
request,
"admin/post_form.html",
{
"user": user,
"post": existing,
"form": {
"title": title,
"body_md": body_md,
"status": existing.status.value,
},
"errors": errors,
"csrf_token": _get_csrf_token_for_template(request),
},
status_code=400,
)
admin_posts.update(
post_id,
title=clean_title,
body_md=body_md or "",
actor_user_id=user.id,
)
return RedirectResponse(url="/admin?msg=saved", status_code=303)
# ---------------------------------------------------------------------------
# POST /admin/posts/{id}/delete
# ---------------------------------------------------------------------------
@router.post("/admin/posts/{post_id}/delete", summary="Admin: delete post")
def admin_post_delete(
request: Request,
post_id: int,
user: User = Depends(require_admin),
admin_posts: AdminPostsService = Depends(_get_admin_posts),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Hard-delete a post row."""
deleted = admin_posts.delete(post_id, actor_user_id=user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Post not found")
return RedirectResponse(url="/admin?msg=deleted", status_code=303)
# ---------------------------------------------------------------------------
# POST /admin/posts/{id}/publish — publish/unpublish toggle
# ---------------------------------------------------------------------------
@router.post(
"/admin/posts/{post_id}/publish", summary="Admin: toggle publish state"
)
def admin_post_toggle_publish(
request: Request,
post_id: int,
user: User = Depends(require_admin),
admin_posts: AdminPostsService = Depends(_get_admin_posts),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Flip draft ↔ published."""
updated = admin_posts.toggle_publish(post_id, actor_user_id=user.id)
if updated is None:
raise HTTPException(status_code=404, detail="Post not found")
# Friendly-ish flash so the admin sees the result of the toggle.
msg = "published" if updated.status is PostStatus.PUBLISHED else "unpublished"
return RedirectResponse(url=f"/admin?msg={msg}", status_code=303)
# ---------------------------------------------------------------------------
# GET /admin/pages/about/edit
# ---------------------------------------------------------------------------
@router.get(
"/admin/pages/about/edit",
response_class=HTMLResponse,
summary="Admin: edit About page",
)
def admin_about_edit_form(
request: Request,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
admin_pages: AdminPagesService = Depends(_get_admin_pages),
) -> HTMLResponse:
"""Render the About-page edit form."""
page = admin_pages.get_about()
if page is None:
raise HTTPException(status_code=500, detail="About page missing")
return templates.TemplateResponse(
request,
"admin/page_form.html",
{
"user": user,
"page": page,
"form": {"title": page.title, "body_md": page.body_md},
"errors": {},
"csrf_token": _get_csrf_token_for_template(request),
},
)
# ---------------------------------------------------------------------------
# POST /admin/pages/about — update
# ---------------------------------------------------------------------------
@router.post("/admin/pages/about", summary="Admin: update About page")
def admin_about_update(
request: Request,
title: str = Form(default=""),
body_md: str = Form(default=""),
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
admin_pages: AdminPagesService = Depends(_get_admin_pages),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Apply edits to the About page (slug is fixed)."""
clean_title = (title or "").strip()
errors: dict[str, str] = {}
if not clean_title:
errors["title"] = "Title is required."
if errors:
page = admin_pages.get_about()
return templates.TemplateResponse(
request,
"admin/page_form.html",
{
"user": user,
"page": page,
"form": {"title": title, "body_md": body_md},
"errors": errors,
"csrf_token": _get_csrf_token_for_template(request),
},
status_code=400,
)
admin_pages.update_about(
title=clean_title,
body_md=body_md or "",
actor_user_id=user.id,
)
return RedirectResponse(url="/admin?msg=saved", status_code=303)
# ---------------------------------------------------------------------------
# POST /admin/media/upload
# ---------------------------------------------------------------------------
@router.post("/admin/media/upload", summary="Admin: upload image")
async def admin_media_upload(
request: Request,
file: UploadFile = File(...),
alt_text: str = Form(default=""),
user: User = Depends(require_admin),
media: MediaService = Depends(_get_media),
_csrf: None = Depends(require_csrf_header),
) -> JSONResponse:
"""Validate and store an uploaded image.
Response JSON is small by design — the drag-drop JS only needs a
URL to splice into the Markdown source as ``![alt](url)``.
"""
data = await file.read()
try:
record = media.save_upload(
original_filename=file.filename or "",
data=data,
uploaded_by=user.id,
alt_text=alt_text or "",
)
except MediaRejectedError as exc:
return JSONResponse(
{"error": str(exc)},
status_code=400,
)
return JSONResponse(
{
"id": record.id,
"url": media.public_url(record),
"alt": record.alt_text,
"filename": record.filename,
"size_bytes": record.size_bytes,
}
)
# ---------------------------------------------------------------------------
# POST /admin/preview
# ---------------------------------------------------------------------------
@router.post(
"/admin/preview",
response_class=HTMLResponse,
summary="Admin: Markdown preview",
)
def admin_preview(
request: Request,
markdown: str = Form(default=""),
user: User = Depends(require_admin),
md: MarkdownService = Depends(_get_markdown),
_csrf: None = Depends(require_csrf_header),
) -> HTMLResponse:
"""Render ``markdown`` through the sanitizer and return an HTML fragment.
The fragment is NOT wrapped in a full page — it is ``innerHTML``-safe
output from the same pipeline that stores ``body_html_cached``. The
route reuses :class:`MarkdownService` so preview output exactly
matches what will eventually be served to the public.
"""
rendered: str = md.render(markdown or "")
return HTMLResponse(content=rendered)
# ---------------------------------------------------------------------------
# Optional aliases for backward-compat imports
# ---------------------------------------------------------------------------
# If another module (e.g. tests) imports ``router`` from here, the
# attribute name stays stable.
_ = router # silence "not used" in linters that don't pick up FastAPI magic
# Avoid "Optional unused" when the type is only referenced via Depends.
_Optional = Optional # pragma: no cover