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 fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings 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.models.posts import PostSummary
from app.services.contact import ContactService from app.services.contact import ContactService
from app.services.hcaptcha import HCaptchaService 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") @router.get("/about", response_class=HTMLResponse, summary="About the farm")
def about( def about(
request: Request, request: Request,

View File

@@ -21,9 +21,9 @@ from typing import Optional
from fastapi import Request from fastapi import Request
from sqlalchemy import Engine, text 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.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 from app.services.cache import TTLCache
@@ -155,6 +155,36 @@ class PostService:
self._cache.set(safe_limit, summaries) self._cache.set(safe_limit, summaries)
return 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: def invalidate_all(self) -> None:
"""Drop every cached post-list entry. """Drop every cached post-list entry.

View File

@@ -348,6 +348,17 @@ a:focus-visible {
margin-bottom: var(--space-2); 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 + card. */
.post-list { .post-list {
display: grid; 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 %}