fix: add /posts/{slug} detail route so post titles resolve

Post cards on the home page have linked to /posts/<slug> since
Phase 1 (per the partial's inline comment), but the matching route
and template were never registered — clicking a post title returned
a JSON 404 from FastAPI. This adds:

- PostService.get_published_by_slug() — status-filtered, parameterized
  read that treats "draft" and "unknown slug" as the same 404 so
  unpublished titles cannot be enumerated via URL guessing.
- GET /posts/{slug} public route that 404s on miss.
- public/post.html detail template mirroring about.html's safe-render
  pattern for the bleach-sanitized body_html_cached.
- Supporting .page-article__date / .page-article__back CSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 06:53:01 -05:00
parent 1e5e3252c6
commit 149c6580f4
4 changed files with 107 additions and 3 deletions

View File

@@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.models.entities import Page
from app.models.entities import Page, Post
from app.models.posts import PostSummary
from app.services.contact import ContactService
from app.services.hcaptcha import HCaptchaService
@@ -117,6 +117,33 @@ def home(
)
@router.get(
"/posts/{slug}",
response_class=HTMLResponse,
summary="Single blog post",
)
def post_detail(
slug: str,
request: Request,
templates: Jinja2Templates = Depends(get_templates),
posts: PostService = Depends(get_post_service),
) -> HTMLResponse:
"""Render a single published post by slug.
Drafts and unknown slugs return 404 (same response so a mistyped
URL cannot be used to enumerate unpublished titles).
"""
post: Post | None = posts.get_published_by_slug(slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
return templates.TemplateResponse(
request,
"public/post.html",
{"active_nav": "home", "post": post},
)
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
def about(
request: Request,