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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user