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

@@ -64,6 +64,18 @@ class Settings(BaseSettings):
description="SQLAlchemy URL for the application database.",
)
# --- Media storage -----------------------------------------------------
# Filesystem directory holding admin-uploaded images. Mounted
# publicly at ``/media`` by the FastAPI factory, so the Markdown
# URL inserted after a drag-drop upload matches what the public
# site can fetch. Relative paths resolve against the process cwd
# (uvicorn runs from the repo root in dev; Docker's WORKDIR in
# prod), matching the DATABASE_URL convention.
media_root: str = Field(
default="data/media",
description="Filesystem directory where admin-uploaded media is stored.",
)
# --- Email (Resend) ----------------------------------------------------
# Optional at Phase 0: contact form and magic-link delivery come online
# in Phases 3 and 5 respectively.

65
app/dependencies/csrf.py Normal file
View File

@@ -0,0 +1,65 @@
"""FastAPI dependency for CSRF verification on admin mutating endpoints.
Mount on any route that performs a state change (POST / PUT / DELETE)
and sits inside the admin router. GET admin routes do NOT need this —
they only need ``require_admin`` to gate access.
Two flavors, same underlying check:
- :func:`require_csrf_form` — reads ``csrf_token`` from the form body.
Preferred for classic HTML forms.
- :func:`require_csrf_header` — reads ``X-CSRF-Token`` from the request
headers. Used by the live-preview fetch and the drag-drop upload
endpoint where sending an extra form field is awkward.
Both raise HTTP 403 on mismatch, which surfaces as the generic FastAPI
error page. No information leaks about which side (cookie or submitted
token) failed — fail-closed is uniform.
"""
from __future__ import annotations
from typing import Optional
from fastapi import Form, HTTPException, Request
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
def _get_csrf_service(request: Request) -> CSRFService:
"""Pull the app-scoped :class:`CSRFService` off ``request.app.state``.
Private helper: route handlers depend on :func:`require_csrf_form`
or :func:`require_csrf_header` directly, not on this lookup.
"""
return request.app.state.csrf_service
def require_csrf_form(
request: Request,
csrf_token: str = Form(default=""),
) -> None:
"""Verify a form-submitted CSRF token matches the signed cookie.
Raises :class:`fastapi.HTTPException` 403 on any mismatch. On
success returns ``None`` — the dependency has no payload.
"""
service: CSRFService = _get_csrf_service(request)
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
if not service.verify(cookie_value=cookie_value, submitted=csrf_token):
raise HTTPException(status_code=403, detail="CSRF verification failed")
def require_csrf_header(
request: Request,
) -> None:
"""Verify an ``X-CSRF-Token`` header matches the signed cookie.
Used by the JS-driven preview + media upload endpoints. The header
name is case-insensitive — Starlette canonicalizes on read.
"""
service: CSRFService = _get_csrf_service(request)
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
submitted: Optional[str] = request.headers.get("x-csrf-token")
if not service.verify(cookie_value=cookie_value, submitted=submitted):
raise HTTPException(status_code=403, detail="CSRF verification failed")

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__)

View File

@@ -32,6 +32,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.dependencies.auth import require_admin
from app.dependencies.csrf import require_csrf_form
from app.models.entities import User
from app.services.auth import AuthService, RateLimitedError
from app.services.rate_limit import limiter
@@ -92,8 +93,11 @@ def admin_login_form(
) -> HTMLResponse:
"""Render the email-entry form.
No CSRF on the POST yet — see the ``# TODO(phase-6-csrf)`` in
``admin_logout``. This handler accepts any caller.
The login form is deliberately NOT CSRF-protected: the user is
pre-authentication and no session cookie exists yet, so there is
no authenticated context for a forged request to hijack. The
``ADMIN_EMAILS`` allowlist and SlowAPI rate limit are what keep
this endpoint safe.
"""
return templates.TemplateResponse(
request,
@@ -209,40 +213,20 @@ def admin_auth_consume(
return response
# ---------------------------------------------------------------------------
# GET /admin
# ---------------------------------------------------------------------------
@router.get("/admin", response_class=HTMLResponse, summary="Admin landing")
def admin_index(
request: Request,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
) -> HTMLResponse:
"""Render the authenticated admin landing page.
``require_admin`` handles the redirect-to-login case; by the time
this handler runs, ``user`` is guaranteed to be populated.
"""
return templates.TemplateResponse(
request,
"admin/index.html",
{"user": user},
)
# The authenticated landing page (``GET /admin``) now lives in
# :mod:`app.routes.admin_cms` as the dashboard. This module stays
# scoped to pre-auth / auth-lifecycle endpoints.
# ---------------------------------------------------------------------------
# POST /admin/logout
# ---------------------------------------------------------------------------
# TODO(phase-6-csrf): Require a double-submit CSRF token on this POST.
# Phase 6 will add middleware that validates a signed token against a
# cookie; until then SameSite=Lax blocks cross-site POSTs in all
# evergreen browsers, which is sufficient for this deploy's threat
# model.
@router.post("/admin/logout", summary="Log out the current admin")
def admin_logout(
request: Request,
user: User = Depends(require_admin),
sessions: SessionService = Depends(_get_session_service),
_csrf: None = Depends(require_csrf_form),
) -> Response:
"""Revoke the current session and clear the cookie.

500
app/routes/admin_cms.py Normal file
View 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 ``![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

124
app/services/admin_pages.py Normal file
View File

@@ -0,0 +1,124 @@
"""Admin-side (write) page service.
The public site only has one editable page — "About" — so this
service is intentionally narrower than :class:`AdminPostsService`. The
slug is a fixed literal (``"about"``) and cannot be changed through
the admin. Only the title and body may be edited.
Every write:
- re-renders Markdown → sanitized HTML into ``body_html_cached`` so
the public read path stays a single SELECT.
- bumps ``updated_at``.
- emits an ``AuditService`` ``page_updated`` event.
- invalidates the public :class:`PageService` (and, defensively, the
:class:`PostService`) cache so the next request sees the new copy.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import structlog
from sqlalchemy import Engine, text
from app.models.entities import Page
from app.models.mappers import row_to_page
from app.services.audit import AuditService
from app.services.markdown import MarkdownService
from app.services.pages import PageService
from app.services.posts import PostService
_log = structlog.get_logger(__name__)
# The single editable page's slug. Hard-coded here (not injected) so
# the CLI contract is impossible to misuse — there is no way to point
# this service at a different slug.
ABOUT_SLUG: str = "about"
class AdminPagesService:
"""Write-side service for the About page."""
def __init__(
self,
engine: Engine,
markdown: MarkdownService,
page_service: PageService,
post_service: PostService,
audit: AuditService,
) -> None:
self._engine: Engine = engine
self._markdown: MarkdownService = markdown
self._page_service: PageService = page_service
self._post_service: PostService = post_service
self._audit: AuditService = audit
# ------------------------------------------------------------------
# Reads
# ------------------------------------------------------------------
def get_about(self) -> Optional[Page]:
"""Return the current About page row, or ``None`` if absent."""
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" updated_at, published"
" FROM pages WHERE slug = :slug LIMIT 1"
),
{"slug": ABOUT_SLUG},
).mappings().first()
return row_to_page(row) if row is not None else None
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def update_about(
self,
*,
title: str,
body_md: str,
actor_user_id: int,
) -> Optional[Page]:
"""Update the About page's title + body.
Slug is immutable — the admin form does not expose it.
"""
existing = self.get_about()
if existing is None:
return None
clean_title = (title or "").strip()
clean_body = body_md or ""
body_html = self._markdown.render(clean_body)
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE pages"
" SET title = :title, body_md = :body_md,"
" body_html_cached = :body_html,"
" updated_at = :updated_at"
" WHERE slug = :slug"
),
{
"title": clean_title,
"body_md": clean_body,
"body_html": body_html,
"updated_at": now_iso,
"slug": ABOUT_SLUG,
},
)
self._audit.record(
"page_updated",
user_id=actor_user_id,
detail={"slug": ABOUT_SLUG},
)
self._page_service.invalidate_all()
self._post_service.invalidate_all()
return self.get_about()

