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

@@ -66,8 +66,10 @@ from app.services.admin_pages import AdminPagesService
from app.services.admin_posts import AdminPostsService
from app.services.audit import AuditService
from app.services.auth import AuthService
from app.services.contact import ContactService
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
from app.services.email import EmailService
from app.services.hcaptcha import HCaptchaService
from app.services.markdown import MarkdownService
from app.services.media import MediaService
from app.services.pages import PageService
@@ -236,6 +238,20 @@ def create_app() -> FastAPI:
application.state.session_service = session_service
application.state.auth_service = auth_service
# --- Phase 5 wiring -----------------------------------------------------
# Public contact form. HCaptchaService has no DB; ContactService
# persists via the shared engine and delegates email to the
# existing EmailService so the dev-fallback semantics match.
hcaptcha_service = HCaptchaService(settings)
contact_service = ContactService(
engine=engine,
email=email_service,
audit=audit_service,
settings=settings,
)
application.state.hcaptcha_service = hcaptcha_service
application.state.contact_service = contact_service
# --- Phase 4 wiring -----------------------------------------------------
# CSRF signer: separate salt so a session cookie never validates
# as a CSRF token (domain separation via salt).