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,

View File

@@ -21,9 +21,9 @@ from typing import Optional
from fastapi import Request
from sqlalchemy import Engine, text
from app.models.entities import PostStatus
from app.models.entities import Post, PostStatus
from app.models.posts import PostSummary
from app.models.mappers import _parse_datetime
from app.models.mappers import _parse_datetime, row_to_post
from app.services.cache import TTLCache
@@ -155,6 +155,36 @@ class PostService:
self._cache.set(safe_limit, summaries)
return summaries
def get_published_by_slug(self, slug: str) -> Optional[Post]:
"""Return the published :class:`Post` for ``slug`` or ``None``.
Drafts are invisible on the public path: the status filter
belongs in SQL so a mistyped slug and a draft slug are
indistinguishable to the caller (same 404 upstream).
SQL safety: ``slug`` and ``status`` are bound parameters; no
string interpolation.
"""
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 slug = :slug AND status = :status"
" LIMIT 1"
),
{
"slug": slug,
"status": PostStatus.PUBLISHED.value,
},
)
.mappings()
.first()
)
return row_to_post(row) if row is not None else None
def invalidate_all(self) -> None:
"""Drop every cached post-list entry.

View File

@@ -348,6 +348,17 @@ a:focus-visible {
margin-bottom: var(--space-2);
}
.page-article__date {
font-size: 0.875rem;
color: var(--c-sky-deep);
font-family: var(--font-sans);
}
.page-article__back {
margin-top: var(--space-4);
max-width: 48rem;
}
/* Post list + card. */
.post-list {
display: grid;

View File

@@ -0,0 +1,36 @@
{#
Single blog post detail page.
Receives:
- post : app.models.entities.Post
- active_nav : str "home"
``post.body_html_cached`` is the bleach-sanitized output of the
Markdown pipeline (allowlisted tags/attrs/protocols only), so
rendering with ``| safe`` does not reintroduce XSS risk. Same
rationale as ``public/about.html``.
#}
{% extends "public/base.html" %}
{% block title %}{{ post.title }} &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}{{ post.title }} &mdash; a post from Chicken Babies R Us.{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">{{ post.title }}</h1>
{% if post.published_at %}
<time class="page-article__date"
datetime="{{ post.published_at.isoformat() }}">
{{ post.published_at.strftime("%B %-d, %Y") }}
</time>
{% endif %}
</header>
{{ post.body_html_cached | safe }}
</article>
<p class="page-article__back">
<a href="/">&larr; Back to all posts</a>
</p>
{% endblock %}