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