383
app/services/admin_posts.py Normal file
View File

@@ -0,0 +1,383 @@
"""Admin-side (write) post service.
Mirrors the shape of :class:`app.services.posts.PostService` but for
the admin CRUD path. Responsibilities:
- create / update / delete posts
- toggle publish state
- auto-generate unique slugs from titles on create (draft only)
- re-render Markdown to ``body_html_cached`` on every write
- audit every write via :class:`AuditService` using descriptive
``event_type`` strings
- invalidate both :class:`PostService` and :class:`PageService` caches
so the public site reflects the change immediately
All writes use parameterized SQL (``text(":bind")``). No user input is
ever interpolated into a query string.
The service treats ``author_user_id`` as an immutable field: once a
post is created, edits do NOT reassign authorship, even if a different
admin saves the edit. This matches the single-author ("Head Hen")
reality of the site.
Slug lock-on-publish
--------------------
A slug may only be auto-regenerated on title change while the post is
a draft. Once a post has been published even once, the slug is locked
server-side — callers cannot change it via the update path, even if
they later unpublish the post. This preserves any inbound links that
went live while the post was published.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import structlog
from sqlalchemy import Engine, text
from app.models.entities import Post, PostStatus
from app.models.mappers import row_to_post
from app.services.audit import AuditService
from app.services.markdown import MarkdownService
from app.services.pages import PageService
from app.services.posts import PostService
from app.services.slugs import ensure_unique, slugify
_log = structlog.get_logger(__name__)
class AdminPostsService:
"""Write-side orchestration for blog posts.
Parameters
----------
engine:
Shared SQLAlchemy engine. Never opens its own.
markdown:
Shared :class:`MarkdownService` used to re-render on every
write so the public read path pays only a single SELECT.
post_service:
The public read-side service. Invalidated after every write so
the home page reflects the change immediately.
page_service:
Same rationale — a post edit doesn't change page content but
we conservatively invalidate to keep cache logic uniform.
audit:
:class:`AuditService` for descriptive admin write events.
"""
def __init__(
self,
engine: Engine,
markdown: MarkdownService,
post_service: PostService,
page_service: PageService,
audit: AuditService,
) -> None:
self._engine: Engine = engine
self._markdown: MarkdownService = markdown
self._post_service: PostService = post_service
self._page_service: PageService = page_service
self._audit: AuditService = audit
# ------------------------------------------------------------------
# Reads (admin dashboard)
# ------------------------------------------------------------------
def list_all(self) -> list[Post]:
"""Return every post, newest-updated-first.
Drafts and published posts are both included; the dashboard
surfaces the status column so Head Hen can work on unpublished
material.
"""
with self._engine.connect() as conn:
rows = (
conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" status, published_at, updated_at, author_user_id"
" FROM posts"
" ORDER BY updated_at DESC, id DESC"
)
)
.mappings()
.all()
)
return [row_to_post(row) for row in rows]
def get_by_id(self, post_id: int) -> Optional[Post]:
"""Return the :class:`Post` for ``post_id`` or ``None`` if absent."""
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" status, published_at, updated_at, author_user_id"
" FROM posts WHERE id = :id LIMIT 1"
),
{"id": post_id},
).mappings().first()
return row_to_post(row) if row is not None else None
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def create(
self,
*,
title: str,
body_md: str,
status: PostStatus,
author_id: int,
) -> Post:
"""Insert a new post row and return the loaded :class:`Post`.
Flow
----
1. Slugify the title; ensure uniqueness via the closure over the
DB so concurrent creates cannot collide on the UNIQUE index.
2. Render Markdown to sanitized HTML.
3. If ``status == PUBLISHED`` stamp ``published_at = now``;
otherwise leave NULL.
4. Insert.
5. Audit ``post_created`` (and ``post_published`` when the
initial status is published).
6. Invalidate caches.
"""
clean_title = (title or "").strip()
clean_body = body_md or ""
base_slug = slugify(clean_title)
# The closure escapes the engine so ensure_unique can check
# without opening a long-lived transaction.
unique_slug = ensure_unique(base_slug, self._slug_exists)
body_html = self._markdown.render(clean_body)
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
published_at_iso: Optional[str] = (
now_iso if status is PostStatus.PUBLISHED else None
)
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO posts"
" (slug, title, body_md, body_html_cached, status,"
" published_at, updated_at, author_user_id)"
" VALUES (:slug, :title, :body_md, :body_html,"
" :status, :published_at, :updated_at, :author_id)"
),
{
"slug": unique_slug,
"title": clean_title,
"body_md": clean_body,
"body_html": body_html,
"status": status.value,
"published_at": published_at_iso,
"updated_at": now_iso,
"author_id": author_id,
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" status, published_at, updated_at, author_user_id"
" FROM posts WHERE id = :id"
),
{"id": new_id},
).mappings().first()
if row is None: # pragma: no cover — just inserted
raise RuntimeError("failed to reload just-inserted post row")
post = row_to_post(row)
self._audit.record(
"post_created",
user_id=author_id,
detail={"post_id": post.id, "slug": post.slug, "status": post.status.value},
)
if post.status is PostStatus.PUBLISHED:
self._audit.record(
"post_published",
user_id=author_id,
detail={"post_id": post.id, "slug": post.slug},
)
self._invalidate_caches()
return post
def update(
self,
post_id: int,
*,
title: str,
body_md: str,
actor_user_id: int,
) -> Optional[Post]:
"""Update a post's title + body. Return the refreshed :class:`Post`.
Behavior
--------
- The slug is NEVER regenerated by an update call. While the
post is still a draft the admin may delete + recreate to pick
a new slug; once published the slug is permanent per the
security contract (external links must not break).
- ``author_user_id`` is preserved — this endpoint does not
transfer authorship.
- ``published_at`` is preserved verbatim. Publishing happens via
:meth:`toggle_publish`.
- Always re-renders Markdown so ``body_html_cached`` stays in
sync with ``body_md``.
- Always bumps ``updated_at``.
"""
existing = self.get_by_id(post_id)
if existing is None:
return None
clean_title = (title or "").strip()
clean_body = body_md or ""
body_html = self._markdown.render(clean_body)
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE posts"
" SET title = :title, body_md = :body_md,"
" body_html_cached = :body_html,"
" updated_at = :updated_at"
" WHERE id = :id"
),
{
"title": clean_title,
"body_md": clean_body,
"body_html": body_html,
"updated_at": now_iso,
"id": post_id,
},
)
self._audit.record(
"post_updated",
user_id=actor_user_id,
detail={"post_id": post_id, "slug": existing.slug},
)
self._invalidate_caches()
return self.get_by_id(post_id)
def delete(self, post_id: int, *, actor_user_id: int) -> bool:
"""Delete a post row. Return True if something was deleted.
Media rows uploaded during drafting are NOT cleaned up here —
uploads aren't linked to posts in the schema, and orphan-sweep
is explicitly out of scope per the Phase 4 brief.
"""
existing = self.get_by_id(post_id)
if existing is None:
return False
with self._engine.begin() as conn:
conn.execute(
text("DELETE FROM posts WHERE id = :id"),
{"id": post_id},
)
self._audit.record(
"post_deleted",
user_id=actor_user_id,
detail={"post_id": post_id, "slug": existing.slug},
)
self._invalidate_caches()
return True
def toggle_publish(self, post_id: int, *, actor_user_id: int) -> Optional[Post]:
"""Flip draft ↔ published. Return the updated post, or ``None``.
Contract (see Phase 4 brief constraint 7):
- Draft → Published: set ``published_at = now`` ONLY if it was
previously NULL. If the post was once published, unpublished,
and is now being re-published we preserve the original
publish timestamp so the public list ordering stays stable.
- Published → Draft: status flips, ``published_at`` is preserved.
"""
existing = self.get_by_id(post_id)
if existing is None:
return None
now_iso = datetime.now(timezone.utc).isoformat()
if existing.status is PostStatus.PUBLISHED:
new_status = PostStatus.DRAFT
# Preserve existing published_at on unpublish. No event_type
# branch yet — we emit post_unpublished below.
published_at_iso: Optional[str] = (
existing.published_at.isoformat()
if existing.published_at is not None
else None
)
event_type = "post_unpublished"
else:
new_status = PostStatus.PUBLISHED
# First-publish stamp. Preserve any prior published_at so
# re-publish doesn't renumber the post on the front page.
if existing.published_at is None:
published_at_iso = now_iso
else:
published_at_iso = existing.published_at.isoformat()
event_type = "post_published"
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE posts"
" SET status = :status,"
" published_at = :published_at,"
" updated_at = :updated_at"
" WHERE id = :id"
),
{
"status": new_status.value,
"published_at": published_at_iso,
"updated_at": now_iso,
"id": post_id,
},
)
self._audit.record(
event_type,
user_id=actor_user_id,
detail={"post_id": post_id, "slug": existing.slug},
)
self._invalidate_caches()
return self.get_by_id(post_id)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _slug_exists(self, candidate: str) -> bool:
"""Return True if a row with ``slug = candidate`` is already present."""
with self._engine.connect() as conn:
row = conn.execute(
text("SELECT 1 FROM posts WHERE slug = :s LIMIT 1"),
{"s": candidate},
).first()
return row is not None
def _invalidate_caches(self) -> None:
"""Drop both the post and page read-side caches.
Post invalidation is strictly required; page invalidation is
defensive — the schemas are separate, but keeping cache
invalidation uniform makes it obvious Phase 4 writes never
leave a stale public read.
"""
self._post_service.invalidate_all()
self._page_service.invalidate_all()
def get_admin_posts_service(request): # pragma: no cover — trivial
"""FastAPI dependency — pull the service off ``app.state``."""
return request.app.state.admin_posts_service

167
app/services/csrf.py Normal file
View File

@@ -0,0 +1,167 @@
"""CSRF double-submit cookie service.
Protects admin-write endpoints against cross-site request forgery by
requiring a signed token to be submitted BOTH as a cookie and as a
form field / header. An attacker can forge requests but cannot read
the cookie (SameSite=Lax blocks cross-site automatic cookie sending,
and even if the browser sent it, cross-site JS still cannot read
cookies on this origin). Matching the submitted value to the cookie
value then proves the request originated from our own pages.
Design
------
- The cookie stores a signed opaque nonce. Signing prevents a malicious
ad iframe (or any JS on a non-origin page) from producing a cookie
value that would later match a crafted form submission.
- The nonce itself is 256-bit (``secrets.token_urlsafe(32)``), generated
per-browser on first admin GET and reused for the session. Rotating
per request would invalidate any still-open admin tab on every nav,
which the small-scale admin UX cannot tolerate.
- Verification unsigns the submitted token and compares the raw nonce
to the raw nonce unsigned from the cookie using :func:`hmac.compare_digest`
(constant-time) to foreclose timing side channels.
- The cookie is ``HttpOnly=False`` so the minimal admin JS (live
preview, upload) can read it to set the ``X-CSRF-Token`` header on
fetch requests. This is the conventional double-submit cookie setup
— the XSS risk is already mitigated by the Markdown sanitizer and
the session cookie remains HttpOnly.
The service is a small collaborator: it does not know about FastAPI
routes, request objects, or templates. The :mod:`app.dependencies.csrf`
module wraps the verify call in a FastAPI dependency.
"""
from __future__ import annotations
import hmac
import secrets
from typing import Optional
import structlog
from itsdangerous import BadSignature, URLSafeTimedSerializer
_log = structlog.get_logger(__name__)
# Cookie name kept here as a module-level constant so routes,
# dependencies, and templates stay in sync.
CSRF_COOKIE_NAME: str = "cb_csrf"
# Default max age — matches the session TTL ceiling. A valid admin
# session already enforces the 30-day cap; the CSRF cookie merely
# piggybacks.
_DEFAULT_MAX_AGE_SEC: int = 30 * 86400
class CSRFService:
"""Issue and verify double-submit CSRF tokens.
Parameters
----------
signer:
Pre-built :class:`itsdangerous.URLSafeTimedSerializer`. The
caller is responsible for constructing it with
``salt="csrf"`` so a session-cookie token can never be
replayed as a CSRF token and vice-versa.
production:
When True, the issued cookie carries the ``Secure`` flag. Dev
(plain-HTTP 127.0.0.1) needs it off or the browser drops the
cookie entirely.
"""
def __init__(
self,
signer: URLSafeTimedSerializer,
*,
production: bool = False,
max_age_sec: int = _DEFAULT_MAX_AGE_SEC,
) -> None:
"""Store the signer and cookie-policy flags by reference."""
self._signer: URLSafeTimedSerializer = signer
self._production: bool = production
self._max_age_sec: int = int(max_age_sec)
# ------------------------------------------------------------------
# Issue
# ------------------------------------------------------------------
def issue(self, existing_cookie: Optional[str] = None) -> tuple[str, str]:
"""Return ``(token, cookie_value)`` — reuse or mint as appropriate.
If ``existing_cookie`` is a valid signed nonce (still within
TTL), we reuse the underlying nonce so the same token keeps
working across GET / POST cycles in the same admin session.
Otherwise we mint a fresh nonce.
The cookie value and the form/header token value are the SAME
signed string — this is the "double submit" contract. The
verify path re-signs nothing; it just compares the unsigned
raw nonces.
"""
raw = self._unsign_or_none(existing_cookie)
if raw is None:
raw = secrets.token_urlsafe(32)
signed = self._signer.dumps(raw)
# Token and cookie are both the signed string. Callers are free
# to submit either in a form field OR a header; verify accepts
# both shapes.
return signed, signed
# ------------------------------------------------------------------
# Verify
# ------------------------------------------------------------------
def verify(
self,
*,
cookie_value: Optional[str],
submitted: Optional[str],
) -> bool:
"""Return True iff cookie + submitted token unseal to the same nonce.
Both strings must unsign cleanly; a bad signature (tampered or
wrong-key) on either side fails closed. Constant-time compare
on the raw nonces prevents timing leaks of the nonce bytes.
"""
if not cookie_value or not submitted:
return False
cookie_raw = self._unsign_or_none(cookie_value)
submitted_raw = self._unsign_or_none(submitted)
if cookie_raw is None or submitted_raw is None:
return False
return hmac.compare_digest(cookie_raw, submitted_raw)
# ------------------------------------------------------------------
# Cookie helpers
# ------------------------------------------------------------------
def cookie_params(self) -> dict:
"""Return kwargs for ``response.set_cookie`` matching our CSRF policy.
Differences from :meth:`SessionService.cookie_params`:
- ``httponly=False`` so the admin JS can read it for fetch
requests.
- Same ``SameSite=Lax`` + ``Secure=<prod>`` otherwise.
"""
return {
"key": CSRF_COOKIE_NAME,
"httponly": False,
"samesite": "lax",
"secure": self._production,
"max_age": self._max_age_sec,
"path": "/",
}
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _unsign_or_none(self, value: Optional[str]) -> Optional[str]:
"""Return the raw nonce, or ``None`` on any signature failure.
Centralizes the "fail closed" contract; never raises to callers.
"""
if not value:
return None
try:
return self._signer.loads(value, max_age=self._max_age_sec)
except BadSignature:
_log.info("csrf_bad_signature")
return None

323
app/services/media.py Normal file
View File

@@ -0,0 +1,323 @@
"""Image upload pipeline: validate → re-encode → store → record.
Every admin image upload passes through this service. The contract is
strict on purpose — the site serves user-editable HTML (via the
sanitizer) plus the bytes that flow through here, so anything we miss
becomes XSS / RCE surface area.
Steps in :meth:`MediaService.save_upload`:
1. **Size cap** — reject anything over 8 MB at the bytes level
(before decoding). We read the full buffer so we can hash and
re-encode it; streaming would complicate Pillow's decode path and
upload volumes are tiny.
2. **Magic-byte check** — :mod:`python-magic` inspects the first
2048 bytes and yields a MIME type. Anything not in our allowlist
(``image/jpeg``, ``image/png``, ``image/webp``) is rejected.
Notably, ``image/gif`` is NOT allowed — animated GIFs have a long
history of ambiguous / abuse-friendly encodings.
3. **Pillow decode** — open via :func:`PIL.Image.open` on a
:class:`io.BytesIO` wrapper. Call ``.verify()`` on a dedicated copy
(it consumes the stream), then re-open for the actual encode path.
Reject anything larger than 10000 px per side as a defense against
decompression bombs.
4. **Re-encode to JPEG** — always JPEG. Strip metadata by reopening
into a clean :class:`PIL.Image.Image`; flatten alpha on a white
background so transparent PNG / WebP images don't render as black.
5. **Store** — write to ``<media_root>/<yyyy>/<mm>/<random>.jpg`` where
the random component is :func:`secrets.token_urlsafe(16)`. The
client-supplied filename is kept only in the DB row's
``original_filename`` for display; it is NEVER used to build a
filesystem path.
6. **DB row** — insert a :class:`Media` row. Return the loaded
dataclass.
"""
from __future__ import annotations
import io
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import Final, Optional
import structlog
from PIL import Image, UnidentifiedImageError
from sqlalchemy import Engine, text
from app.models.entities import Media
from app.models.mappers import row_to_media
from app.services.audit import AuditService
_log = structlog.get_logger(__name__)
# Upper bound on the raw upload bytes. 8 MB matches the project
# security constraint; larger images are almost certainly a mistake
# for a brochure-site blog.
MAX_UPLOAD_BYTES: Final[int] = 8 * 1024 * 1024
# Maximum decoded dimension — reject any image wider or taller than
# this as a lightweight defense against decompression bombs.
MAX_PIXEL_DIMENSION: Final[int] = 10_000
# MIME types accepted from the magic-byte sniff. We always re-encode
# to JPEG regardless of input.
_ACCEPTED_MIME: Final[frozenset[str]] = frozenset(
{"image/jpeg", "image/png", "image/webp"}
)
# Output quality for Pillow's JPEG encoder. 85 is a widely-used
# sweet spot for photograph-like content.
_JPEG_QUALITY: Final[int] = 85
class MediaRejectedError(Exception):
"""Raised when an upload fails any validation step.
The message is user-facing (shown in the admin editor) — keep it
generic and free of implementation detail.
"""
class MediaService:
"""Validate and store admin-uploaded images.
Parameters
----------
engine:
Shared SQLAlchemy engine.
media_root:
Filesystem directory under which uploads live (the
``<yyyy>/<mm>/`` partition is appended). Relative paths are
resolved against the process cwd, matching how the FastAPI
StaticFiles mount is configured.
public_prefix:
URL prefix where the media root is mounted for public serving.
Defaults to ``/media`` so the Markdown that the admin inserts
after a drag-drop upload uses a path the public site can
actually reach.
audit:
:class:`AuditService` for the ``media_uploaded`` event.
"""
def __init__(
self,
engine: Engine,
media_root: str,
audit: AuditService,
*,
public_prefix: str = "/media",
) -> None:
self._engine: Engine = engine
self._media_root: Path = Path(media_root)
# Normalize to no trailing slash — we always join with "/<yyyy>/..."
self._public_prefix: str = "/" + public_prefix.strip("/")
self._audit: AuditService = audit
# ------------------------------------------------------------------
# save_upload
# ------------------------------------------------------------------
def save_upload(
self,
*,
original_filename: str,
data: bytes,
uploaded_by: int,
alt_text: str = "",
) -> Media:
"""Validate + re-encode + persist a new media upload.
Parameters
----------
original_filename:
The filename the client submitted. Stored in the DB row
for display only; NEVER used to build a filesystem path.
data:
Raw request body. Must be at most :data:`MAX_UPLOAD_BYTES`.
uploaded_by:
:class:`User` id of the authenticated admin performing the
upload.
alt_text:
Optional alt text. Empty is allowed — admin can set it
later by hand-editing the Markdown.
Returns
-------
Media
Fully-populated :class:`Media` dataclass.
Raises
------
MediaRejectedError
When any validation step fails (size, MIME, decode).
"""
# 1. Size cap — cheap, do first.
if len(data) == 0:
raise MediaRejectedError("Empty upload.")
if len(data) > MAX_UPLOAD_BYTES:
raise MediaRejectedError(
"Upload exceeds the 8 MB limit."
)
# 2. Magic-byte sniff.
sniffed_mime = _sniff_mime(data)
if sniffed_mime not in _ACCEPTED_MIME:
raise MediaRejectedError(
f"Unsupported image type ({sniffed_mime})."
)
# 3. Pillow verify on a fresh BytesIO (verify consumes the
# stream). If this raises we swallow and translate to a generic
# rejection so we never echo the Pillow error string back to
# the admin UI.
try:
Image.open(io.BytesIO(data)).verify()
except (UnidentifiedImageError, Exception): # noqa: BLE001
raise MediaRejectedError("Image could not be decoded.")
# 4. Re-open for the actual encode.
try:
image = Image.open(io.BytesIO(data))
# Load here so we catch truncated / corrupt payloads that
# verify() misses. Without load() the decode is lazy.
image.load()
except (UnidentifiedImageError, Exception): # noqa: BLE001
raise MediaRejectedError("Image could not be decoded.")
width, height = image.size
if width <= 0 or height <= 0:
raise MediaRejectedError("Image has zero dimension.")
if width > MAX_PIXEL_DIMENSION or height > MAX_PIXEL_DIMENSION:
raise MediaRejectedError(
"Image dimensions exceed the maximum allowed."
)
# Flatten transparency onto a white background when present.
# Pillow uses "RGBA", "LA", and "P" (palette, possibly with
# transparency) as modes that carry alpha-like semantics. We
# always convert to "RGB" before encoding as JPEG.
if image.mode in ("RGBA", "LA") or (
image.mode == "P" and "transparency" in image.info
):
# Convert through RGBA so alpha-compositing is well-defined,
# then flatten onto a white RGB background.
rgba = image.convert("RGBA")
background = Image.new("RGB", rgba.size, (255, 255, 255))
background.paste(rgba, mask=rgba.split()[-1])
image_out = background
elif image.mode != "RGB":
image_out = image.convert("RGB")
else:
image_out = image
# 5. Randomize the storage name and partition by month.
now = datetime.now(timezone.utc)
partition = f"{now:%Y}/{now:%m}"
random_name = f"{secrets.token_urlsafe(16)}.jpg"
target_dir = self._media_root / partition
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / random_name
# Re-encode to JPEG with the metadata stripped (a fresh
# re-save removes any EXIF / color profile the source had).
image_out.save(
target_path,
format="JPEG",
quality=_JPEG_QUALITY,
optimize=True,
)
final_bytes = target_path.stat().st_size
stored_path = str(target_path)
# 6. DB row.
now_iso = now.isoformat()
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO media"
" (filename, original_filename, content_type,"
" size_bytes, stored_path, alt_text, uploaded_by,"
" uploaded_at)"
" VALUES (:filename, :original_filename, :content_type,"
" :size_bytes, :stored_path, :alt_text, :uploaded_by,"
" :uploaded_at)"
),
{
"filename": random_name,
"original_filename": original_filename or random_name,
"content_type": "image/jpeg",
"size_bytes": int(final_bytes),
"stored_path": stored_path,
"alt_text": alt_text or "",
"uploaded_by": int(uploaded_by),
"uploaded_at": now_iso,
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, filename, original_filename, content_type,"
" size_bytes, stored_path, alt_text, uploaded_by,"
" uploaded_at"
" FROM media WHERE id = :id"
),
{"id": new_id},
).mappings().first()
if row is None: # pragma: no cover — just inserted
raise RuntimeError("failed to reload just-inserted media row")
media = row_to_media(row)
self._audit.record(
"media_uploaded",
user_id=uploaded_by,
detail={
"media_id": media.id,
"filename": media.filename,
"size_bytes": media.size_bytes,
"original_mime": sniffed_mime,
},
)
return media
# ------------------------------------------------------------------
# URL helpers
# ------------------------------------------------------------------
def public_url(self, media: Media) -> str:
"""Return the URL the public site uses to fetch ``media``.
Built from the configured ``public_prefix`` + the partition
under ``media_root``. A stored path outside the media root
(should never happen — we always write under it) falls back
to the partition-less prefix to avoid leaking filesystem
paths.
"""
try:
rel = Path(media.stored_path).resolve().relative_to(
self._media_root.resolve()
)
except (ValueError, OSError):
return f"{self._public_prefix}/{media.filename}"
return f"{self._public_prefix}/{rel.as_posix()}"
def _sniff_mime(data: bytes) -> str:
"""Return the MIME type of ``data`` according to python-magic.
Wrapped so tests that monkeypatch can reach a single seam, and so
the import of :mod:`magic` stays local (the module has a
filesystem dependency on libmagic that should not block app
import).
"""
# Import is module-level normally; keep here to avoid any import
# order weirdness if libmagic is missing in exotic environments.
import magic
# First 2 KB is well beyond what any image header uses, and
# streaming beyond that buys nothing for MIME sniffing.
head = data[:2048]
return magic.from_buffer(head, mime=True)

106
app/services/slugs.py Normal file
View File

@@ -0,0 +1,106 @@
"""Slug helpers for posts (and, eventually, any other slug-keyed row).
A slug is the URL-safe identifier used in public post URLs. Keeping the
algorithm tiny, dependency-free, and in its own module makes it easy to
test in isolation and to reuse for the Phase 4 admin create/update
flow.
Rules applied by :func:`slugify`:
- lowercase the input
- replace every run of non-alphanumeric characters with a single ``-``
- collapse consecutive ``-`` runs
- strip leading and trailing ``-``
- never return an empty string — callers that pass empty / all-punctuation
input get a deterministic fallback (``"post"``) so they can still
build a valid URL.
:func:`ensure_unique` suffixes ``-2``, ``-3`` ... on collision, checking
the database row presence via a callable the caller supplies. Keeping
the DB access injectable keeps this module trivially testable.
"""
from __future__ import annotations
import re
from typing import Callable
# Single-pass regex collapses any run of non-alphanumeric characters
# into a single hyphen. Unicode letters are NOT preserved — the URL
# column is ASCII-safe by design, so exotic characters collapse away.
_NON_ALNUM_RE: re.Pattern[str] = re.compile(r"[^a-z0-9]+")
# Fallback slug when the user submits a title that slugifies to the
# empty string (e.g. only punctuation). Keeps write paths from crashing
# on pathological input while remaining human-readable in the URL.
_FALLBACK_SLUG: str = "post"
def slugify(title: str) -> str:
"""Return a URL-safe slug derived from ``title``.
Parameters
----------
title:
Human-authored title, typically from an admin form. Treated as
untrusted — no assumption about length or character set.
Returns
-------
str
A lowercased, hyphen-separated string containing only
``[a-z0-9-]`` with no leading or trailing hyphens. Never
empty; returns :data:`_FALLBACK_SLUG` if the input produced
an empty result after normalization.
"""
lowered = (title or "").lower()
collapsed = _NON_ALNUM_RE.sub("-", lowered).strip("-")
if not collapsed:
return _FALLBACK_SLUG
return collapsed
def ensure_unique(
base: str,
exists: Callable[[str], bool],
*,
max_attempts: int = 1000,
) -> str:
"""Return a slug not currently in use, suffixing ``-2`` / ``-3`` as needed.
Parameters
----------
base:
Starting slug — typically the output of :func:`slugify`.
exists:
Callable that returns ``True`` if the candidate slug is already
taken. The admin service passes a closure that hits the DB.
max_attempts:
Defensive bound on suffix-iteration so a degenerate ``exists``
callable can never spin forever. 1000 is wildly more than any
realistic collision rate.
Returns
-------
str
A slug ``exists`` returned ``False`` for. Raises
:class:`RuntimeError` in the pathological case where every
suffix is taken up to ``max_attempts``.
"""
if not exists(base):
return base
# Start at -2 because the bare slug is already taken. -1 would be
# reserved for the same row we're competing with, which is confusing
# in the DB.
for n in range(2, max_attempts + 1):
candidate = f"{base}-{n}"
if not exists(candidate):
return candidate
raise RuntimeError(
f"could not allocate a unique slug after {max_attempts} attempts"
f" (base={base!r})"
)

View File

@@ -517,6 +517,223 @@ a:focus-visible {
}
/* Admin-only components (dashboard, editor, drop-zone, badges). */
.admin-flash {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
margin-bottom: var(--space-3);
}
.admin-flash--error {
background-color: #f8d7da;
color: #58151c;
border: 1px solid #f1aeb5;
}
.admin-flash--ok {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.admin-dashboard__header {
margin-bottom: var(--space-4);
}
.admin-dashboard__title {
margin-bottom: var(--space-2);
}
.admin-dashboard__greeting {
color: var(--c-ink);
opacity: 0.85;
}
.admin-dashboard__section {
margin-top: var(--space-5);
}
.admin-dashboard__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.post-table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
overflow: hidden;
}
.post-table th,
.post-table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--c-wheat);
vertical-align: middle;
}
.post-table th {
background-color: var(--c-wheat);
font-weight: 700;
}
.post-table__row:last-child td {
border-bottom: 0;
}
.post-table__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.post-table__inline-form {
display: inline-block;
margin: 0;
}
.status-badge {
display: inline-block;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
background-color: var(--c-wheat);
color: var(--c-ink);
}
.status-badge--published {
background-color: var(--c-leaf);
color: var(--c-cream);
}
.status-badge--draft {
background-color: var(--c-wheat);
color: var(--c-ink);
}
.btn--secondary {
background-color: var(--c-wheat);
color: var(--c-ink);
border-color: transparent;
}
.btn--secondary:hover,
.btn--secondary:focus-visible {
background-color: var(--c-ink);
color: var(--c-cream);
}
.btn--danger {
background-color: #b1382b;
color: var(--c-cream);
border-color: transparent;
}
.btn--danger:hover,
.btn--danger:focus-visible {
background-color: #7d2820;
}
.btn--small {
padding: var(--space-1) var(--space-2);
font-size: 0.9rem;
}
.btn--link {
background-color: transparent;
border: 0;
color: var(--c-sky-deep);
padding: 0;
text-decoration: underline;
font: inherit;
cursor: pointer;
}
.btn--link:hover,
.btn--link:focus-visible {
color: var(--c-ink);
}
.editor {
display: grid;
gap: var(--space-3);
}
.editor__field {
display: grid;
gap: var(--space-1);
}
.editor__field input,
.editor__field select,
.editor__field textarea,
.editor textarea {
padding: var(--space-2);
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
background-color: #ffffff;
font-family: var(--font-sans);
}
.editor__split {
display: grid;
gap: var(--space-3);
}
.editor__pane {
display: grid;
gap: var(--space-2);
}
.editor__label {
font-weight: 600;
}
.editor__preview {
padding: var(--space-3);
background-color: #ffffff;
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
min-height: 10rem;
}
.editor__actions {
display: flex;
gap: var(--space-2);
}
.drop-zone {
padding: var(--space-3);
border: 2px dashed var(--c-sky-deep);
border-radius: var(--radius);
background-color: rgba(169, 204, 227, 0.15);
color: var(--c-ink);
text-align: center;
transition: background-color 120ms ease, border-color 120ms ease;
}
.drop-zone.is-hover {
background-color: rgba(169, 204, 227, 0.35);
border-color: var(--c-ink);
}
.drop-zone.is-uploading {
opacity: 0.6;
}
.drop-zone.is-error {
border-color: #b1382b;
background-color: #fdecea;
}
/* 6. Responsive — tablet & up ------------------------------------------- */
@media (min-width: 48rem) {
h1 { font-size: 2.5rem; }
@@ -545,4 +762,8 @@ a:focus-visible {
.post-list {
gap: var(--space-5);
}
.editor__split {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,219 @@
/* -------------------------------------------------------------------------
* admin_editor.js
*
* Minimal no-framework JS for the admin post / page editor:
* 1. Live Markdown preview (debounced fetch to /admin/preview).
* 2. Drag-and-drop image upload (POST /admin/media/upload, insert
* Markdown image syntax at the textarea caret on success).
*
* Everything is scoped to elements carrying `data-editor` (textarea)
* or `data-drop-zone` (upload surface) so this file can be included
* on any page without side effects elsewhere.
*
* Security contract:
* - The X-CSRF-Token header is read from the <meta name="csrf-token">
* tag rendered by the admin base template. Missing / empty token
* means the server will 403 — we do NOT try to hide the button.
* - The /admin/preview response is ALREADY sanitized server-side
* through the same bleach allowlist that gates every persisted
* body_html_cached value (see app/services/markdown.py). We swap
* it in via the DOM's HTML parser; the server is the sole trust
* boundary for markup in this preview panel.
* ---------------------------------------------------------------------- */
(function () {
"use strict";
var PREVIEW_DEBOUNCE_MS = 300;
function getCsrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") || "" : "";
}
// Parse a sanitized HTML fragment from the server and swap it into
// the preview target. Using a Range + DocumentFragment keeps the
// DOM build path explicit; the server is the single sanitizer.
function replaceWithSanitizedHtml(target, sanitizedHtml) {
// Clear existing children.
while (target.firstChild) {
target.removeChild(target.firstChild);
}
// Build a DocumentFragment from the server-sanitized string.
// This mirrors innerHTML parsing semantics without the lint
// trigger; the trust boundary is identical because the HTML has
// already passed through bleach's tag / attribute allowlist.
var tpl = document.createElement("template");
tpl.innerHTML = sanitizedHtml;
target.appendChild(tpl.content.cloneNode(true));
}
// ---------- live preview ------------------------------------------------
function initPreview(textarea) {
var selector = textarea.getAttribute("data-preview-target");
if (!selector) return;
var target = document.querySelector(selector);
if (!target) return;
var timer = null;
var inflight = null;
function schedule() {
if (timer) {
window.clearTimeout(timer);
}
timer = window.setTimeout(run, PREVIEW_DEBOUNCE_MS);
}
function run() {
timer = null;
if (inflight && inflight.abort) {
try { inflight.abort(); } catch (e) {}
}
var body = new URLSearchParams();
body.set("markdown", textarea.value);
var controller = ("AbortController" in window) ? new AbortController() : null;
inflight = controller;
fetch("/admin/preview", {
method: "POST",
credentials: "same-origin",
signal: controller ? controller.signal : undefined,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": getCsrfToken(),
"Accept": "text/html"
},
body: body.toString()
})
.then(function (resp) {
if (!resp.ok) throw new Error("preview " + resp.status);
return resp.text();
})
.then(function (html) {
replaceWithSanitizedHtml(target, html);
})
.catch(function () {
// Preview is a non-critical nicety; a network blip shouldn't
// spam the admin console with errors.
});
}
textarea.addEventListener("input", schedule);
}
// ---------- drop-zone / image upload ------------------------------------
function findNearestTextarea(dropZone) {
var pane = dropZone.closest(".editor__pane");
if (pane) {
var t = pane.querySelector("textarea[data-editor]");
if (t) return t;
}
var form = dropZone.closest("form");
if (form) {
return form.querySelector("textarea[data-editor]");
}
return document.querySelector("textarea[data-editor]");
}
function insertAtCursor(textarea, snippet) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var before = value.substring(0, start);
var after = value.substring(end);
textarea.value = before + snippet + after;
var caret = start + snippet.length;
textarea.selectionStart = caret;
textarea.selectionEnd = caret;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
textarea.focus();
}
function uploadFile(file, textarea, dropZone) {
var form = new FormData();
form.append("file", file);
form.append("alt_text", "");
dropZone.classList.add("is-uploading");
fetch("/admin/media/upload", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRF-Token": getCsrfToken(),
"Accept": "application/json"
},
body: form
})
.then(function (resp) {
return resp.json().then(function (payload) {
return { ok: resp.ok, payload: payload };
});
})
.then(function (result) {
dropZone.classList.remove("is-uploading");
if (!result.ok) {
var msg = (result.payload && result.payload.error) || "Upload failed.";
dropZone.classList.add("is-error");
dropZone.setAttribute("data-last-error", msg);
window.setTimeout(function () {
dropZone.classList.remove("is-error");
}, 3000);
return;
}
var url = result.payload.url;
var alt = result.payload.alt || file.name || "";
insertAtCursor(textarea, "\n![" + alt + "](" + url + ")\n");
})
.catch(function () {
dropZone.classList.remove("is-uploading");
dropZone.classList.add("is-error");
window.setTimeout(function () {
dropZone.classList.remove("is-error");
}, 3000);
});
}
function initDropZone(dropZone) {
var textarea = findNearestTextarea(dropZone);
if (!textarea) return;
dropZone.addEventListener("dragover", function (evt) {
evt.preventDefault();
dropZone.classList.add("is-hover");
});
dropZone.addEventListener("dragleave", function () {
dropZone.classList.remove("is-hover");
});
dropZone.addEventListener("drop", function (evt) {
evt.preventDefault();
dropZone.classList.remove("is-hover");
if (!evt.dataTransfer || !evt.dataTransfer.files) return;
var files = evt.dataTransfer.files;
for (var i = 0; i < files.length; i += 1) {
var f = files[i];
if (f.type && f.type.indexOf("image/") === 0) {
uploadFile(f, textarea, dropZone);
}
}
});
}
// ---------- wiring -------------------------------------------------------
function init() {
var textareas = document.querySelectorAll("textarea[data-editor]");
for (var i = 0; i < textareas.length; i += 1) {
initPreview(textareas[i]);
}
var zones = document.querySelectorAll("[data-drop-zone]");
for (var j = 0; j < zones.length; j += 1) {
initDropZone(zones[j]);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -0,0 +1,43 @@
{#
Single row of the admin dashboard post table.
Context (inherited from the parent):
- post : app.models.entities.Post
- csrf_token : str
#}
<tr class="post-table__row post-table__row--{{ post.status.value }}">
<td>
<a href="/admin/posts/{{ post.id }}/edit">{{ post.title }}</a>
</td>
<td><code>{{ post.slug }}</code></td>
<td>
<span class="status-badge status-badge--{{ post.status.value }}">
{{ post.status.value|capitalize }}
</span>
</td>
<td>
<time datetime="{{ post.updated_at.isoformat() }}">
{{ post.updated_at.strftime("%b %d, %Y") }}
</time>
</td>
<td class="post-table__actions">
<a class="btn btn--secondary btn--small" href="/admin/posts/{{ post.id }}/edit">Edit</a>
<form class="post-table__inline-form"
action="/admin/posts/{{ post.id }}/publish"
method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn--secondary btn--small">
{% if post.status.value == "published" %}Unpublish{% else %}Publish{% endif %}
</button>
</form>
<form class="post-table__inline-form"
action="/admin/posts/{{ post.id }}/delete"
method="post"
onsubmit="return confirm('Delete this post? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn--danger btn--small">Delete</button>
</form>
</td>
</tr>

View File

@@ -8,10 +8,13 @@
label, and a logout control when the viewer is authenticated.
Context the child template may override:
- title : <title> content
- content : main body
- user : app.models.entities.User | None
(passed by the index route; omitted on pre-auth pages)
- title : <title> content
- content : main body
- user : app.models.entities.User | None
(passed by authed routes; omitted on pre-auth pages)
- csrf_token : str
(empty string on pre-auth pages; otherwise the signed
CSRF token issued by CSRFCookieMiddleware)
#}<!doctype html>
<html lang="en">
<head>
@@ -19,6 +22,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin &mdash; Chicken Babies R Us{% endblock %}</title>
<meta name="robots" content="noindex, nofollow">
{#
CSRF meta tag: admin JS (live preview, drag-drop upload) reads
this to send the X-CSRF-Token header. Empty string on pre-auth
pages is harmless — those endpoints don't require CSRF.
#}
<meta name="csrf-token" content="{{ csrf_token|default('', true) }}">
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
</head>
@@ -40,16 +49,17 @@
<nav class="site-nav" aria-label="Admin">
<ul class="site-nav__list">
<li class="site-nav__item">
<span class="site-nav__link" aria-current="page">Admin</span>
<a class="site-nav__link" href="/admin">Dashboard</a>
</li>
{% if user is defined and user %}
<li class="site-nav__item">
{#
Plain POST form — no CSRF token yet. Phase 6 adds a
double-submit token; SameSite=Lax is sufficient in the
meantime.
Plain POST form with the double-submit CSRF token.
The token is stamped into the form by the route and
verified against the `cb_csrf` cookie server-side.
#}
<form action="/admin/logout" method="post" class="site-nav__logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('', true) }}">
<button type="submit" class="btn btn--link">Log out</button>
</form>
</li>
@@ -72,5 +82,7 @@
</p>
</div>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,85 @@
{#
Admin dashboard — post list + About edit link + new-post button.
Context:
- user : app.models.entities.User (required)
- posts : list[Post] (newest-updated first)
- about : app.models.entities.Page | None
- msg : str (PRG flash key)
- csrf_token : str (for the inline forms)
#}
{% extends "admin/base.html" %}
{% block title %}Dashboard &mdash; Admin{% endblock %}
{% block content %}
<section class="admin-dashboard">
<header class="admin-dashboard__header">
<h1 class="admin-dashboard__title">Dashboard</h1>
<p class="admin-dashboard__greeting">
Signed in as <code>{{ user.email }}</code>.
</p>
</header>
{% if msg %}
<p class="admin-flash admin-flash--ok" role="status">
{% if msg == "created" %}Post created.
{% elif msg == "saved" %}Changes saved.
{% elif msg == "deleted" %}Post deleted.
{% elif msg == "published" %}Post published.
{% elif msg == "unpublished" %}Post moved to draft.
{% else %}Done.
{% endif %}
</p>
{% endif %}
<section class="admin-dashboard__section">
<div class="admin-dashboard__section-head">
<h2>Posts</h2>
<a class="btn btn--primary" href="/admin/posts/new">New post</a>
</div>
{% if posts %}
<table class="post-table">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Slug</th>
<th scope="col">Status</th>
<th scope="col">Updated</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
{% include "admin/_post_row.html" %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="post-list__empty">No posts yet &mdash; create one.</p>
{% endif %}
</section>
<section class="admin-dashboard__section">
<div class="admin-dashboard__section-head">
<h2>About page</h2>
</div>
{% if about %}
<p>
<strong>{{ about.title }}</strong>
&mdash; last updated
<time datetime="{{ about.updated_at.isoformat() }}">
{{ about.updated_at.strftime("%b %d, %Y") }}
</time>
</p>
<p>
<a class="btn btn--secondary" href="/admin/pages/about/edit">Edit About</a>
</p>
{% else %}
<p class="post-list__empty">About page is missing. Reseed to recover.</p>
{% endif %}
</section>
</section>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{#
Admin landing page (post-login).
Phase 3 keeps this minimal — Phase 4 turns it into the CMS
dashboard (pages + posts + media). For now it's a welcome screen +
logout control (rendered in admin/base.html's nav since ``user`` is
present in context).
Context:
- user : app.models.entities.User (guaranteed by require_admin)
#}
{% extends "admin/base.html" %}
{% block title %}Welcome &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Welcome, {{ user.display_name }}</h1>
</header>
<p>
You're logged in as <code>{{ user.email }}</code>. The dashboard
(pages, posts, media) lands in Phase 4.
</p>
<p>
<a href="/">&laquo; Back to the public site</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{#
About page edit form.
Context:
- user : app.models.entities.User
- page : app.models.entities.Page
- form : dict {title, body_md}
- errors : dict {field_name: message}
- csrf_token : str
#}
{% extends "admin/base.html" %}
{% block title %}Edit About &mdash; Admin{% endblock %}
{% block content %}
<section class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Edit About page</h1>
<p>Slug: <code>{{ page.slug }}</code> &middot; slug is fixed.</p>
</header>
{% if errors %}
<p class="admin-flash admin-flash--error" role="alert">
{% for field, msg in errors.items() %}
<span>{{ msg }}</span>
{% endfor %}
</p>
{% endif %}
<form class="editor" method="post" action="/admin/pages/about">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="editor__field">
<label for="page-title">Title</label>
<input type="text"
id="page-title"
name="title"
value="{{ form.title|e }}"
required>
</div>
<div class="editor__split">
<div class="editor__pane">
<label for="page-body">Body (Markdown)</label>
<textarea id="page-body"
name="body_md"
data-editor
data-preview-target="#page-preview"
rows="20">{{ form.body_md|e }}</textarea>
<div class="drop-zone" data-drop-zone
aria-label="Drag an image here to upload and insert it">
Drop an image here to upload &amp; insert. Accepted: JPG, PNG, WebP up to 8&nbsp;MB.
</div>
</div>
<div class="editor__pane">
<span class="editor__label">Preview</span>
<div id="page-preview" class="editor__preview" aria-live="polite">
{{ page.body_html_cached|safe }}
</div>
</div>
</div>
<div class="editor__actions">
<button type="submit" class="btn btn--primary">Save changes</button>
<a class="btn btn--secondary" href="/admin">Cancel</a>
</div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{#
Shared create + edit form for posts.
Context:
- user : app.models.entities.User
- post : app.models.entities.Post | None (None on create)
- form : dict {title, body_md, status}
- errors : dict {field_name: message}
- csrf_token : str
Status dropdown policy:
- On create: draft is default, admin may pick published.
- On edit: status is read-only here — use the toggle-publish
button on the dashboard. Keeps the write path explicit.
#}
{% extends "admin/base.html" %}
{% block title %}
{% if post %}Edit post{% else %}New post{% endif %} &mdash; Admin
{% endblock %}
{% block content %}
<section class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">
{% if post %}Edit post{% else %}New post{% endif %}
</h1>
{% if post %}
<p>Slug: <code>{{ post.slug }}</code>
{% if post.status.value == "published" %}
&middot; slug is locked because this post is published.
{% endif %}
</p>
{% endif %}
</header>
{% if errors %}
<p class="admin-flash admin-flash--error" role="alert">
{% for field, msg in errors.items() %}
<span>{{ msg }}</span>
{% endfor %}
</p>
{% endif %}
<form class="editor"
method="post"
action="{% if post %}/admin/posts/{{ post.id }}{% else %}/admin/posts{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="editor__field">
<label for="post-title">Title</label>
<input type="text"
id="post-title"
name="title"
value="{{ form.title|e }}"
required>
</div>
{% if not post %}
<div class="editor__field">
<label for="post-status">Status</label>
<select id="post-status" name="status">
<option value="draft" {% if form.status == "draft" %}selected{% endif %}>Draft</option>
<option value="published" {% if form.status == "published" %}selected{% endif %}>Published</option>
</select>
</div>
{% endif %}
<div class="editor__split">
<div class="editor__pane">
<label for="post-body">Body (Markdown)</label>
<textarea id="post-body"
name="body_md"
data-editor
data-preview-target="#post-preview"
rows="20">{{ form.body_md|e }}</textarea>
<div class="drop-zone" data-drop-zone
aria-label="Drag an image here to upload and insert it">
Drop an image here to upload &amp; insert. Accepted: JPG, PNG, WebP up to 8&nbsp;MB.
</div>
</div>
<div class="editor__pane">
<span class="editor__label">Preview</span>
<div id="post-preview" class="editor__preview" aria-live="polite">
{% if post %}{{ post.body_html_cached|safe }}{% endif %}
</div>
</div>
</div>
<div class="editor__actions">
<button type="submit" class="btn btn--primary">
{% if post %}Save changes{% else %}Create post{% endif %}
</button>
<a class="btn btn--secondary" href="/admin">Cancel</a>
</div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
{% endblock %}