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>
501 lines
17 KiB
Python
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 ````.
|
|
"""
|
|
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
|