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:
500
app/routes/admin_cms.py
Normal file
500
app/routes/admin_cms.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""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 ````.
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user