Files
chicken_babies_site/app/routes/public.py
Phillip Tarrant d9090f5055 feat: phase 5 contact form — hCaptcha, honeypot, rate limit, notify
Working /contact POST flow: honeypot → hCaptcha server-verify →
field validation → SlowAPI 3/hr IP rate limit → contact_submissions
row → best-effort Resend notification (Reply-To = submitter) →
generic success page. Spam paths don't persist and render the same
success page (anti-enumeration). Send failures don't break the
request path — the row is already durable.

New services: HCaptchaService (async httpx + dev fallback),
ContactService. EmailService gains send_contact_notification.
Production config validator now requires ADMIN_CONTACT_EMAIL,
HCAPTCHA_SECRET, HCAPTCHA_SITE_KEY. 23 new tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 06:47:06 -05:00

360 lines
13 KiB
Python

"""Public-facing HTTP routes.
Phase 2 scope:
- ``GET /`` — blog index; posts come from :class:`PostService`
which now reads the ``posts`` table.
- ``GET /about`` — DB-backed; loads the ``about`` row from the
``pages`` table via :class:`PageService` and
renders its ``body_html_cached`` directly.
- ``GET /contact`` — form UI + optional ``mailto:`` link.
- ``POST /contact`` — Phase 5 live submission endpoint (hCaptcha +
honeypot + rate-limit + persist + notify).
- ``GET /shop`` — "Coming soon" card.
Every handler is thin: it resolves its dependencies, calls any service
methods it needs, and delegates rendering to a Jinja template. No HTML
is constructed in Python.
"""
from __future__ import annotations
import re
import structlog
from fastapi import APIRouter, Depends, Form, HTTPException, Request
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.posts import PostSummary
from app.services.contact import ContactService
from app.services.hcaptcha import HCaptchaService
from app.services.pages import PageService, get_page_service
from app.services.posts import PostService, get_post_service
from app.services.rate_limit import limiter
# Module-level router. Mounted without a prefix by ``app.main.create_app``
# so the routes below live at the site root.
router: APIRouter = APIRouter(tags=["public"])
# One module-level logger is fine; structlog handles context binding.
_log = structlog.get_logger(__name__)
# Same loose email shape used on the admin login form. Intentionally
# permissive: the mapper layer / Resend does the real work; we only
# want to reject obvious junk before hitting the service.
_EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# Field length guards — matched on the server regardless of the
# HTML5 attributes the template emits.
_NAME_MAX: int = 80
_EMAIL_MAX: int = 254
_MESSAGE_MIN: int = 10
_MESSAGE_MAX: int = 4000
def get_templates(request: Request) -> Jinja2Templates:
"""Return the shared :class:`Jinja2Templates` instance.
The singleton is attached to ``app.state.templates`` in
:func:`app.main.create_app`. Looking it up via ``request.app.state``
(rather than importing from ``app.main``) avoids an import cycle and
keeps the handlers test-friendly — tests can swap out the instance by
mutating ``app.state.templates`` before issuing requests.
"""
return request.app.state.templates
def _get_hcaptcha_service(request: Request) -> HCaptchaService:
"""Return the app-scoped :class:`HCaptchaService`."""
return request.app.state.hcaptcha_service
def _get_contact_service(request: Request) -> ContactService:
"""Return the app-scoped :class:`ContactService`."""
return request.app.state.contact_service
def _client_ip(request: Request) -> str:
"""Best-effort client IP from the request.
Starlette already respects ``X-Forwarded-For`` via the proxy-headers
middleware Uvicorn installs with ``--proxy-headers``; that means
``request.client.host`` is the real client IP.
"""
return request.client.host if request.client else ""
def _user_agent(request: Request) -> str:
"""Return the submitted User-Agent header (empty string if missing)."""
return request.headers.get("user-agent", "")
@router.get("/", response_class=HTMLResponse, summary="Blog index")
def home(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
posts: PostService = Depends(get_post_service),
) -> HTMLResponse:
"""Render the blog index with any published posts.
Phase 2: the service now returns real ``PostSummary`` rows from
SQLite. The homepage template still handles the empty-list case
gracefully in case a future deployment starts with an unseeded
database.
"""
# Query the service layer for the most recent published posts. The
# template handles the empty-list case; we do not branch here.
summaries: list[PostSummary] = posts.list_published()
return templates.TemplateResponse(
request,
"public/home.html",
{"posts": summaries, "active_nav": "home"},
)
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
def about(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
pages: PageService = Depends(get_page_service),
) -> HTMLResponse:
"""Render the About page from the ``pages`` table.
Phase 2 rewires this route: the body comes from ``pages.about``
(seeded at first boot and editable via Phase 4 admin). If the
page row is missing — which should not happen after a successful
seed — we log the anomaly and return a generic 500 without
leaking implementation details (CWE-200).
"""
page: Page | None = pages.get_by_slug("about")
if page is None:
# Anomalous: the seed should always have populated this row.
# Log with enough context to diagnose without exposing it to
# the visitor.
_log.error("about_page_missing", slug="about")
raise HTTPException(
status_code=500,
detail="The About page is temporarily unavailable.",
)
return templates.TemplateResponse(
request,
"public/about.html",
{"active_nav": "about", "page": page},
)
@router.get("/contact", response_class=HTMLResponse, summary="Contact the farm")
def contact(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
settings: Settings = Depends(get_settings),
) -> HTMLResponse:
"""Render the contact page.
Phase 5 wires the form up to a real POST handler; this GET now
returns the blank form. ``ADMIN_CONTACT_EMAIL`` is still surfaced
as a secondary ``mailto:`` link for visitors who prefer their own
inbox over the form.
"""
return templates.TemplateResponse(
request,
"public/contact.html",
{
"active_nav": "contact",
# None when unset; the template hides the mailto link in that
# case. We pass the value through settings so tests can
# override it without touching environment variables.
"contact_email": settings.admin_contact_email,
"hcaptcha_site_key": settings.hcaptcha_site_key,
"errors": {},
"form": {"name": "", "email": "", "message": ""},
"form_error": None,
},
)
@router.post("/contact", summary="Submit the contact form")
@limiter.limit("3/hour")
async def contact_submit(
request: Request,
name: str = Form(default=""),
email: str = Form(default=""),
message: str = Form(default=""),
website: str = Form(default=""),
templates: Jinja2Templates = Depends(get_templates),
settings: Settings = Depends(get_settings),
hcaptcha: HCaptchaService = Depends(_get_hcaptcha_service),
contact_service: ContactService = Depends(_get_contact_service),
) -> Response:
"""Handle the contact-form POST.
Flow (strict order — each stage short-circuits):
1. Honeypot: if ``website`` is non-empty → silent spam. Audit
``contact_spam_rejected`` with reason ``honeypot``; render the
generic success page so the bot operator cannot tell they were
filtered.
2. hCaptcha: call :meth:`HCaptchaService.verify`. On False → audit
``contact_spam_rejected`` with reason ``hcaptcha``; render the
success page. Same anti-enumeration rationale.
3. Validate fields. On any error → re-render the form with inline
error messages + HTTP 400 + submitted values preserved.
4. Persist: :meth:`ContactService.record_submission`. After this
point the message is durable even if the email fails.
5. Notify: :meth:`ContactService.send_notification`. Best-effort;
if it raises (it never should — service is defensive) we still
fall through to the success page.
6. Render ``public/contact_sent.html``.
CSRF is NOT required here: the endpoint is pre-auth, has no
session to hijack, and adding a cookie dance would break the
first-contact UX. SlowAPI + hCaptcha + honeypot are the controls.
"""
ip = _client_ip(request)
ua = _user_agent(request)
audit = request.app.state.audit_service
# --- Stage 1: honeypot ------------------------------------------------
if (website or "").strip():
audit.record(
"contact_spam_rejected",
ip=ip,
user_agent=ua,
detail={"reason": "honeypot"},
)
return templates.TemplateResponse(
request,
"public/contact_sent.html",
{"active_nav": "contact"},
)
# --- Stage 2: hCaptcha ------------------------------------------------
token = (
(request.headers.get("h-captcha-response") or "")
or ""
)
# hCaptcha's widget posts the token as a form field named
# ``h-captcha-response``. Starlette's ``Request.form()`` is async;
# re-reading it here is fine because FastAPI caches the parsed form
# for the lifetime of the request.
form_data = await request.form()
captcha_token = str(form_data.get("h-captcha-response", token) or "")
ok = await hcaptcha.verify(captcha_token, ip)
if not ok:
audit.record(
"contact_spam_rejected",
ip=ip,
user_agent=ua,
detail={"reason": "hcaptcha"},
)
return templates.TemplateResponse(
request,
"public/contact_sent.html",
{"active_nav": "contact"},
)
# --- Stage 3: validation ---------------------------------------------
clean_name = (name or "").strip()
clean_email = (email or "").strip()
clean_message = (message or "").strip()
errors: dict[str, str] = {}
if not clean_name:
errors["name"] = "Please enter your name."
elif len(clean_name) > _NAME_MAX:
errors["name"] = f"Name must be {_NAME_MAX} characters or fewer."
if not clean_email:
errors["email"] = "Please enter your email address."
elif len(clean_email) > _EMAIL_MAX or not _EMAIL_RE.match(clean_email):
errors["email"] = "Please enter a valid email address."
if not clean_message:
errors["message"] = "Please include a message."
elif len(clean_message) < _MESSAGE_MIN:
errors["message"] = (
f"Message must be at least {_MESSAGE_MIN} characters."
)
elif len(clean_message) > _MESSAGE_MAX:
errors["message"] = (
f"Message must be {_MESSAGE_MAX} characters or fewer."
)
if errors:
return templates.TemplateResponse(
request,
"public/contact.html",
{
"active_nav": "contact",
"contact_email": settings.admin_contact_email,
"hcaptcha_site_key": settings.hcaptcha_site_key,
"errors": errors,
"form": {
"name": clean_name,
"email": clean_email,
"message": clean_message,
},
"form_error": None,
},
status_code=400,
)
# --- Stage 4: persist -------------------------------------------------
submission = contact_service.record_submission(
name=clean_name,
email=clean_email,
message=clean_message,
ip=ip,
user_agent=ua,
)
# Audit the successful submission. Store the message length + a
# short preview so on-call can tell whether a flood is substantive
# without exposing full bodies in the audit log.
audit.record(
"contact_submitted",
email=clean_email,
ip=ip,
user_agent=ua,
detail={
"submission_id": submission.id,
"message_length": len(clean_message),
"message_preview": clean_message[:40],
},
)
# --- Stage 5: notify --------------------------------------------------
contact_service.send_notification(submission)
# --- Stage 6: success page -------------------------------------------
return templates.TemplateResponse(
request,
"public/contact_sent.html",
{"active_nav": "contact"},
)
@router.get("/shop", response_class=HTMLResponse, summary="Shop placeholder")
def shop(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
) -> HTMLResponse:
"""Render the "Coming soon" placeholder for the future shop.
The nav link to ``/shop`` remains enabled so visitors can preview the
offering; it is the landing page itself that signals the shop is not
yet live. Phase 7 replaces this template with the real catalog.
"""
return templates.TemplateResponse(
request,
"public/shop.html",
{"active_nav": "shop"},
)