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>
This commit is contained in:
2026-04-22 06:47:06 -05:00
parent 67c848f329
commit d9090f5055
16 changed files with 1671 additions and 39 deletions

View File

@@ -7,7 +7,9 @@ Phase 2 scope:
- ``GET /about`` — DB-backed; loads the ``about`` row from the
``pages`` table via :class:`PageService` and
renders its ``body_html_cached`` directly.
- ``GET /contact`` — inert contact form UI + optional ``mailto:`` link.
- ``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
@@ -17,16 +19,21 @@ is constructed in Python.
from __future__ import annotations
import re
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
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``
@@ -37,6 +44,19 @@ router: APIRouter = APIRouter(tags=["public"])
_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.
@@ -49,6 +69,31 @@ def get_templates(request: Request) -> Jinja2Templates:
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,
@@ -110,12 +155,12 @@ def contact(
templates: Jinja2Templates = Depends(get_templates),
settings: Settings = Depends(get_settings),
) -> HTMLResponse:
"""Render the inert contact page.
"""Render the contact page.
The form fields are marked ``disabled`` and the form has no ``method``
attribute — it is UI-only. If ``ADMIN_CONTACT_EMAIL`` is configured
the template renders a ``mailto:`` link so visitors still have a way
to reach the farm before Phase 5 wires up the real POST flow.
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,
@@ -126,10 +171,176 @@ def contact(
# 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,