diff --git a/app/routes/public.py b/app/routes/public.py index 51a0e2a..6aca31a 100644 --- a/app/routes/public.py +++ b/app/routes/public.py @@ -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, diff --git a/app/services/posts.py b/app/services/posts.py index 9f3e4b9..5817850 100644 --- a/app/services/posts.py +++ b/app/services/posts.py @@ -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. diff --git a/app/static/css/site.css b/app/static/css/site.css index 58af2dd..e1982c4 100644 --- a/app/static/css/site.css +++ b/app/static/css/site.css @@ -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; diff --git a/app/templates/public/post.html b/app/templates/public/post.html new file mode 100644 index 0000000..99d3674 --- /dev/null +++ b/app/templates/public/post.html @@ -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 }} — Chicken Babies R Us{% endblock %} +{% block meta_description %}{{ post.title }} — a post from Chicken Babies R Us.{% endblock %} + +{% block content %} +
+
+

{{ post.title }}

+ {% if post.published_at %} + + {% endif %} +
+ + {{ post.body_html_cached | safe }} +
+ +

+ ← Back to all posts +

+{% endblock %}